// ==UserScript== // @name Kata Studio // @namespace https://codewars.com/ // @version 1.8.0 // @description A workbench for the Codewars kata trainer: an AI tutor that hints instead of solving, in-place kata translation, real formatters for C, C++, Python, Kotlin and Rust, a trainer laid out for the code rather than the chrome, and a practice history on the dashboard. // @author NihilDigit // @match https://www.codewars.com/* // @match https://codewars.com/* // @icon https://www.codewars.com/favicon.ico // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @connect * // @require https://cdn.jsdelivr.net/npm/marked@18.0.10/lib/marked.umd.js#sha256=6szuL7n7OywJ6HOlUE2oJQeFDZ5ne9cgEirEnioDmCo= // @require https://cdn.jsdelivr.net/npm/dompurify@3.4.14/dist/purify.min.js#sha256=wvJupPwNiBQcmqQw61FayG/OWUGM7r2F+kdbh6jWw+Y= // @updateURL https://update.greasyfork.org/scripts/579184/Kata%20Studio.meta.js // @downloadURL https://update.greasyfork.org/scripts/579184/Kata%20Studio.user.js // @run-at document-start // @license MIT // ==/UserScript== (function () { "use strict"; const STYLE_ID = "cw-polish-style"; const PROFILE_STYLE_ID = "cw-polish-profile-style"; const HIDDEN_MARK = "data-cw-polish-hidden"; const SPARKS_ID = "cw-polish-sparks"; const PANEL_ID = "cw-polish-ai"; const DIALOG_ID = "cw-polish-settings"; const CONFIRM_ID = "cw-polish-confirm"; const SIDE_TOGGLE_ID = "cw-polish-side-toggle"; const TESTS_TOGGLE_ID = "cw-polish-tests-toggle"; // Deliberately still the old name. The prefix is the key under which a reader's // endpoint, key and model already live; renaming it would silently orphan their // settings, and the name is not visible to anyone. const STORAGE_PREFIX = "prettier-codewars:"; // The fallback for languages with no formatter of their own; the ones that have a // formatter take their indent width from it, see FORMATTERS. const INDENT_SIZE = 2; const TRANSLATION_PREFIX = "prettier-codewars:translation:"; // Everything here is built for the trainer: the CSS reclaims the top strip and // relays out the two editor columns, and there is nothing on the dashboard, a // profile or the kata list for it to lay out — it only eats their header. The // match rule stays site-wide because Tampermonkey cannot match a client-side // route change; the gate is here instead. const TRAINER_PATH = /^\/kata\/[^/]+\/train(\/|$)/; function onTrainerPage() { return TRAINER_PATH.test(location.pathname); } const defaultConfig = { hidePromotions: true, useMapleMono: true, tuneCodeMirror: true, lineWrapping: true, autoFormat: true, typingSparks: true, deleteAnnihilation: true, rainbowBrackets: true, // Not in the settings menu: the toggle in the tab bar is how this is set, and it // is kept only so a reload comes back to the layout the reader was working in. sideCollapsed: false, testsCollapsed: false, editorFontSize: "15px", editorLineHeight: 1.55, compactHeader: true, // Off by default: a 16-inch tablet is normally docked to a keyboard, where the // bar only eats vertical space. It earns its place when the keyboard is away. touchToolbar: false, aiEnabled: true, aiBaseUrl: "https://api.openai.com/v1", aiApiKey: "", aiModel: "gpt-5.6-luna", aiTargetLanguage: "简体中文", aiPanelWidth: 400, aiPanelOpen: false, aiAutoTranslate: false, dashboardHistory: true, hideDashboardNoise: true }; // Settings that only affect runtime behavior and can be applied without a reload. const liveSettings = new Set([ "aiBaseUrl", "aiApiKey", "aiModel", "aiTargetLanguage", "aiPanelWidth", "aiPanelOpen", "aiAutoTranslate", "sideCollapsed", "testsCollapsed" ]); const menuOptions = [ ["hidePromotions", "Hide promotions"], ["useMapleMono", "Maple Mono font"], ["tuneCodeMirror", "CodeMirror polish"], ["lineWrapping", "Line wrapping"], ["autoFormat", "AutoFormat"], ["typingSparks", "Typing sparks"], ["deleteAnnihilation", "Delete annihilation"], ["rainbowBrackets", "Rainbow brackets"], ["aiEnabled", "AI tutor"], ["touchToolbar", "Touch symbol bar"], ["compactHeader", "Compact kata header"], ["dashboardHistory", "Dashboard history"], ["hideDashboardNoise", "Hide allies and forum feed"] ]; function readSetting(key) { const fallback = defaultConfig[key]; const storageKey = STORAGE_PREFIX + key; try { if (typeof GM_getValue === "function") { return GM_getValue(storageKey, fallback); } const value = window.localStorage.getItem(storageKey); return value === null ? fallback : JSON.parse(value); } catch (_error) { return fallback; } } function writeSetting(key, value, reload = true) { const storageKey = STORAGE_PREFIX + key; try { if (typeof GM_setValue === "function") { GM_setValue(storageKey, value); } else { window.localStorage.setItem(storageKey, JSON.stringify(value)); } } catch (_error) { return; } config[key] = value; // A reload is how the CSS-level settings take effect; the AI settings are read // at call time, so reloading there would only throw away the open conversation. if (reload && !liveSettings.has(key)) { window.location.reload(); } } function readConfig() { return Object.fromEntries(Object.keys(defaultConfig).map((key) => [key, readSetting(key)])); } const config = readConfig(); const effectColors = { sparks: ["#ffd166", "#ff9f1c", "#ff6b35", "#e5383b", "#fff3b0"] }; function buildCss() { return ` ${ config.hidePromotions ? ` [data-cw-polish-hidden="true"] { display: none !important; } .partner-display, .promoted { display: none !important; } ` : "" } ${ config.useMapleMono ? ` @font-face { font-family: "Maple Mono Web"; font-style: normal; font-weight: 400; font-display: swap; src: local("Maple Mono NF"), local("MapleMono NF"), local("Maple Mono Normal NF"), url("https://cdn.jsdelivr.net/fontsource/fonts/maple-mono@latest/latin-400-normal.woff2") format("woff2"); } @font-face { font-family: "Maple Mono Web"; font-style: italic; font-weight: 400; font-display: swap; src: local("Maple Mono NF Italic"), local("MapleMono NF Italic"), local("Maple Mono Normal NF Italic"), url("https://cdn.jsdelivr.net/fontsource/fonts/maple-mono@latest/latin-400-italic.woff2") format("woff2"); } .CodeMirror, .CodeMirror pre, .CodeMirror code, .CodeMirror-line, .CodeMirror-line *, pre, code, kbd, samp { font-family: "Maple Mono Web", "Maple Mono NF", "Maple Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important; font-variant-ligatures: contextual common-ligatures !important; } ` : "" } ${ config.tuneCodeMirror ? ` .CodeMirror { font-size: ${config.editorFontSize} !important; line-height: ${config.editorLineHeight} !important; } .CodeMirror-lines, .CodeMirror pre.CodeMirror-line, .CodeMirror pre.CodeMirror-line-like { line-height: ${config.editorLineHeight} !important; } .CodeMirror-cursor { transition: left 80ms ease-out, top 80ms ease-out, height 80ms ease-out !important; } .CodeMirror-activeline-background { background: rgb(255 255 255 / 5.5%) !important; } .CodeMirror-hscrollbar { display: none !important; } .CodeMirror-scroll { overflow-x: hidden !important; } /* CodeMirror's own foldmarker is a blue arrow under a purple glow, and its line-height of .3 squashes the line the fold sits on. All three are replaced here: the glyph itself is hidden with font-size 0 and re-stated in ::after, because the character comes from the addon's widget and CSS cannot reach it. */ .CodeMirror-foldmarker { color: var(--color-ui-text-lc, #c9c9c9) !important; text-shadow: none !important; line-height: inherit !important; font-size: 0 !important; background: rgb(128 128 128 / 16%) !important; border: 1px solid var(--color-ui-border, rgb(255 255 255 / 12.5%)) !important; border-radius: 4px !important; padding: 0 5px !important; margin: 0 2px !important; cursor: pointer !important; } .CodeMirror-foldmarker::after { content: "⋯"; font-size: ${config.editorFontSize}; line-height: 1; } .CodeMirror-foldgutter-open, .CodeMirror-foldgutter-folded { color: var(--color-ui-text-lc, #c9c9c9) !important; opacity: 0.55; } .CodeMirror-foldgutter-open:hover, .CodeMirror-foldgutter-folded:hover { opacity: 1; } ` : "" } ${ config.rainbowBrackets ? ` /* The light theme is the absence of html.dark, so the light values are the unprefixed ones and the dark theme overrides them. */ :root { --cw-rb-1: #b58900; --cw-rb-2: #2f6fbe; --cw-rb-3: #1f8a8a; --cw-rb-4: #8e4ec6; --cw-rb-5: #c2570c; --cw-rb-6: #3f8f3f; --cw-rb-bad: #c0392b; } html.dark { --cw-rb-1: #e5c07b; --cw-rb-2: #61afef; --cw-rb-3: #56b6c2; --cw-rb-4: #c678dd; --cw-rb-5: #d19a66; --cw-rb-6: #98c379; --cw-rb-bad: #e06c75; } .CodeMirror .cw-rb-1 { color: var(--cw-rb-1) !important; } .CodeMirror .cw-rb-2 { color: var(--cw-rb-2) !important; } .CodeMirror .cw-rb-3 { color: var(--cw-rb-3) !important; } .CodeMirror .cw-rb-4 { color: var(--cw-rb-4) !important; } .CodeMirror .cw-rb-5 { color: var(--cw-rb-5) !important; } .CodeMirror .cw-rb-6 { color: var(--cw-rb-6) !important; } .CodeMirror .cw-rb-bad { color: var(--cw-rb-bad) !important; text-decoration: underline wavy var(--cw-rb-bad) !important; text-underline-offset: 3px; } ` : "" } /* One height for the card, whichever of the two tabs is up: the column's, with a floor for a short window. Content shorter than that leaves the card at its own size rather than shrinking to the text, and content longer than it scrolls inside — which is also what gives the console a height to be h-full of. The footer and promo blocks below the text are hidden, so nothing else is competing for the space. */ #description_area .description.h-full > :not(.description-content) { display: none !important; } #description_area > .h-full > div:has(.description) { height: calc(100vh - ${config.compactHeader ? 162 : 195}px) !important; min-height: 200px !important; } #description_area .description.h-full, #description_area div:has(> .console-output) { height: 100% !important; max-height: 100% !important; overflow: hidden !important; } #description_area .description-content.p-4 { height: 100% !important; max-height: 100% !important; overflow-y: auto !important; } /* Instructions and Output are two tabs over one panel, so collapsing is one state for both: the panel body and the tab links go, the bar stays as a strip carrying the toggle back, and the editors take the width that frees up. */ #cw-polish-side-toggle, #cw-polish-tests-toggle { display: grid; place-items: center; width: 26px; height: 26px; margin-left: auto; margin-right: 6px; border-radius: 6px; color: var(--color-ui-text-lc, #c9c9c9); cursor: pointer; } #cw-polish-side-toggle:hover, [data-cw-tests-head]:hover #cw-polish-tests-toggle { background: rgb(128 128 128 / 16%); color: var(--color-ui-text, #efefef); } #cw-polish-side-toggle svg, #cw-polish-tests-toggle svg { width: 16px; height: 16px; } /* The whole header row is the target, not just the chevron: it is a 36px bar with nothing else on it. */ [data-cw-tests-head] { display: flex !important; align-items: center; justify-content: space-between; cursor: pointer; } /* At the far right of a full-width bar the chevron was easy to miss, and a header that folds has to say so where the eye already is: on the label. */ [data-cw-tests-head] { justify-content: flex-start; gap: 6px; } #cw-polish-tests-toggle { width: 20px; height: 20px; margin: 0; } /* The editor column, laid out once instead of in three ways at cross purposes. Codewars sizes the two blocks as 60% / 40% of the column, writes the editor's own height into it in px from JS — measured once, so every fold in this file invalidates it — and leaves 60px of padding under the lot for its own layout to overrun into, which is why TEST and ATTEMPT sit half under the bottom of the window. A flex column states all of it in one place: the two block headers take what they need, the editors take the rest, the buttons keep their row, and nothing overruns. It also makes folding the sample tests one rule rather than a second layout. */ #editors { display: flex !important; flex-direction: column !important; height: 100% !important; padding-bottom: 12px !important; } #editors #code_container { flex: 1 1 auto !important; } #editors #code_container, #editors #fixture_container { display: flex !important; flex-direction: column !important; min-height: 0 !important; height: auto !important; padding-bottom: 0 !important; } #editors .code-editor-wrapper { flex: 1 1 auto !important; min-height: 0 !important; height: auto !important; padding-bottom: 0 !important; } /* The px height Codewars wrote is overridden rather than corrected: with the wrapper's height settled by the flex column, 100% is the whole answer. */ #editors .text-editor-container, #editors .text-editor { height: 100% !important; } /* Solution, Sample Tests and the buttons stood 56px apart — 20px of padding under the editor, 20px more under its block, and a 16px margin, three reasons for one gap. The space is worth more to the editors. */ #editors #fixture_container, #editors > div:last-child { margin-top: 8px !important; } #editors #fixture_container { flex: 0 0 40% !important; } #editors > div:last-child { flex: 0 0 auto !important; } /* Two ids beat one id and a class, so the rule this overrides has to be matched selector for selector. */ html.cw-tests-collapsed #editors #fixture_container { flex: 0 0 auto !important; } html.cw-tests-collapsed #editors #fixture_container .code-editor-wrapper { display: none !important; } html.cw-side-collapsed #description_area { width: 30px !important; min-width: 30px !important; flex: 0 0 30px !important; padding-right: 0 !important; overflow: visible !important; } html.cw-side-collapsed #description_area > .h-full > :not(:first-child) { display: none !important; } html.cw-side-collapsed #description_area > .h-full > :first-child > div { display: none !important; } html.cw-side-collapsed #cw-polish-side-toggle { margin: 0; } html.cw-side-collapsed #editors_area { width: calc(100% - 30px) !important; padding-left: 4px !important; } #cw-polish-sparks { position: fixed; inset: 0; z-index: 2147483647; pointer-events: none; overflow: hidden; } /* The panel consumes Codewars' own custom properties rather than copying their values, so it follows the site's light/dark toggle without being told. The fallbacks are the dark-theme values, for the moment before their stylesheet lands and for any page that does not define them. */ :root { --cw-ai-width: ${config.aiPanelWidth}px; --cw-ai-bg: var(--color-ui-bg, #16171b); --cw-ai-surface: var(--color-ui-section, #222327); --cw-ai-code: var(--color-ui-code-bg, #131414); --cw-ai-line: var(--color-ui-border, rgb(255 255 255 / 12.5%)); --cw-ai-text: var(--color-ui-text, #efefef); --cw-ai-muted: var(--color-ui-text-lc, #c9c9c9); --cw-ai-accent: var(--color-ui-link-text-hover, #6795de); --cw-ai-danger: var(--color-ui-hover-important, #b1361e); --cw-ai-input: var(--color-ui-input-bg, rgb(0 0 0 / 10%)); /* A wash rather than a colour, so it darkens a dark surface and stays quiet on a light one without needing a second token. */ --cw-ai-well: rgb(128 128 128 / 16%); /* Two radii, matching the site: 4px on controls, 8px on surfaces. */ --cw-ai-radius: 8px; --cw-ai-radius-control: 4px; /* Tailwind's shadow-lg, which is the only elevation Codewars itself uses. */ --cw-ai-shadow: 0 10px 15px -3px rgb(0 0 0 / 30%), 0 4px 6px -4px rgb(0 0 0 / 30%); --cw-ai-z-tab: 2147482000; --cw-ai-z-panel: 2147483000; --cw-ai-z-dialog: 2147483100; --cw-ai-mono: "Maple Mono Web", "Maple Mono NF", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } /* The trainer lays out in normal flow inside #app, so padding on the body is enough to make Codewars reflow its own panes — no JS layout maths needed. Whether it can afford to is decided by fitPanel() against the measured width of the editor column, not by a width breakpoint: see there for why. */ html.cw-ai-docked body { padding-right: var(--cw-ai-width) !important; box-sizing: border-box !important; } html.cw-ai-docked #main_header { right: var(--cw-ai-width) !important; } /* No scrim in overlay mode. Reading a hint and editing code is one back-and-forth motion, so the page underneath has to stay live; a dimmed, click-blocking backdrop would break exactly the loop the panel exists to serve. */ html.cw-ai-open:not(.cw-ai-docked) #${PANEL_ID} { box-shadow: var(--cw-ai-shadow); } /* Docked, the panel is a pane of the layout and comes back on the next fold or resize however it is dismissed, so it does not offer to be closed. Overlaid, it is the reader's to put away and the control is there. */ html.cw-ai-docked #${PANEL_ID} [data-act="close"] { display: none !important; } #${PANEL_ID} { position: fixed; top: 0; right: 0; bottom: 0; z-index: var(--cw-ai-z-panel); display: flex; width: var(--cw-ai-width); flex-direction: column; border-left: 1px solid var(--cw-ai-line); background: var(--cw-ai-bg); color: var(--cw-ai-text); font-family: inherit; font-size: 14px; transform: translateX(0); transition: transform 220ms cubic-bezier(.22, 1, .36, 1); } #${PANEL_ID}[data-open="false"] { transform: translateX(100%); box-shadow: none; } /* touch-action:none is what stops a drag on the handle from being stolen by the page's own scrolling on a touchscreen. */ #${PANEL_ID} .cw-ai-handle { position: absolute; top: 0; left: -8px; bottom: 0; display: flex; width: 16px; align-items: center; justify-content: center; cursor: col-resize; background: transparent; touch-action: none; } #${PANEL_ID} .cw-ai-handle::before { content: ""; width: 2px; height: 40px; border-radius: 2px; background: var(--cw-ai-line); transition: background 140ms ease, height 140ms ease; } #${PANEL_ID} .cw-ai-handle:hover::before, #${PANEL_ID} .cw-ai-handle[data-dragging="true"]::before { height: 88px; background: var(--cw-ai-accent); } /* Header and footer sit on the section colour, the log on the page colour — the same figure/ground split as the trainer's own Solution pane. */ #${PANEL_ID} .cw-ai-head { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--cw-ai-line); background: var(--cw-ai-surface); } #${PANEL_ID} .cw-ai-title { flex: 1; overflow: hidden; font-size: 14px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; color: var(--cw-ai-text); } /* Codewars' button: 12px, 4px radius, .2px tracking, flat. Never a border and a shadow on the same control. */ #${PANEL_ID} button, .cw-dialog button { appearance: none; padding: 9px 10px 7px; border: 1px solid transparent; border-radius: var(--cw-ai-radius-control); background: var(--cw-ai-well); color: var(--cw-ai-accent); cursor: pointer; font: inherit; font-size: 12px; line-height: 1; letter-spacing: .2px; transition: background 120ms ease, border-color 120ms ease, color 120ms ease; } #${PANEL_ID} button:hover:not(:disabled), .cw-dialog button:hover:not(:disabled) { border-color: var(--cw-ai-accent); } #${PANEL_ID} button:disabled, .cw-dialog button:disabled { cursor: default; opacity: .45; } /* The primary fill comes from Codewars' own button tokens, foreground included: the site flips that foreground between themes (dark ink on the dark theme's blue, light ink on the light theme's), and borrowing the pair keeps the contrast it already solved for. */ #${PANEL_ID} button.cw-ai-primary, .cw-dialog button.cw-ai-primary { border-color: var(--color-ui-button-border, #6795de); background: var(--color-ui-button-bg, #6795de); color: var(--color-ui-button-text, #131414); } #${PANEL_ID} button.cw-ai-primary:hover:not(:disabled), .cw-dialog button.cw-ai-primary:hover:not(:disabled) { border-color: var(--color-ui-button-bg-hover, #7ca4e3); background: var(--color-ui-button-bg-hover, #7ca4e3); } /* Icon buttons carry no background until touched, so a row of them reads as chrome rather than as four competing calls to action. */ #${PANEL_ID} .cw-ai-icon { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border-color: transparent; background: transparent; color: var(--cw-ai-muted); } #${PANEL_ID} .cw-ai-icon svg { width: 16px; height: 16px; display: block; } #${PANEL_ID} .cw-ai-icon:hover:not(:disabled) { border-color: transparent; background: var(--cw-ai-well); color: var(--cw-ai-accent); } /* Fetching the solutions page takes a moment before the reply starts streaming, which is otherwise a press with no feedback at all. */ #${PANEL_ID} .cw-ai-icon[data-busy="true"] { opacity: 0.45; pointer-events: none; } #${PANEL_ID} .cw-ai-icon[data-state="on"] { background: var(--cw-ai-well); color: var(--cw-ai-accent); } #${PANEL_ID} .cw-ai-icon[data-state="busy"] svg { animation: cw-ai-pulse 1.1s ease-in-out infinite; } @keyframes cw-ai-pulse { 50% { opacity: .35; } } #${PANEL_ID} :focus-visible, .cw-dialog :focus-visible { outline: 2px solid var(--cw-ai-accent); outline-offset: 1px; } #${PANEL_ID} .cw-ai-log { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 12px 10px; display: flex; flex-direction: column; gap: 12px; scroll-behavior: smooth; } #${PANEL_ID} .cw-ai-empty { margin: auto 0; padding: 0 6px; color: var(--cw-ai-muted); font-size: 13px; line-height: 1.7; text-align: center; text-wrap: pretty; } #${PANEL_ID} .cw-ai-empty p { margin: 0 0 .8em; } #${PANEL_ID} .cw-ai-empty p:last-child { margin-bottom: 0; } #${PANEL_ID} .cw-ai-empty strong { color: var(--cw-ai-accent); font-weight: 700; } #${PANEL_ID} .cw-ai-msg { border-radius: var(--cw-ai-radius); font-size: 14px; line-height: 1.65; overflow-wrap: anywhere; } #${PANEL_ID} .cw-ai-msg[data-role="user"] { align-self: flex-end; max-width: 88%; padding: 8px 10px; background: var(--cw-ai-surface); white-space: pre-wrap; } /* What was attached to a turn, kept as a receipt above the question. */ #${PANEL_ID} .cw-ai-msg-chips { margin-bottom: 4px; color: var(--cw-ai-muted); font-size: 11.5px; letter-spacing: .2px; } #${PANEL_ID} .cw-ai-msg[data-role="assistant"] { padding: 0; } #${PANEL_ID} .cw-ai-msg[data-role="error"] { padding: 8px 10px; background: color-mix(in srgb, var(--cw-ai-danger) 16%, transparent); color: var(--cw-ai-text); white-space: pre-wrap; } #${PANEL_ID} .cw-ai-msg p { margin: 0 0 .7em; } #${PANEL_ID} .cw-ai-msg > :last-child { margin-bottom: 0; } /* Codewars resets list markers globally, so they have to be restored here or every bullet list in an answer renders as flat lines. */ #${PANEL_ID} .cw-ai-msg ul { margin: 0 0 .7em; padding-left: 1.35em; list-style: disc outside !important; } #${PANEL_ID} .cw-ai-msg ol { margin: 0 0 .7em; padding-left: 1.35em; list-style: decimal outside !important; } #${PANEL_ID} .cw-ai-msg li { margin: .2em 0; } #${PANEL_ID} .cw-ai-msg h1, #${PANEL_ID} .cw-ai-msg h2, #${PANEL_ID} .cw-ai-msg h3 { margin: 1em 0 .4em; font-size: 14px; font-weight: 700; color: var(--cw-ai-text); } /* Codewars' code background is tuned to sit on its section colour; the log sits on the page colour, where that same value all but disappears. Surface plus a hairline is what actually reads as a code block here, in both themes. */ #${PANEL_ID} .cw-ai-msg code { padding: .1em .35em; border-radius: var(--cw-ai-radius-control); background: var(--cw-ai-surface); font-family: var(--cw-ai-mono); font-size: .9em; } #${PANEL_ID} .cw-ai-msg pre { margin: 0 0 .7em; padding: 9px 10px; border: 1px solid var(--cw-ai-line); border-radius: var(--cw-ai-radius-control); background: var(--cw-ai-surface); overflow-x: auto; } #${PANEL_ID} .cw-ai-msg pre code { padding: 0; background: none; font-size: 12.5px; line-height: 1.6; } #${PANEL_ID} .cw-ai-msg blockquote { margin: 0 0 .7em; padding-left: .8em; border-left: 1px solid var(--cw-ai-line); color: var(--cw-ai-muted); } #${PANEL_ID} .cw-ai-msg a { color: var(--cw-ai-accent); } #${PANEL_ID} .cw-ai-msg hr { margin: .9em 0; border: 0; border-top: 1px solid var(--cw-ai-line); } /* marked emits GFM tables; they are rare in an answer but must not blow the panel's width open when they appear. */ #${PANEL_ID} .cw-ai-msg table { display: block; overflow-x: auto; margin: 0 0 .7em; border-collapse: collapse; font-size: 12.5px; } #${PANEL_ID} .cw-ai-msg th, #${PANEL_ID} .cw-ai-msg td { padding: 4px 8px; border: 1px solid var(--cw-ai-line); text-align: left; } #${PANEL_ID} .cw-ai-msg th { background: var(--cw-ai-surface); font-weight: 700; } #${PANEL_ID} .cw-ai-cursor::after { content: ""; display: inline-block; width: .5em; height: 1em; margin-left: .12em; background: var(--cw-ai-accent); vertical-align: text-bottom; animation: cw-ai-blink 1s steps(2, start) infinite; } @keyframes cw-ai-blink { to { visibility: hidden; } } #${PANEL_ID} .cw-ai-compose { display: flex; flex-direction: column; gap: 6px; padding: 8px 10px; border-top: 1px solid var(--cw-ai-line); background: var(--cw-ai-surface); } #${PANEL_ID} .cw-ai-input-row { display: flex; gap: 6px; align-items: flex-end; } #${PANEL_ID} .cw-ai-chips { display: flex; flex-wrap: wrap; gap: 4px; } #${PANEL_ID} .cw-ai-chips[hidden] { display: none; } #${PANEL_ID} .cw-ai-chip { display: inline-flex; align-items: center; gap: 5px; max-width: 100%; padding: 3px 3px 3px 7px; border: 1px solid var(--cw-ai-line); border-radius: var(--cw-ai-radius-control); background: var(--cw-ai-bg); font-size: 11.5px; line-height: 1.4; } #${PANEL_ID} .cw-ai-chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #${PANEL_ID} .cw-ai-chip-meta { color: var(--cw-ai-muted); white-space: nowrap; } #${PANEL_ID} .cw-ai-chip-x { display: grid; place-items: center; width: 18px; height: 18px; padding: 0; border-color: transparent; background: transparent; color: var(--cw-ai-muted); } #${PANEL_ID} .cw-ai-chip-x svg { width: 11px; height: 11px; } #${PANEL_ID} .cw-ai-chip-x:hover { border-color: transparent; color: var(--cw-ai-text); } #${PANEL_ID} .cw-ai-send { flex: 0 0 auto; width: 34px; height: 34px; border-color: var(--color-ui-button-border, #6795de); background: var(--color-ui-button-bg, #6795de); color: var(--color-ui-button-text, #131414); } #${PANEL_ID} .cw-ai-send:hover:not(:disabled) { border-color: var(--color-ui-button-bg-hover, #7ca4e3); background: var(--color-ui-button-bg-hover, #7ca4e3); color: var(--color-ui-button-text, #131414); } /* The floating "Ask" affordance over a selection, and its twin pinned to the test output. Both hand the same shape of context to the panel. */ #cw-polish-ai-selection, #cw-polish-ai-output { display: inline-flex; align-items: center; gap: 4px; padding: 5px 9px 5px 7px; border: 1px solid var(--color-ui-button-border, #6795de); border-radius: var(--cw-ai-radius-control); background: var(--color-ui-button-bg, #6795de); color: var(--color-ui-button-text, #131414); cursor: pointer; font-family: inherit; font-size: 12px; line-height: 1; letter-spacing: .2px; box-shadow: var(--cw-ai-shadow); touch-action: manipulation; } #cw-polish-ai-selection svg, #cw-polish-ai-output svg { width: 14px; height: 14px; } #cw-polish-ai-selection { position: fixed; z-index: var(--cw-ai-z-dialog); } #cw-polish-ai-selection[hidden], #cw-polish-ai-output[hidden] { display: none; } .cw-ai-output-host { position: relative; } #cw-polish-ai-output { position: absolute; top: 8px; right: 14px; z-index: 5; } #${PANEL_ID} textarea { flex: 1; max-height: 168px; min-height: 34px; padding: 8px 9px; border: 1px solid var(--cw-ai-line); border-radius: var(--cw-ai-radius-control); background: var(--cw-ai-input); color: var(--cw-ai-text); font-family: inherit; font-size: 14px; line-height: 1.45; resize: none; } #${PANEL_ID} textarea::placeholder { color: var(--cw-ai-muted); } #${PANEL_ID} .cw-ai-log::-webkit-scrollbar, #${PANEL_ID} textarea::-webkit-scrollbar { width: 9px; } #${PANEL_ID} .cw-ai-log::-webkit-scrollbar-thumb, #${PANEL_ID} textarea::-webkit-scrollbar-thumb { border: 3px solid transparent; border-radius: 9px; background: var(--cw-ai-line); background-clip: content-box; } #cw-polish-ai-tab { position: fixed; top: 50%; right: 0; z-index: var(--cw-ai-z-tab); padding: 12px 6px; border: 1px solid var(--cw-ai-line); border-right: none; border-radius: var(--cw-ai-radius) 0 0 var(--cw-ai-radius); background: var(--cw-ai-surface); color: var(--cw-ai-accent); cursor: pointer; font-family: inherit; font-size: 12px; letter-spacing: .2px; writing-mode: vertical-rl; transform: translateY(-50%); transition: background 140ms ease, color 140ms ease; } #cw-polish-ai-tab:hover { background: var(--color-ui-button-bg, #6795de); color: var(--color-ui-button-text, #131414); } html.cw-ai-open #cw-polish-ai-tab { display: none; } .cw-dialog { position: fixed; inset: 0; z-index: var(--cw-ai-z-dialog); display: flex; align-items: center; justify-content: center; padding: 24px; background: rgb(0 0 0 / 60%); color: var(--cw-ai-text); font-family: inherit; } .cw-dialog .cw-set-card { width: min(480px, 100%); max-height: 100%; overflow-y: auto; padding: 18px 20px 16px; border-radius: var(--cw-ai-radius); background: var(--cw-ai-surface); box-shadow: var(--cw-ai-shadow); } .cw-dialog h2 { margin: 0 0 4px; font-size: 16px; font-weight: 700; } .cw-dialog .cw-set-hint { margin: 0 0 16px; color: var(--cw-ai-muted); font-size: 13px; line-height: 1.6; } .cw-dialog .cw-set-hint code { padding: .1em .35em; border-radius: var(--cw-ai-radius-control); background: var(--cw-ai-code); font-family: var(--cw-ai-mono); font-size: .9em; } .cw-dialog label { display: block; margin-bottom: 12px; font-size: 13px; } .cw-dialog label > span { display: block; margin-bottom: 4px; color: var(--cw-ai-muted); } .cw-dialog input, .cw-dialog select { width: 100%; padding: 8px 9px; border: 1px solid var(--cw-ai-line); border-radius: var(--cw-ai-radius-control); background: var(--cw-ai-input); color: var(--cw-ai-text); font-family: var(--cw-ai-mono); font-size: 13px; } .cw-dialog .cw-set-foot { display: flex; justify-content: flex-end; gap: 6px; margin-top: 16px; } .cw-dialog .cw-set-status { flex: 1; align-self: center; font-size: 12px; line-height: 1.5; color: var(--cw-ai-muted); } /* Coarse pointers: every control grows to a real finger target, and double-tap zoom is off so a quick second tap counts as a second tap. */ @media (pointer: coarse) { #${PANEL_ID} button, .cw-dialog button, #cw-polish-ai-tab, #cw-polish-touchbar button { min-height: 44px; touch-action: manipulation; } #${PANEL_ID} .cw-ai-icon { width: 44px; height: 44px; } #${PANEL_ID} .cw-ai-send { width: 44px; height: 44px; } #${PANEL_ID} .cw-ai-chip-x { width: 26px; height: 26px; min-height: 0; } #cw-polish-ai-selection, #cw-polish-ai-output { min-height: 40px; padding: 8px 12px 8px 10px; } #${PANEL_ID} textarea, .cw-dialog input, .cw-dialog select { /* Below 16px iPadOS and Android both zoom the viewport on focus. */ font-size: 16px; min-height: 44px; } #${PANEL_ID} .cw-ai-handle::before { height: 88px; } #cw-polish-ai-tab { padding: 20px 9px; } } @media (prefers-reduced-motion: reduce) { #${PANEL_ID}, #${PANEL_ID} button, #${PANEL_ID} .cw-ai-handle::before, #cw-polish-ai-tab, #description_area .description-content [data-cw-translated] { transition-duration: 1ms !important; } #${PANEL_ID} .cw-ai-log { scroll-behavior: auto; } #${PANEL_ID} .cw-ai-cursor::after { animation: none; } } #cw-polish-touchbar { display: flex; gap: 5px; overflow-x: auto; overscroll-behavior-x: contain; padding: 6px 8px; border-bottom: 1px solid var(--cw-ai-line); background: var(--cw-ai-surface); scrollbar-width: none; -webkit-overflow-scrolling: touch; } #cw-polish-touchbar::-webkit-scrollbar { display: none; } #cw-polish-touchbar button { flex: 0 0 auto; min-width: 38px; padding: 8px 10px; border: 1px solid var(--cw-ai-line); border-radius: var(--cw-ai-radius-control); background: var(--cw-ai-well); color: var(--cw-ai-text); cursor: pointer; font-family: var(--cw-ai-mono); font-size: 14px; line-height: 1; touch-action: manipulation; user-select: none; } #cw-polish-touchbar button:active { border-color: var(--cw-ai-accent); color: var(--cw-ai-accent); } #cw-polish-touchbar button[data-wide] { font-family: inherit; font-size: 12px; letter-spacing: .2px; } #description_area .description-content [data-cw-translated] { transition: opacity 160ms ease; } #description_area .description-content[data-cw-translating="true"] { opacity: .55; } ${ config.compactHeader ? ` /* On the trainer the top strip is a page title naming the page you are already on, plus an account bar nobody opens mid-kata. What actually reserves the strip is body's padding-top, so that is what shrinks; the pane's own calc() height grows by the same amount, which is where the space ends up. The bar itself is not hidden but moved, into the menu below, so its own click handlers keep working. */ body.play_view h1.page-title { display: none !important; } body.play_view { padding-top: 22px !important; } /* The strip above the panes is the body padding plus the kata title block, and the title block changes height when its stats row wraps — which it does as soon as the AI panel narrows the column. A constant here was six pixels off and clipped the TEST row; trackTitleHeight() measures the block and keeps --cw-title-h. */ body.play_view #app div:has(> #description_area) { height: calc(100vh - 22px - var(--cw-title-h, 106px)) !important; } /* The trigger joins the editor's own control list rather than floating over the page, so it inherits that group's 50px cell and moves with the layout when the AI panel squeezes the page. */ #cw-polish-menu-item { position: relative; display: block; margin-right: 0; } #cw-polish-menu { display: grid; grid-auto-flow: column; place-items: center; gap: 6px; min-width: 50px; height: 50px; padding: 0 8px; border: 0; background: transparent; color: var(--cw-ai-muted); cursor: pointer; opacity: .85; transition: opacity 140ms ease, background 140ms ease; } #cw-polish-menu img { display: block; width: 30px; height: 30px; border-radius: var(--cw-ai-radius-control); } /* The rank badge is the one number worth keeping at a glance; the honor total is not, so it is dropped rather than shrunk. */ #cw-polish-menu .small-hex { transform: scale(.85); transform-origin: center; } #cw-polish-menu svg { width: 18px; height: 18px; display: block; } #cw-polish-menu:hover, #cw-polish-menu:focus-visible, #cw-polish-menu[aria-expanded="true"] { opacity: 1; background: var(--cw-ai-well); } #cw-polish-menu-panel { position: absolute; top: calc(100% + 4px); right: 0; z-index: 60; min-width: 168px; padding: 6px; border: 1px solid var(--cw-ai-line); border-radius: var(--cw-ai-radius); background: var(--cw-ai-surface); box-shadow: var(--cw-ai-shadow); white-space: nowrap; } #cw-polish-menu-panel[hidden] { display: none; } /* The relocated bar was laid out as a horizontal strip in the viewport corner. None of that survives: it becomes a plain vertical menu, with the labels the icon-only original never had room for. */ #cw-polish-menu-panel #main_header { position: static !important; width: auto !important; height: auto !important; margin: 0 !important; padding: 0 !important; border: 0 !important; background: transparent !important; transform: none !important; opacity: 1 !important; visibility: visible !important; } /* One row spec for the whole menu. The lifted items and the profile links come from two different parts of Codewars' markup with different padding, heights and glyph elements, so everything is reset and re-stated here rather than patched per group — that is what makes the rows line up. */ #cw-polish-menu-panel #main_header .items { display: flex !important; flex-direction: column !important; align-items: stretch !important; gap: 0 !important; margin: 0 !important; padding: 0 !important; } #cw-polish-menu-panel #main_header .items > li, #cw-polish-menu-panel .profile-item .menu-body li { position: relative !important; display: block !important; float: none !important; width: 100% !important; min-width: 0 !important; height: auto !important; min-height: 0 !important; margin: 0 !important; padding: 0 !important; border: 0 !important; line-height: normal !important; } #cw-polish-menu-panel #main_header .items > li > a, #cw-polish-menu-panel .profile-item .menu-body a { display: flex !important; align-items: center !important; gap: 10px !important; box-sizing: border-box !important; width: 100% !important; min-width: 0 !important; max-width: 100% !important; height: 34px !important; min-height: 0 !important; margin: 0 !important; padding: 0 8px !important; border: 0 !important; border-radius: var(--cw-ai-radius-control) !important; overflow: hidden !important; color: var(--cw-ai-text) !important; font-size: 13px !important; line-height: 1 !important; text-align: left !important; } #cw-polish-menu-panel #main_header .items > li > a:hover, #cw-polish-menu-panel .profile-item .menu-body a:hover { background: var(--cw-ai-well); } /* Every glyph gets the same slot, whether it is an icon font, an svg or an img. */ #cw-polish-menu-panel #main_header .items > li > a > *, #cw-polish-menu-panel .profile-item .menu-body a > * { flex: 0 0 18px !important; width: 18px !important; height: 18px !important; min-width: 0 !important; margin: 0 !important; font-size: 16px !important; line-height: 18px !important; text-align: center !important; } #cw-polish-menu-panel .js-toggle-dark-mode { width: 100% !important; } #cw-polish-menu-panel .item-list > a.js-toggle-dark-mode::after { content: "Theme"; } #cw-polish-menu-panel .stars-item > a::after { content: "Starred kata"; } #cw-polish-menu-panel #notifications_drawer > a::after { content: "Notifications"; } /* The starred and notification drawers still belong to Codewars; they just open to the left, which is the side with room once the menu sits at the edge. */ #cw-polish-menu-panel .items > li > .menu { top: 0 !important; right: 100% !important; left: auto !important; margin-right: 6px; } /* The profile row's own container chain is shrink-to-fit, which would leave its links narrower than the rows above them. */ #cw-polish-menu-panel .profile-item, #cw-polish-menu-panel .profile-item > .menu, #cw-polish-menu-panel .profile-item .menu-body, #cw-polish-menu-panel .profile-item .menu-body ul { box-sizing: border-box !important; width: 100% !important; } #cw-polish-menu-panel .profile-item > .menu { position: static !important; display: block !important; min-width: 0 !important; margin: 0 !important; padding: 0 !important; border: 0 !important; background: transparent !important; box-shadow: none !important; } #cw-polish-menu-panel .profile-item .menu-body { padding: 0 !important; } #cw-polish-menu-panel .profile-item .menu-body ul { margin: 0 !important; padding: 0 !important; list-style: none !important; } /* The account links are one group, the site controls another; a single rule between them beats a line under every link. */ #cw-polish-menu-panel .profile-item { margin-top: 5px !important; padding-top: 5px !important; border-top: 1px solid var(--cw-ai-line) !important; } /* Must outrank the row spec above, which also sets display. The avatar is the trigger now, so its row inside the menu would be a duplicate. */ #cw-polish-menu-panel #main_header .items > li > a#header_profile_link, #cw-polish-menu-panel .profile-pic { display: none !important; } ${ config.hidePromotions ? ` #cw-polish-menu-panel .profile-item .menu-body a[href="/subscription"] { display: none !important; } ` : "" } @media (pointer: coarse) { #cw-polish-menu { touch-action: manipulation; } } ` : "" } @media (max-width: 1100px) { body.play_view #cc_play_view .game-title .panel > .flex.flex-col.md\\:flex-row { flex-direction: column !important; } body.play_view #cc_play_view .game-title .w-full.md\\:w-5\\/12, body.play_view #cc_play_view .game-title .w-full.md\\:w-7\\/12 { width: 100% !important; } body.play_view #cc_play_view .game-title .w-full.md\\:w-7\\/12.pt-4.md\\:pl-4 { display: flex !important; flex-wrap: wrap !important; align-items: stretch !important; gap: 8px !important; padding-left: 0 !important; padding-top: 12px !important; } body.play_view #cc_play_view .game-title .language-selector, body.play_view #cc_play_view .game-title #language_dd, body.play_view #cc_play_view .game-title #language_version { flex: 1 1 180px !important; min-width: 160px !important; max-width: none !important; } body.play_view #cc_play_view .game-title .w-full.md\\:w-7\\/12.pt-4.md\\:pl-4 > a { display: flex !important; flex: 0 0 auto !important; } } `; } // Codewars' own responsive rules leave the profile's Rank Breakdown overlapping // below 1000px. Every selector here is scoped to the profile page and none of it // touches the trainer, so this is the one sheet that stays on site-wide. function buildProfileCss() { return ` @media (max-width: 1000px) { body#users.show_view main .bg-ui-section .flex.flex-col.md\\:flex-row { flex-direction: column !important; align-items: stretch !important; } body#users.show_view main .bg-ui-section .flex.flex-col.md\\:flex-row > .w-full.md\\:w-6\\/12 { width: 100% !important; padding-left: 0 !important; } body#users.show_view #report .honor-chart-container { display: grid !important; grid-template-columns: 220px minmax(220px, 1fr) !important; align-items: center !important; column-gap: 32px !important; width: max-content !important; max-width: 100% !important; margin: 16px auto 0 !important; } body#users.show_view #report #honor_chart { grid-column: 1 !important; } body#users.show_view #report .honor-chart-center { left: 55px !important; top: 55px !important; } body#users.show_view #report .honor-chart-container > .md\\:w-64 { position: static !important; grid-column: 2 !important; width: auto !important; height: auto !important; overflow: visible !important; padding-left: 0 !important; margin-top: 0 !important; } } @media (max-width: 720px) { body#users.show_view main .bg-ui-section .flex.flex-col.md\\:flex-row { flex-direction: column !important; } body#users.show_view main .bg-ui-section .flex.flex-col.md\\:flex-row > .w-full.md\\:w-6\\/12 { width: 100% !important; } body#users.show_view #report .honor-chart-container { grid-template-columns: 1fr !important; justify-items: center !important; row-gap: 18px !important; width: 100% !important; } body#users.show_view #report .honor-chart-container > .md\\:w-64 { grid-column: 1 !important; } } `; } const adSelectors = [ "#house_ad_display", ".cw-ad", ".ads-container", "[id*='ad_display' i]", "[id*='ad-container' i]", "[class*='ad-container' i]", "a[href*='/ads/']", "a[href*='house_srv']", "iframe[src*='ad' i]", "ins.adsbygoogle", ".partner-display", ".promoted", ".my-4.flex.flex-col.md\\:flex-row.space-y-4.md\\:space-y-0.md\\:space-x-4", ".mt-4.flex.flex-col.md\\:flex-row.space-y-4.md\\:space-y-0.md\\:space-x-4" ]; const classSetBlocklist = [ ["my-4", "flex", "flex-col", "md:flex-row", "space-y-4", "md:space-y-0", "md:space-x-4"], ["mt-4", "flex", "flex-col", "md:flex-row", "space-y-4", "md:space-y-0", "md:space-x-4"], ["description-footer", "flex", "flex-row"], ["w-256", "max-w-full", "mx-auto", "my-4"], ["partner-display"], ["promoted"] ]; function injectStyle() { let style = document.getElementById(STYLE_ID); if (!style) { style = document.createElement("style"); style.id = STYLE_ID; (document.head || document.documentElement).append(style); } style.id = STYLE_ID; style.textContent = buildCss(); } // Dashboard-only, and kept out of buildCss() so the trainer's layout rules cannot // reach a page that has no editor panes to lay out. Colours come from Codewars' own // custom properties, so the card follows the site's light/dark toggle. function buildDashboardCss() { return ` /* removeAds() and hideDashboardNoise() only mark nodes; the rule that acts on the mark lives in the trainer sheet, which never reaches this page. Unconditional, because the allies box is marked even when promotions are left alone. */ [${HIDDEN_MARK}="true"] { display: none !important; } ${ config.hidePromotions ? ` .partner-display, .promoted { display: none !important; } ` : "" } #${HISTORY_CARD_ID} { background: var(--color-ui-section, #222327); color: var(--color-ui-text, #efefef); border-radius: 8px; padding: 15px; margin: 15px 0; } #${HISTORY_CARD_ID} .cw-hist-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; } #${HISTORY_CARD_ID} .cw-hist-title { font-weight: 600; font-size: 15px; color: var(--color-ui-text-hc, #fff); } #${HISTORY_CARD_ID} .cw-hist-meta { flex: 1; font-size: 12px; opacity: 0.7; } #${HISTORY_CARD_ID}[data-loading="true"] .cw-hist-meta::after { content: " · refreshing…"; } #${HISTORY_CARD_ID} .cw-hist-heat { margin-bottom: 14px; } /* Seven columns of equal fraction: the grid is as wide as the card, whatever the window does. Days are rows here rather than columns — a month is five weeks, and five square columns spanning this width would be a grid thirteen hundred px tall. */ /* Fifty-three columns of one fraction each: the wall is exactly as wide as the card, and the cells land near sixteen pixels rather than being fixed there. */ /* minmax(0, …), not 1fr: a plain 1fr cannot shrink below the track's min-content, and a cell with aspect-ratio supplies one, so the wall overflowed the card. */ #${HISTORY_CARD_ID} .cw-hist-months { display: grid; grid-template-columns: repeat(${CALENDAR_WEEKS}, minmax(0, 1fr)); gap: 3px; } #${HISTORY_CARD_ID} .cw-hist-grid { display: grid; grid-template-rows: repeat(7, auto); grid-template-columns: 26px; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 3px; } #${HISTORY_CARD_ID} .cw-hist-grid > span { font-size: 9px; line-height: 1; align-self: center; opacity: 0.55; } #${HISTORY_CARD_ID} .cw-hist-months { margin-left: 29px; margin-bottom: 3px; font-size: 9px; opacity: 0.55; } /* A label is wider than its column and overflows into the next few, which is what GitHub does too — it marks where the month starts. */ #${HISTORY_CARD_ID} .cw-hist-months span { white-space: nowrap; } #${HISTORY_CARD_ID} .cw-hist-cell { aspect-ratio: 1; border-radius: 3px; background: rgba(128, 128, 128, 0.14); } /* Codewars' own ramp: the legacy red through the orange to the honor gold. */ #${HISTORY_CARD_ID} .cw-hist-cell[data-level="none"] { background: transparent; } /* One hue at four lightnesses. Codewars' red is hsl(10 71% 41%); a ramp that also moved the hue — red to orange to gold — could not be put in order by eye. The direction follows the theme: busier days move away from the page, so on the light theme the ramp darkens and on the dark theme it lightens. Codewars marks its dark theme with a class on ; light is the absence of it. */ #${HISTORY_CARD_ID} .cw-hist-cell[data-level="1"] { background: hsl(10 71% 84%); } #${HISTORY_CARD_ID} .cw-hist-cell[data-level="2"] { background: hsl(10 71% 70%); } #${HISTORY_CARD_ID} .cw-hist-cell[data-level="3"] { background: hsl(10 71% 55%); } #${HISTORY_CARD_ID} .cw-hist-cell[data-level="4"] { background: hsl(10 71% 41%); } html.dark #${HISTORY_CARD_ID} .cw-hist-cell[data-level="1"] { background: hsl(10 71% 19%); } html.dark #${HISTORY_CARD_ID} .cw-hist-cell[data-level="2"] { background: hsl(10 71% 30%); } html.dark #${HISTORY_CARD_ID} .cw-hist-cell[data-level="3"] { background: hsl(10 71% 41%); } html.dark #${HISTORY_CARD_ID} .cw-hist-cell[data-level="4"] { background: hsl(10 71% 56%); } /* No inner scroll: ten rows is the card, and the rest is a link away. A scrollbar inside a card on a scrolling page is two scrolls competing for the same wheel. */ #${HISTORY_CARD_ID} .cw-hist-list { list-style: none; margin: 0; padding: 0; } #${HISTORY_CARD_ID} .cw-hist-stats { display: flex; gap: 14px; align-items: center; } #${HISTORY_CARD_ID} .cw-hist-stat { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; } #${HISTORY_CARD_ID} .cw-hist-stat b { font-weight: 600; } #${HISTORY_CARD_ID} .cw-hist-foot { margin-top: 10px; font-size: 12px; } #${HISTORY_CARD_ID} .cw-hist-foot a { color: var(--color-ui-link-text-hover, #6795de); text-decoration: none; } #${HISTORY_CARD_ID} .cw-hist-foot a:hover { text-decoration: underline; } #${HISTORY_CARD_ID} .cw-hist-subhead { margin: 4px 0 6px; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; opacity: 0.55; } #${HISTORY_CARD_ID} .cw-hist-subhead-done { margin-top: 14px; } #${HISTORY_CARD_ID} .cw-hist-more { opacity: 0.7; } #${HISTORY_CARD_ID} .cw-hist-since { font-size: 11px; opacity: 0.55; } #${HISTORY_CARD_ID} .cw-hist-day { margin: 10px 0 3px; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; opacity: 0.55; } #${HISTORY_CARD_ID} .cw-hist-day:first-child { margin-top: 0; } #${HISTORY_CARD_ID} .cw-hist-item { display: flex; align-items: center; gap: 10px; padding: 4px 8px; border-radius: 6px; } #${HISTORY_CARD_ID} .cw-hist-item:hover { background: rgba(128, 128, 128, 0.12); } #${HISTORY_CARD_ID} .cw-hist-name { flex: 1; /* A flex item's floor is its content width unless this says otherwise, so a long kata title widens the row and the whole card scrolls sideways. */ min-width: 0; color: inherit; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #${HISTORY_CARD_ID} .cw-hist-name:hover { text-decoration: underline; } /* The site's own hexagon goes in here. The slot keeps its width while empty so the kata names stay lined up before the ranks land. */ #${HISTORY_CARD_ID} .cw-hist-kyu { flex: 0 0 34px; height: 26px; } #${HISTORY_CARD_ID} .cw-hist-langs { display: flex; gap: 6px; } #${HISTORY_CARD_ID} .cw-hist-lang { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; padding: 1px 8px; border-radius: 999px; background: rgba(128, 128, 128, 0.18); } /* Codewars' own icon font, already loaded by the page. */ #${HISTORY_CARD_ID} .cw-hist-lang i { font-size: 12px; opacity: 0.85; } #${HISTORY_CARD_ID} .cw-hist-train { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; font-size: 11px; padding: 2px 10px; border-radius: 999px; text-decoration: none; background: var(--color-ui-button-bg, #3a3b40); color: var(--color-ui-button-text, #fff); opacity: 0.75; } #${HISTORY_CARD_ID} .cw-hist-item:hover .cw-hist-train { opacity: 1; } #${HISTORY_CARD_ID} .cw-hist-train i { font-size: 10px; } #${HISTORY_CARD_ID} .cw-hist-empty { font-size: 13px; opacity: 0.7; } `; } function injectDashboardStyle() { if (document.getElementById(DASH_STYLE_ID)) return; const style = document.createElement("style"); style.id = DASH_STYLE_ID; style.textContent = buildDashboardCss(); (document.head || document.documentElement).append(style); } function injectProfileStyle() { if (document.getElementById(PROFILE_STYLE_ID)) return; const style = document.createElement("style"); style.id = PROFILE_STYLE_ID; style.textContent = buildProfileCss(); (document.head || document.documentElement).append(style); } let menuRegistered = false; function registerSettingsMenu() { if (typeof GM_registerMenuCommand !== "function" || menuRegistered) return; menuRegistered = true; menuOptions.forEach(([key, label]) => { const state = config[key] ? "On" : "Off"; GM_registerMenuCommand(`${state} - ${label}`, () => writeSetting(key, !config[key])); }); GM_registerMenuCommand(`Set editor font size (${config.editorFontSize})`, () => { const value = window.prompt("Editor font size, for example 15px:", config.editorFontSize); if (value && /^\d+(?:\.\d+)?(?:px|rem|em)$/.test(value.trim())) { writeSetting("editorFontSize", value.trim()); } }); GM_registerMenuCommand(`Set editor line height (${config.editorLineHeight})`, () => { const value = window.prompt("Editor line height, for example 1.55:", String(config.editorLineHeight)); const numberValue = Number(value); if (Number.isFinite(numberValue) && numberValue >= 1 && numberValue <= 3) { writeSetting("editorLineHeight", numberValue); } }); GM_registerMenuCommand("AI settings…", () => openSettings()); GM_registerMenuCommand("Reset Kata Studio settings", () => { Object.entries(defaultConfig).forEach(([key, value]) => writeSetting(key, value, false)); window.location.reload(); }); } function hide(node) { if (node && node.nodeType === Node.ELEMENT_NODE) { node.setAttribute(HIDDEN_MARK, "true"); } } function isProtected(element) { return Boolean( element.closest("html, body") === element || element.matches("main, #app, #sidenav, #main_header, #description_area, #editors_area") || element.closest("#description_area, #editors_area") || // A node that contains the page's own content is a layout column, not an ad's // box. nearestAdContainer climbs to div[class*='w-full'], and on the dashboard // that is the whole main column: one house ad would hide the entire page. element.querySelector?.("#description_area, #editors_area, #trainer, #discourse") ); } function hideSafely(element) { if (!element || isProtected(element)) return; hide(element); } function nearestAdContainer(element) { return ( element.closest( "#house_ad_display, .ads-container, .cw-ad, aside, article, section, .panel, div[class*='md:w-'], div[class*='w-full']" ) || element ); } function removeAds(root = document) { if (!config.hidePromotions) return; for (const selector of adSelectors) { root.querySelectorAll(selector).forEach((element) => hideSafely(nearestAdContainer(element))); } root.querySelectorAll("div").forEach((element) => { if (classSetBlocklist.some((classSet) => classSet.every((name) => element.classList.contains(name)))) { hideSafely(element); } }); } // The panes are sized against the kata title block, whose height moves when its stats // row wraps: opening the AI panel narrows the column and adds a line. A ResizeObserver // is what keeps the two in step; a constant cannot. let titleObserver = null; let observedTitle = null; function trackTitleHeight() { if (!config.compactHeader) return; const title = document.querySelector(".game-title"); if (!title) return; publishTitleHeight(); // Codewars re-renders this block, and an observer left on the node it replaced // never fires again: --cw-title-h then keeps the height the title had before its // stats row wrapped, the column is sized 40px too tall, and the row of buttons // hangs past the bottom of the window. if (title === observedTitle) return; titleObserver?.disconnect(); observedTitle = title; titleObserver = new ResizeObserver(publishTitleHeight); titleObserver.observe(title); } function publishTitleHeight() { const title = document.querySelector(".game-title"); const height = title ? Math.round(title.getBoundingClientRect().height) : 0; if (height) document.documentElement.style.setProperty("--cw-title-h", `${height}px`); } // Instructions and Output are two tabs over a single panel, so there is one thing // to collapse, not two. The toggle joins Codewars' own tab bar rather than floating // over it, which is also why its click is delegated from `document`: the bar is // re-rendered and anything bound to the node itself would stop responding. function sideTabBar() { const bar = document.querySelector("#description_area > div > div:first-child"); return bar?.querySelector("a") ? bar : null; } function buildSideToggle() { const bar = sideTabBar(); if (!bar || document.getElementById(SIDE_TOGGLE_ID)) { applySideCollapsed(); return; } const button = document.createElement("button"); button.id = SIDE_TOGGLE_ID; button.type = "button"; bar.append(button); applySideCollapsed(); } function applySideCollapsed() { const collapsed = Boolean(config.sideCollapsed); document.documentElement.classList.toggle("cw-side-collapsed", collapsed); const button = document.getElementById(SIDE_TOGGLE_ID); if (!button) return; button.innerHTML = icon(collapsed ? "close" : "chevronLeft"); button.title = collapsed ? "Show instructions and output" : "Collapse instructions and output"; } function toggleSide() { writeSetting("sideCollapsed", !config.sideCollapsed); applySideCollapsed(); afterLayoutChange(); } // Resizing the window changes the answer fitPanel() gave. Our own reflow dispatches // a resize of its own, which lands here too — harmless, because a fit that changes // nothing dispatches nothing. function attachViewportFit() { let timer = 0; window.addEventListener("resize", () => { if (!active || timer) return; timer = window.setTimeout(() => { timer = 0; fitPanel(); }, 150); }); } function attachSideToggle() { document.addEventListener("click", (event) => { if (!active) return; if (event.target.closest?.(`#${SIDE_TOGGLE_ID}`)) { event.preventDefault(); toggleSide(); return; } if (event.target.closest?.("[data-cw-tests-head]")) { event.preventDefault(); toggleTests(); } }); } // The sample tests are read once and then in the way for the rest of the kata, so // they fold to their own header. A kata with no fixture at all — Codewars marks that // one `is-only-editor` — has nothing to fold. function buildTestsToggle() { const head = document.querySelector("#fixture_container > div:first-child"); if (!head || document.getElementById(TESTS_TOGGLE_ID)) { applyTestsCollapsed(); return; } const button = document.createElement("button"); button.id = TESTS_TOGGLE_ID; button.type = "button"; head.setAttribute("data-cw-tests-head", ""); head.prepend(button); applyTestsCollapsed(); } function applyTestsCollapsed() { const collapsed = Boolean(config.testsCollapsed); document.documentElement.classList.toggle("cw-tests-collapsed", collapsed); const button = document.getElementById(TESTS_TOGGLE_ID); if (!button) return; button.innerHTML = icon(collapsed ? "close" : "chevronDown"); button.title = collapsed ? "Show the sample tests" : "Collapse the sample tests"; } function toggleTests() { writeSetting("testsCollapsed", !config.testsCollapsed); applyTestsCollapsed(); afterLayoutChange(); } function afterLayoutChange() { document.querySelectorAll(".CodeMirror").forEach((element) => element.CodeMirror?.refresh()); // A narrower column wraps the title's stats row, which is what the column's own // height is measured against. publishTitleHeight(); fitPanel(); } function tuneEditors(root = document) { const mirrors = []; if (root.matches?.(".CodeMirror")) { mirrors.push(root); } if (root.querySelectorAll) { mirrors.push(...root.querySelectorAll(".CodeMirror")); } mirrors.forEach((element) => { const cm = element.CodeMirror; if (!cm) return; // Each of these carries its own switch, so none of them hangs off another's. if (config.tuneCodeMirror) tuneEditor(cm); scheduleInitialAutoFormat(cm, element); if (config.typingSparks || config.deleteAnnihilation) attachEffects(cm); attachRainbowBrackets(cm); }); } function tuneEditor(cm) { const optionsKey = [config.lineWrapping, false].join(":"); if (cm.__cwPolishOptionsKey !== optionsKey) { cm.__cwPolishOptionsKey = optionsKey; cm.setOption("lineWrapping", config.lineWrapping); cm.setOption("indentWithTabs", false); cm.refresh(); } // Outside the guard above, on purpose: Codewars sets its own indent width after // we have set ours — measured on Kotlin, where it puts back 4 over the 2 ktfmt // emits — and the boot ladder calls this again afterwards, which is what puts it // right. Left as it was, CodeMirror would auto-indent by one width while the // formatter rewrote the file at another. if (cm.getOption("indentUnit") !== indentSize()) { cm.setOption("indentUnit", indentSize()); cm.setOption("tabSize", indentSize()); } attachEditorFeatures(cm); } function scheduleInitialAutoFormat(cm, element) { if (!config.autoFormat || cm.__cwPolishInitialFormatted || !formatterSpec() || !isSolutionEditor(element)) return; cm.__cwPolishInitialFormatted = true; window.setTimeout(() => { if (!cm.getWrapperElement?.().isConnected) return; autoFormat(cm); }, 200); } function isSolutionEditor(element) { return document.querySelector(".CodeMirror") === element; } // --------------------------------------------------------------------------- // Draft keeper // --------------------------------------------------------------------------- // Codewars does write the editor to localStorage as you type — and then clears that // key to null on the next page load and fills the editor from the server, which only // has what TEST or ATTEMPT last sent. So a refresh loses everything typed since the // last run, and the site's own copy cannot be borrowed: it is gone before anything // could read it. This keeps our own, under the script's storage. const DRAFT_KEY = "prettier-codewars:drafts"; const DRAFT_LIMIT = 80; const DRAFT_TTL = 30 * 24 * 60 * 60 * 1000; const DRAFT_DEBOUNCE = 700; // Codewars installs the server's copy asynchronously and can land after we do, so the // restore is re-applied while the buffer still holds exactly what the server sent. // The window closes the moment the learner types, so it can never fight them. const DRAFT_SETTLE = 6000; const draft = { id: "", base: null, entry: null, timer: 0, until: 0, own: false }; // Set for exactly one click, the one offerReset() re-dispatches so Codewars' own // reset can run. let resetPassthrough = false; function draftId() { const match = location.pathname.match(/^\/kata\/([^/]+)\/train\/([^/]+)/); return match ? `${match[1]}:${match[2]}` : ""; } function readDrafts() { try { const raw = typeof GM_getValue === "function" ? GM_getValue(DRAFT_KEY, null) : window.localStorage.getItem(DRAFT_KEY); const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; return parsed && typeof parsed === "object" ? parsed : {}; } catch (_error) { return {}; } } function writeDrafts(map) { const cutoff = Date.now() - DRAFT_TTL; const entries = Object.entries(map) .filter(([, entry]) => (entry?.at || 0) > cutoff) .sort((a, b) => (a[1].at || 0) - (b[1].at || 0)) .slice(-DRAFT_LIMIT); try { if (typeof GM_setValue === "function") { GM_setValue(DRAFT_KEY, Object.fromEntries(entries)); } else { window.localStorage.setItem(DRAFT_KEY, JSON.stringify(Object.fromEntries(entries))); } } catch (_error) { /* a full quota costs the next refresh, not this session */ } } // Called up the boot ladder, so it also covers the editor arriving late. function keepDraft() { const element = document.querySelector(".CodeMirror"); const cm = element?.CodeMirror; const id = draftId(); if (!cm || !id) return; if (draft.id !== id) { draft.id = id; draft.entry = readDrafts()[id] || null; draft.base = null; draft.own = false; } const text = cm.getValue(); // An empty buffer is Codewars not having filled the editor yet; taking that as the // server's copy would make every draft look like the learner's own work. if (!text.trim()) return; if (draft.base === null) { draft.base = text; draft.until = Date.now() + DRAFT_SETTLE; } restoreDraft(cm); if (cm.__cwPolishDraft) return; cm.__cwPolishDraft = true; cm.on("change", (instance, change) => onDraftChange(instance, change)); // A tab closed or hidden mid-edit is exactly the case the debounce would lose. window.addEventListener("pagehide", () => flushDraft()); window.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") flushDraft(); }); } function restoreDraft(cm) { const entry = draft.entry; if (!entry || typeof entry.text !== "string" || draft.own) return; const current = cm.getValue(); if (current === entry.text || Date.now() > draft.until) return; // Only over a buffer that is still the one the draft was written against. Anything // else is a solution sent from another tab or another machine, and replacing that // with an older draft would lose more than it saves. if (current !== entry.base) return; cm.operation(() => { // replaceRange, like the formatter: one Ctrl+Z takes the restore back. cm.replaceRange( entry.text, { line: cm.firstLine(), ch: 0 }, { line: cm.lastLine(), ch: cm.getLine(cm.lastLine()).length }, "+cwDraft" ); }); } function onDraftChange(cm, change) { const origin = change?.origin || ""; // Codewars installing its copy, our own restore, and the format that runs on // entering a kata are all "what the learner was given", not what they wrote. The // baseline moves with them, so the next load compares against the same thing. if (!draft.own && (origin === "setValue" || origin === "+cwFormat" || origin === "+cwDraft")) { if (origin !== "+cwDraft") draft.base = cm.getValue(); return; } draft.own = true; window.clearTimeout(draft.timer); draft.timer = window.setTimeout(() => flushDraft(), DRAFT_DEBOUNCE); } function flushDraft() { window.clearTimeout(draft.timer); draft.timer = 0; const cm = document.querySelector(".CodeMirror")?.CodeMirror; if (!cm || !draft.id) return; const text = cm.getValue(); if (!text.trim()) return; const base = draft.base === null ? text : draft.base; const drafts = readDrafts(); const run = drafts[draft.id]?.run; // Identical to what the learner was given is nothing worth keeping — and keeping it // would put an entry in storage for every kata merely opened. if (text === base && !run) delete drafts[draft.id]; else drafts[draft.id] = { text, base, at: Date.now(), ...(run ? { run } : {}) }; writeDrafts(drafts); draft.entry = drafts[draft.id] || null; } // TEST and ATTEMPT are the only moments Codewars itself keeps a copy, so they are // also the natural checkpoints: the last version that was known to run. Written after // the format on the same click, which is what the run actually receives — unless the // formatter is still downloading, in which case this is the buffer as typed. function recordRunCheckpoint() { const cm = document.querySelector(".CodeMirror")?.CodeMirror; if (!cm || !draft.id) return; const text = cm.getValue(); if (!text.trim()) return; const drafts = readDrafts(); const entry = drafts[draft.id] || { text, base: draft.base === null ? text : draft.base }; entry.run = { text, at: Date.now() }; entry.at = Date.now(); drafts[draft.id] = entry; writeDrafts(drafts); draft.entry = entry; } function revertToRun() { const cm = document.querySelector(".CodeMirror")?.CodeMirror; const run = readDrafts()[draft.id]?.run; if (!cm || !run?.text) return; if (cm.getValue() === run.text) { openConfirm({ title: "Nothing to go back to", body: `The editor already holds the version last sent to the tests, from ${timeAgo(run.at)}.`, confirmLabel: "" }); return; } openConfirm({ title: "Go back to the last test run?", body: `The editor will be replaced with the version last sent to the tests, from ${timeAgo(run.at)}. ` + `Everything written since is lost — though one Ctrl+Z takes this back.`, confirmLabel: "Go back", onConfirm: () => applyRunCheckpoint(cm, run) }); } function applyRunCheckpoint(cm, run) { // The learner asked for this, so the automatic restore must not argue with it. draft.own = true; cm.operation(() => { cm.replaceRange( run.text, { line: cm.firstLine(), ch: 0 }, { line: cm.lastLine(), ch: cm.getLine(cm.lastLine()).length }, "+input" ); }); cm.focus(); } // A shortcut nobody is told about is not a way back. RESET is where a reader already // goes to undo, and Codewars' reset — the original stub — is rarely the version they // want, so the button now asks which of the two it is. Without a checkpoint there is // only one answer and the click is left alone. function offerReset(cm, run) { openConfirm({ title: "Reset the editor", body: `Reset puts back Codewars' original stub. The version last sent to the tests, from ` + `${timeAgo(run.at)}, is the other way back. Either way one Ctrl+Z undoes it.`, confirmLabel: "Last test run", onConfirm: () => applyRunCheckpoint(cm, run), altLabel: "Original stub", onAlt: () => { resetPassthrough = true; document.getElementById("reset_btn")?.click(); } }); } function timeAgo(at) { const minutes = Math.max(0, Math.round((Date.now() - (at || 0)) / 60000)); if (minutes < 1) return "just now"; if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; const hours = Math.round(minutes / 60); if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; const days = Math.round(hours / 24); return `${days} day${days === 1 ? "" : "s"} ago`; } // Replacing the buffer is the one thing here that can lose work, so it is the one // thing that asks first. function openConfirm({ title, body, confirmLabel, onConfirm, altLabel, onAlt }) { document.getElementById(CONFIRM_ID)?.remove(); const dialog = document.createElement("div"); dialog.id = CONFIRM_ID; dialog.className = "cw-dialog"; dialog.innerHTML = `
${escapeHtml(body)}
${escapeHtml(text)}
`; } // Model output is untrusted text that becomes innerHTML, so it is sanitised on // every frame of the stream, not just at the end. return DOMPurify.sanitize(marked.parse(text), { ALLOWED_TAGS: [ "p", "br", "strong", "em", "del", "code", "pre", "blockquote", "ul", "ol", "li", "h1", "h2", "h3", "h4", "a", "hr", "table", "thead", "tbody", "tr", "th", "td" ], ALLOWED_ATTR: ["href", "title"], ALLOW_DATA_ATTR: false }); } function ensureSparkLayer() { let layer = document.getElementById(SPARKS_ID); if (!layer) { layer = document.createElement("div"); layer.id = SPARKS_ID; document.documentElement.append(layer); } return layer; } function sparkAt(x, y, intensity = 1) { const layer = ensureSparkLayer(); const count = Math.min(7, Math.max(3, Math.round(4 * intensity))); for (let index = 0; index < count; index += 1) { const spark = document.createElement("i"); const angle = -Math.PI + Math.random() * Math.PI; const distance = 18 + Math.random() * 34 * intensity; const dx = Math.cos(angle) * distance; const dy = Math.sin(angle) * distance - Math.random() * 8; const size = 4 + Math.random() * 4; const color = effectColors.sparks[Math.floor(Math.random() * effectColors.sparks.length)]; const rotation = (Math.random() - 0.5) * 160; spark.style.cssText = [ "position:absolute", `left:${x}px`, `top:${y}px`, `width:${size}px`, `height:${size}px`, `background:${color}`, "border-radius:1px", `box-shadow:0 0 ${7 + size * 2}px ${color}`, `transform:translate(-50%,-50%) rotate(${rotation}deg) scale(1)`, "opacity:.9", "will-change:transform,opacity" ].join(";"); layer.append(spark); spark .animate( [ { transform: `translate(-50%, -50%) rotate(${rotation}deg) scale(1)`, opacity: 0.95 }, { transform: `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) rotate(${rotation + 120}deg) scale(.25)`, opacity: 0 } ], { duration: 520 + Math.random() * 260, easing: "cubic-bezier(.16, 1, .3, 1)" } ) .finished.finally(() => spark.remove()); } } function annihilateAt(x, y, intensity = 1) { const layer = ensureSparkLayer(); const driftX = -5 - Math.random() * 8; const driftY = -2 + (Math.random() - 0.5) * 6; const count = Math.min(5, Math.max(3, Math.round(4 * intensity))); for (let index = 0; index < count; index += 1) { const voidBit = document.createElement("i"); const angle = Math.random() * Math.PI * 2; const distance = 18 + Math.random() * 32 * intensity; const sx = Math.cos(angle) * distance; const sy = Math.sin(angle) * distance; const endX = driftX * 0.35 + (Math.random() - 0.5) * 1.5; const endY = driftY * 0.35 + (Math.random() - 0.5) * 1.5; const size = 4 + Math.random() * 5; const rotation = (Math.random() - 0.5) * 180; voidBit.style.cssText = [ "position:absolute", `left:${x}px`, `top:${y}px`, `width:${size}px`, `height:${size}px`, "background:oklch(5% 0.01 265 / .92)", "border-radius:2px", "box-shadow:0 0 8px oklch(0% 0 0 / .9)", `transform:translate(calc(-50% + ${sx}px), calc(-50% + ${sy}px)) rotate(${rotation}deg) scale(1)`, "opacity:.86", "will-change:transform,opacity,filter" ].join(";"); layer.append(voidBit); voidBit .animate( [ { transform: `translate(calc(-50% + ${sx}px), calc(-50% + ${sy}px)) rotate(${rotation}deg) scale(1)`, opacity: 0.86 }, { transform: `translate(calc(-50% + ${endX}px), calc(-50% + ${endY}px)) rotate(${rotation + 210}deg) scale(.05)`, opacity: 0 } ], { duration: 500 + Math.random() * 140, easing: "cubic-bezier(.55, 0, .1, 1)" } ) .finished.finally(() => voidBit.remove()); } } function attachEffects(cm) { if (cm.__cwPolishEffects) return; cm.__cwPolishEffects = true; let lastSpark = 0; cm.on("change", (_instance, change) => { if (!change.origin || change.origin === "setValue") return; const now = performance.now(); if (now - lastSpark < 30) return; lastSpark = now; const cursor = cm.getCursor(); const pos = cm.cursorCoords(cursor, "window"); const typed = change.text.join("").length; const removed = change.removed ? change.removed.join("").length : 0; const x = pos.left + 2; const y = pos.top + (pos.bottom - pos.top) / 2; if (typed > 0 && config.typingSparks) { sparkAt(x, y, Math.min(1.8, 1 + typed / 8)); } else if (removed > 0 && config.deleteAnnihilation) { annihilateAt(x, y, Math.min(1.6, 1 + removed / 8)); } }); } // --------------------------------------------------------------------------- // Rainbow brackets // --------------------------------------------------------------------------- // Colouring is driven by the mode's own tokens rather than by a CodeMirror overlay. // An overlay is handed the raw line and cannot tell a brace in a string apart from // a real one, which miscolours the literal *and* shifts the depth for everything // after it; getLineTokens() answers with the type the language mode assigned, so // strings and comments can be stepped over. const RAINBOW_LEVELS = 6; // Depth is counted from line 0 every time, so a bracket keeps its colour no matter // where the reader has scrolled. That is a whole-document walk, hence the ceiling — // no kata is anywhere near it, and a pasted-in monster degrades to plain text. const RAINBOW_MAX_LINES = 2000; const RAINBOW_SKIP = /\b(?:string|comment)\b/; const RAINBOW_OPEN = "([{"; const RAINBOW_CLOSE = ")]}"; function attachRainbowBrackets(cm) { if (!config.rainbowBrackets || cm.__cwPolishRainbow) return; cm.__cwPolishRainbow = true; const marks = []; const painted = { generation: null, from: -1, to: -1 }; let timer = 0; let painting = false; const paint = () => { timer = 0; const viewport = cm.getViewport(); const generation = typeof cm.changeGeneration === "function" ? cm.changeGeneration() : 0; // Marking is not a document change, so a repaint over the same text and the same // viewport can only produce the marks that are already there. Without this the // paint feeds itself: markText re-renders the lines it touches, that fires // viewportChange, and the editor never stops repainting — which is what made // typing feel slow rather than the marking itself. if (generation === painted.generation && viewport.from === painted.from && viewport.to === painted.to) return; painted.generation = generation; painted.from = viewport.from; painted.to = viewport.to; painting = true; try { paintRainbowBrackets(cm, marks); } finally { painting = false; } }; const schedule = () => { if (timer || painting) return; timer = window.setTimeout(paint, 120); }; cm.on("changes", (_instance, changes) => { // An edit with no bracket in it cannot change any bracket's depth, and the marks // travel with the text on their own, so there is nothing to redo. Ordinary // typing therefore costs nothing at all. if (!changes.some(bracketInChange)) return; schedule(); }); // Scrolling brings unpainted lines into view; the marks themselves live on the // document, so only the newly visible ones are actually new work. cm.on("viewportChange", schedule); schedule(); } function bracketInChange(change) { return [...(change.text || []), ...(change.removed || [])].some( (line) => RAINBOW_OPEN.split("").some((c) => line.includes(c)) || RAINBOW_CLOSE.split("").some((c) => line.includes(c)) ); } function paintRainbowBrackets(cm, marks) { const total = cm.lineCount(); if (total > RAINBOW_MAX_LINES) return; const viewport = cm.getViewport(); const first = Math.max(0, viewport.from - 5); const last = Math.min(total, viewport.to + 5); let depth = 0; // Clearing belongs inside the operation as much as marking does: outside it, each // of several hundred clear() calls re-renders the document on its own, and a // repaint of a bracket-heavy file measured 4.5 seconds instead of 45ms. cm.operation(() => { marks.forEach((mark) => mark.clear()); marks.length = 0; for (let line = 0; line < last; line += 1) { for (const token of cm.getLineTokens(line)) { if (token.type && RAINBOW_SKIP.test(token.type)) continue; for (let i = 0; i < token.string.length; i += 1) { const char = token.string[i]; const opening = RAINBOW_OPEN.includes(char); if (!opening && !RAINBOW_CLOSE.includes(char)) continue; let className; if (opening) { className = `cw-rb-${(depth % RAINBOW_LEVELS) + 1}`; depth += 1; } else if (depth === 0) { className = "cw-rb-bad"; } else { depth -= 1; className = `cw-rb-${(depth % RAINBOW_LEVELS) + 1}`; } if (line < first) continue; const ch = token.start + i; marks.push(cm.markText({ line, ch }, { line, ch: ch + 1 }, { className })); } } } }); } // --------------------------------------------------------------------------- // Description translation // --------------------------------------------------------------------------- const translation = { units: [], originals: [], segments: null, showing: "original", busy: false, missing: 0 }; function descriptionRoot() { return document.querySelector("#description_area .description-content"); } // The model is never shown a tag it could drop, which is what keeps the kata's own // markup intact. But a text node is the wrong unit: `returntrue if the
// string is valid` is three of them, and translating each alone gives the model a
// third of a sentence and no way to move the clause around the code span — which is
// exactly what Chinese word order requires. So the unit is a whole run of inline
// content, with every inline element standing in as {{n}}. The model sees one
// sentence, moves the placeholders where the target language wants them, and the
// elements themselves are put back untouched.
const INLINE_TAGS = new Set([
"A", "ABBR", "B", "BDI", "BDO", "BR", "CITE", "CODE", "DATA", "DEL", "DFN", "EM", "I",
"IMG", "INS", "KBD", "MARK", "Q", "S", "SAMP", "SMALL", "SPAN", "STRONG", "SUB", "SUP",
"TIME", "U", "VAR", "WBR"
]);
const OPAQUE_TAGS = "pre, code, kbd, samp, script, style";
const PLACEHOLDER = /\{\{(\d+)\}\}/g;
function collectTranslationUnits(root) {
const units = [];
const visit = (element) => {
if (element.matches?.(OPAQUE_TAGS)) return;
let run = [];
const flush = () => {
const unit = makeTranslationUnit(run);
if (unit) units.push(unit);
// The inline elements carried as placeholders hold text of their own — a link's
// label, an emphasised phrase — so each is walked in turn once the run that
// contains it has been recorded.
run.filter((node) => node.nodeType === 1).forEach(visit);
run = [];
};
for (const child of [...element.childNodes]) {
if (child.nodeType === 3 || (child.nodeType === 1 && INLINE_TAGS.has(child.tagName))) {
run.push(child);
continue;
}
flush();
if (child.nodeType === 1) visit(child);
}
flush();
};
visit(root);
return units;
}
// A run becomes a unit only if it holds words. Nothing is wrapped: a span around
// the run would sit between Codewars' own `p > code` rules and the code they style.
// The unit instead remembers which nodes it currently has on the page, which is all
// that is needed to swap one rendering for another, in either direction.
function makeTranslationUnit(run) {
if (!run.length) return null;
const parts = run.slice();
const placeholders = [];
const template = parts
.map((node) => {
if (node.nodeType === 3) return node.nodeValue;
placeholders.push(node);
return `{{${placeholders.length - 1}}}`;
})
.join("");
if (!needsTranslation(template)) return null;
return { parts, placeholders, template, current: parts.slice() };
}
// A translation that lost a placeholder lost a code span or a link with it, so the
// unit keeps its original rather than being rendered short.
function renderUnit(unit, text) {
const mounted = unit.current;
const parent = mounted[0]?.parentNode;
if (!parent || !mounted[0].isConnected) return;
const next = typeof text === "string" ? unitChildren(unit, text) : null;
const children = next || unit.parts;
// The insertion point is the node after the run, never one of the run's own: both
// renderings share the placeholder elements, so anchoring on a node that is about
// to be moved would leave the reference detached before it is used.
const tail = mounted[mounted.length - 1].nextSibling;
mounted.forEach((node) => {
if (node.parentNode === parent) parent.removeChild(node);
});
children.forEach((node) => parent.insertBefore(node, tail));
unit.current = children.slice();
}
function unitChildren(unit, text) {
const found = new Set();
let match;
PLACEHOLDER.lastIndex = 0;
while ((match = PLACEHOLDER.exec(text))) found.add(Number(match[1]));
if (found.size !== unit.placeholders.length) return null;
const children = [];
let cursor = 0;
PLACEHOLDER.lastIndex = 0;
while ((match = PLACEHOLDER.exec(text))) {
const lead = text.slice(cursor, match.index);
if (lead) children.push(document.createTextNode(lead));
children.push(unit.placeholders[Number(match[1])]);
cursor = match.index + match[0].length;
}
const tail = text.slice(cursor);
if (tail) children.push(document.createTextNode(tail));
return children;
}
function hashSegments(segments) {
let hash = 5381;
const joined = segments.join("");
for (let index = 0; index < joined.length; index += 1) {
hash = ((hash << 5) + hash + joined.charCodeAt(index)) >>> 0;
}
return `${hash.toString(36)}:${joined.length}`;
}
function translationCacheKey() {
return `${TRANSLATION_PREFIX}${kataKey()}:${config.aiTargetLanguage}`;
}
function readTranslationCache(originals) {
try {
const raw =
typeof GM_getValue === "function"
? GM_getValue(translationCacheKey(), null)
: window.localStorage.getItem(translationCacheKey());
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!parsed || parsed.hash !== hashSegments(originals)) return null;
if (!Array.isArray(parsed.segments) || parsed.segments.length !== originals.length) return null;
return parsed.segments;
} catch (_error) {
return null;
}
}
function writeTranslationCache(originals, segments) {
const payload = { hash: hashSegments(originals), segments };
try {
if (typeof GM_setValue === "function") {
GM_setValue(translationCacheKey(), payload);
} else {
window.localStorage.setItem(translationCacheKey(), JSON.stringify(payload));
}
} catch (_error) {
/* a full quota only costs us the cache, not the feature */
}
}
// Keyed by the segment's index in the document, not by position in an array.
// Asking a model to return an array of exactly N items makes the whole batch fail
// when it splits or merges one entry; with explicit keys a miscount costs only the
// segments that actually went missing, and those keep their original text.
function parseKeyedTranslation(reply) {
const cleaned = String(reply)
.replace(/^\s*```(?:json)?\s*/i, "")
.replace(/\s*```\s*$/, "")
.trim();
const start = cleaned.indexOf("{");
const end = cleaned.lastIndexOf("}");
if (start === -1 || end === -1) throw new Error("The model did not return a JSON object.");
const parsed = JSON.parse(cleaned.slice(start, end + 1));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("The model returned something other than an object.");
}
const out = new Map();
for (const [key, value] of Object.entries(parsed)) {
const index = Number(key);
if (Number.isInteger(index) && typeof value === "string") out.set(index, value);
}
return out;
}
// A fragment with no word in it has nothing to translate. Measured on a real kata,
// dropping these cut wall time 8.2s -> 5.7s and completion tokens 371 -> 244, and it
// stops the model "correcting" punctuation it was only ever meant to pass through.
function needsTranslation(text) {
return /\p{L}{2,}/u.test(text);
}
// Takes the items to send, already paired with their index in the document, and
// groups them so no single request carries more than `budget` characters.
// The budget is deliberately large: splitting duplicates the system prompt in every
// request, and on the same kata two batches cost more and finished later than one.
function batchSegments(items, budget = 6000) {
const batches = [];
let batch = [];
let size = 0;
for (const item of items) {
if (batch.length && size + item.text.length > budget) {
batches.push(batch);
batch = [];
size = 0;
}
batch.push(item);
size += item.text.length;
}
if (batch.length) batches.push(batch);
return batches;
}
function translationPrompt(language) {
return [
`Translate the value of every key into ${language}.`,
"",
"Return only a JSON object with exactly the same keys as the input. Every key",
"must be present. No prose, no code fence, no extra keys, no renumbering.",
"",
"Keep identifiers, function names, type names, literal values, error text and",
"inline code verbatim. Keep the leading and trailing whitespace of each value.",
"",
"A value may contain placeholders written {{0}}, {{1}}, … Each one stands for a",
"piece of markup — a code span, a link, an emphasised phrase. Reproduce every",
"placeholder of a value exactly once, spelled exactly as it appears, and put it",
"where the target language needs it: the word order around it is yours to change.",
"Never translate, renumber, drop or duplicate one.",
"A value that is only punctuation or a number comes back unchanged.",
"These values are fragments of one document, so translate them consistently."
].join("\n");
}
// Pulls out every "N": "…" pair that is already complete in a partial JSON body.
// Waiting for the closing brace means staring at nothing for five to thirteen
// seconds; a key is usable the moment its own closing quote arrives.
function harvestKeyedPairs(buffer, seen) {
const pattern = /"(\d+)"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
const found = [];
let match;
while ((match = pattern.exec(buffer))) {
const index = Number(match[1]);
if (seen.has(index)) continue;
let text;
try {
text = JSON.parse(`"${match[2]}"`);
} catch (_error) {
continue; // the escape at the tail is still being written
}
seen.add(index);
found.push({ index, text });
}
return found;
}
async function translateBatch(batch, onSegment) {
const language = config.aiTargetLanguage || "简体中文";
const payload = JSON.stringify(Object.fromEntries(batch.map((item) => [item.index, item.text])));
const wanted = new Set(batch.map((item) => item.index));
const seen = new Set();
let buffer = "";
const { promise } = requestCompletion(
[
{ role: "system", content: translationPrompt(language) },
{ role: "user", content: payload }
],
{
onDelta: (piece) => {
if (!onSegment) return;
buffer += piece;
harvestKeyedPairs(buffer, seen).forEach(({ index, text }) => {
if (wanted.has(index)) onSegment(index, text);
});
}
}
);
const reply = await promise;
// The harvest is an optimisation for the wait, not the source of truth: the
// finished body is parsed properly so a mangled partial cannot survive.
return parseKeyedTranslation(reply);
}
async function ensureTranslation(onSegment) {
const root = descriptionRoot();
if (!root) throw new Error("Could not find the kata description.");
translation.units = collectTranslationUnits(root);
translation.originals = translation.units.map((unit) => unit.template);
if (!translation.originals.length) throw new Error("The kata description has no translatable text.");
const cached = readTranslationCache(translation.originals);
if (cached) {
translation.segments = cached;
return cached;
}
const segments = new Array(translation.originals.length);
let firstError = null;
// Punctuation, numbers and whitespace are already correct in any language.
translation.originals.forEach((text, index) => {
if (!needsTranslation(text)) segments[index] = text;
});
const wordy = translation.originals.filter((_t, index) => segments[index] === undefined).length;
// Two passes: the second retries only what came back missing, in smaller batches
// so a model that drifted on a long list gets an easier question. Batches within a
// pass go out together — when splitting is unavoidable, serial doubled wall time.
for (const budget of [6000, 1200]) {
const missing = translation.originals
.map((text, index) => ({ index, text }))
.filter((item) => typeof segments[item.index] !== "string");
if (!missing.length) break;
const results = await Promise.all(
batchSegments(missing, budget).map((batch) =>
translateBatch(batch, onSegment).then(
(map) => ({ batch, map }),
(error) => {
firstError = firstError || error;
return null;
}
)
)
);
results.filter(Boolean).forEach(({ batch, map }) => {
batch.forEach((item) => {
const value = map.get(item.index);
if (typeof value === "string") segments[item.index] = value;
});
});
}
// Coverage is "did the model answer this key", not "did the text change".
// Leaving a literal like an assertion message verbatim is the prompt working,
// and counting that as a miss would report a shortfall that is not one.
const translated = translation.originals.filter(
(text, index) => needsTranslation(text) && typeof segments[index] === "string"
).length;
if (!translated && wordy) {
throw firstError || new Error("The model returned no usable translation.");
}
// Anything still missing keeps its original text, so the description stays whole.
translation.originals.forEach((text, index) => {
if (typeof segments[index] !== "string") segments[index] = text;
});
translation.missing = wordy - translated;
translation.segments = segments;
writeTranslationCache(translation.originals, segments);
return segments;
}
function applyTranslation(mode) {
const source = mode === "translated" ? translation.segments : translation.originals;
if (!source) return;
translation.units.forEach((unit, index) => {
renderUnit(unit, mode === "translated" ? source[index] : null);
});
translation.showing = mode;
descriptionRoot()?.setAttribute("data-cw-translated", mode);
refreshTranslateButton();
}
function refreshTranslateButton() {
const button = document.querySelector(`#${PANEL_ID} [data-act="translate"]`);
if (!button) return;
const translated = translation.showing === "translated";
button.disabled = translation.busy;
button.setAttribute("data-state", translation.busy ? "busy" : translated ? "on" : "off");
button.title = translation.busy
? "Translating…"
: translated
? "Show the original description"
: `Translate the kata description into ${config.aiTargetLanguage}`;
button.setAttribute("aria-label", button.title);
button.setAttribute("aria-pressed", String(translated));
}
async function toggleTranslation() {
if (translation.busy) return;
if (translation.showing === "translated") {
applyTranslation("original");
return;
}
if (translation.segments && translation.units.every((unit) => unit.current[0]?.isConnected)) {
applyTranslation("translated");
return;
}
if (!aiConfigured()) {
openSettings("Translation needs a base URL and a model.");
return;
}
translation.busy = true;
refreshTranslateButton();
descriptionRoot()?.setAttribute("data-cw-translating", "true");
try {
// Each fragment is written as it arrives, so the description fills in during
// the wait instead of after it.
await ensureTranslation((index, text) => {
const unit = translation.units[index];
if (unit && typeof text === "string") renderUnit(unit, text);
});
translation.busy = false;
applyTranslation("translated");
// Partial is still useful; saying which part is not translated is not.
if (translation.missing > 0) {
openPanel({ summoned: true });
appendMessage(
"error",
`${translation.missing} of ${translation.originals.length} fragments came back untranslated and are shown in the original.`
);
}
} catch (error) {
openPanel({ summoned: true });
appendMessage("error", `Translation failed. ${error.message}`);
} finally {
translation.busy = false;
descriptionRoot()?.removeAttribute("data-cw-translating");
refreshTranslateButton();
}
}
// ---------------------------------------------------------------------------
// AI panel
// ---------------------------------------------------------------------------
const chat = {
history: [],
active: null,
// Which conversation is current. An aborted request still settles, and its
// handlers would otherwise write the abandoned reply into whatever conversation
// has taken its place — pressing "new conversation" mid-answer left the new one
// opening with the old one's last words.
epoch: 0,
// What the log shows, which is not what the model was sent: a user turn carries
// the editor, the run output and the attachments, and none of that belongs on
// screen a second time. Kept alongside the history so a restored conversation
// reads the way it did when it was written.
view: [],
// The kata whose stored conversation has already been offered back, so the boot
// ladder does not offer it again over itself.
restored: "",
// Community solutions, once fetched. Kept on the conversation rather than sent
// with one turn, so the rest of the exchange can keep referring back to them.
solutions: null,
// Everything below exists to keep the request's prefix byte-identical from one
// turn to the next, which is what a provider's prompt cache keys on. The kata
// brief is built once; the volatile state is appended to the newest turn and
// only when it has actually changed since the turn that last carried it.
brief: "",
briefComplete: false,
sentCode: null,
sentOutput: null,
sentSolutions: false
};
// Attached context, the way an editor-side assistant collects it: the learner
// points at something, it becomes a chip, the chips travel with the next message.
const attachments = {
items: [],
nextId: 1
};
// Lucide geometry, 24-unit box, stroked in currentColor.
const icons = {
translate: "m5 8 6 6M4 14l6-6 2-3M2 5h12M7 2h1m14 20-5-10-5 10M14 18h6",
solutions: "M3 20h18M7 20v-6M12 20V8M17 20v-9",
newChat: "M12 5v14M5 12h14",
settings: "M4 6h10M18 6h2M4 12h4M12 12h8M4 18h10M18 18h2M14 4v4M8 10v4M14 16v4",
close: "m9 18 6-6-6-6",
chevronLeft: "m15 18-6-6 6-6",
chevronDown: "m6 9 6 6 6-6",
send: "M12 19V5M5 12l7-7 7 7",
quote: "M8 12h8M8 8h8M8 16h4M4 4v16l4-4h12V4z",
remove: "M18 6 6 18M6 6l12 12",
stop: "M6 6h12v12H6z",
menu: "M4 6h16M4 12h16M4 18h16"
};
function icon(name) {
return ``;
}
function panelNode() {
return document.getElementById(PANEL_ID);
}
// The one piece of state the layout cannot work out for itself: a panel the reader
// asked for while there was no room for it. That one is theirs to close, and no
// later fold or resize takes it away. Everything else — whether it docks, whether it
// shows itself at all — is measured, not remembered. Not persisted: after a reload
// the layout decides again.
let panelSummoned = false;
function openPanel({ summoned = false } = {}) {
if (!panelNode()) return;
if (summoned) panelSummoned = true;
document.documentElement.classList.add("cw-ai-open");
panelNode().setAttribute("data-open", "true");
writeSetting("aiPanelOpen", true);
fitPanel();
reflowAfterResize();
}
function closePanel() {
if (!panelNode()) return;
panelSummoned = false;
document.documentElement.classList.remove("cw-ai-open", "cw-ai-docked");
panelNode().setAttribute("data-open", "false");
writeSetting("aiPanelOpen", false);
reflowAfterResize();
}
// Docked or overlaid is one question — is the solution editor still wide enough —
// asked again on every layout change rather than answered once from the viewport
// width, which is what a `min-width: 1500px` media query did before. Overlaying is
// the fallback and not the default: an overlaid panel sits on top of TEST and
// ATTEMPT, so the tutor would have to be closed before every run.
const DOCK_MINIMUM = 600;
function solutionWidth() {
return document.getElementById("code")?.getBoundingClientRect().width || 0;
}
function panelIsOpen() {
return document.documentElement.classList.contains("cw-ai-open");
}
function fitPanel() {
const root = document.documentElement;
if (!panelNode()) return;
// Reserve first, then read: the number that decides this is the width the editor
// would actually have, not one predicted from the layout's percentages. Nothing
// is painted between the two, so the reservation never shows on its own.
root.classList.add("cw-ai-docked");
const room = solutionWidth() >= DOCK_MINIMUM;
// With room the panel is a pane of the layout rather than something to summon, so
// it shows itself — folding either of the other two panes is what makes room, and
// this is asked again right after. A tutor with no endpoint behind it is not worth
// 400px of anyone's screen, so that one waits to be asked for.
if (room) panelSummoned = false;
if (room && !panelIsOpen() && aiConfigured()) openPanel();
if (!room || !panelIsOpen()) root.classList.remove("cw-ai-docked");
// Losing the room is not a reason to float over the code: a panel that showed
// itself goes back the way it came. One the reader summoned stays — that is what
// they asked for, and it is theirs to close.
if (!room && !panelSummoned && panelIsOpen()) closePanel();
publishTitleHeight();
}
function reflowAfterResize() {
window.setTimeout(() => {
window.dispatchEvent(new Event("resize"));
document.querySelectorAll(".CodeMirror").forEach((element) => element.CodeMirror?.refresh());
}, 240);
}
function logNode() {
return panelNode()?.querySelector(".cw-ai-log");
}
function scrollLogToEnd() {
const log = logNode();
if (!log) return;
// Only follow the stream while the reader is already at the bottom.
if (log.scrollHeight - log.scrollTop - log.clientHeight < 120) {
log.scrollTop = log.scrollHeight;
}
}
function appendMessage(role, text, chips) {
const log = logNode();
if (!log) return null;
log.querySelector(".cw-ai-empty")?.remove();
const node = document.createElement("div");
node.className = "cw-ai-msg";
node.setAttribute("data-role", role);
if (role === "assistant") {
node.innerHTML = renderMarkdown(text);
} else {
if (chips?.length) {
const strip = document.createElement("div");
strip.className = "cw-ai-msg-chips";
strip.textContent = chips.map((item) => item.label).join(" · ");
node.append(strip);
}
const body = document.createElement("div");
body.textContent = text;
node.append(body);
}
log.append(node);
log.scrollTop = log.scrollHeight;
return node;
}
function setBusy(busy) {
const panel = panelNode();
if (!panel) return;
const send = panel.querySelector('[data-act="send"]');
if (send) {
send.innerHTML = icon(busy ? "stop" : "send");
send.title = busy ? "Stop generating" : "Send";
send.setAttribute("aria-label", send.title);
}
}
// ---------------------------------------------------------------------------
// Attached context
// ---------------------------------------------------------------------------
function chipsNode() {
return panelNode()?.querySelector(".cw-ai-chips");
}
function renderChips() {
const strip = chipsNode();
if (!strip) return;
strip.innerHTML = attachments.items
.map(
(item) =>
`${escapeHtml(item.label)}` +
`` +
``
)
.join("");
strip.hidden = attachments.items.length === 0;
}
function describeSize(text) {
const lines = text.split("\n").length;
return lines > 1 ? `${lines} lines` : `${text.trim().length} chars`;
}
function attachContext(label, text) {
const trimmed = String(text || "").replace(/\s+$/, "");
if (!trimmed.trim()) return;
// Re-attaching the same snippet should not stack duplicates.
const existing = attachments.items.find((item) => item.label === label && item.text === trimmed);
if (!existing) {
attachments.items.push({
id: attachments.nextId++,
label,
meta: describeSize(trimmed),
text: trimmed
});
}
openPanel({ summoned: true });
renderChips();
panelNode()?.querySelector("textarea")?.focus();
}
function clearAttachments() {
attachments.items = [];
renderChips();
}
function attachmentBlock() {
if (!attachments.items.length) return "";
return attachments.items
.map((item) => `# ${item.label} (selected by the learner)\n\`\`\`\n${item.text}\n\`\`\``)
.join("\n\n");
}
// ---------------------------------------------------------------------------
// Selection capture
// ---------------------------------------------------------------------------
const SELECTION_BUTTON_ID = "cw-polish-ai-selection";
function selectionButton() {
let button = document.getElementById(SELECTION_BUTTON_ID);
if (button) return button;
button = document.createElement("button");
button.id = SELECTION_BUTTON_ID;
button.type = "button";
button.hidden = true;
button.innerHTML = `${icon("quote")}Ask`;
// mousedown, not click: by the time click fires the selection is already gone.
button.addEventListener("mousedown", (event) => {
event.preventDefault();
const pending = button.__cwPending;
if (pending) attachContext(pending.label, pending.text);
hideSelectionButton();
});
document.body.append(button);
return button;
}
function hideSelectionButton() {
const button = document.getElementById(SELECTION_BUTTON_ID);
if (button) {
button.hidden = true;
button.__cwPending = null;
}
}
function editorLabel(wrapper) {
const editors = [...document.querySelectorAll(".CodeMirror")];
return editors.indexOf(wrapper) === 0 ? "Solution" : "Sample Tests";
}
// Reads the selection from whichever surface owns it. CodeMirror keeps its own
// selection model, so the DOM selection alone would come back empty there.
function readSelection() {
for (const wrapper of document.querySelectorAll(".CodeMirror")) {
const cm = wrapper.CodeMirror;
if (cm?.somethingSelected?.()) {
return { label: editorLabel(wrapper), text: cm.getSelection(), rect: selectionRect() };
}
}
const selection = window.getSelection();
const text = selection?.toString() || "";
if (!text.trim() || !selection.rangeCount) return null;
const node = selection.anchorNode;
const element = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
if (!element) return null;
if (element.closest(`#${PANEL_ID}, .cw-dialog`)) return null;
if (element.closest("#description_area")) {
return { label: "Description", text, rect: selectionRect() };
}
if (element.closest("#code_results, .console-output")) {
return { label: "Test output", text, rect: selectionRect() };
}
return null;
}
function selectionRect() {
const selection = window.getSelection();
if (!selection || !selection.rangeCount) return null;
const rect = selection.getRangeAt(0).getBoundingClientRect();
return rect.width || rect.height ? rect : null;
}
function showSelectionButton() {
if (!active) return;
const found = readSelection();
if (!found || !found.rect) {
hideSelectionButton();
return;
}
const button = selectionButton();
button.__cwPending = { label: found.label, text: found.text };
button.hidden = false;
const width = button.offsetWidth || 74;
const left = Math.min(window.innerWidth - width - 12, Math.max(8, found.rect.right - width));
const top = Math.max(8, found.rect.top - button.offsetHeight - 8);
button.style.left = `${left}px`;
button.style.top = `${top}px`;
}
function attachSelectionCapture() {
let frame = 0;
const schedule = () => {
if (frame) return;
frame = window.requestAnimationFrame(() => {
frame = 0;
showSelectionButton();
});
};
document.addEventListener("mouseup", schedule);
document.addEventListener("keyup", (event) => {
if (event.shiftKey || event.key === "Shift") schedule();
});
document.addEventListener("scroll", hideSelectionButton, true);
document.addEventListener("mousedown", (event) => {
if (!event.target.closest?.(`#${SELECTION_BUTTON_ID}`)) hideSelectionButton();
});
}
// ---------------------------------------------------------------------------
// Test output capture
// ---------------------------------------------------------------------------
const OUTPUT_BUTTON_ID = "cw-polish-ai-output";
function buildOutputButton() {
if (!config.aiEnabled || document.getElementById(OUTPUT_BUTTON_ID)) return;
const host = document.querySelector(".console-output");
if (!host) return;
host.classList.add("cw-ai-output-host");
const button = document.createElement("button");
button.id = OUTPUT_BUTTON_ID;
button.type = "button";
button.title = "Send the test output to the AI tutor";
button.setAttribute("aria-label", button.title);
button.innerHTML = `${icon("quote")}Ask`;
button.addEventListener("click", () => {
const text = outputText();
if (!text) return;
attachContext("Test output", text);
});
host.append(button);
refreshOutputButton();
}
function refreshOutputButton() {
const button = document.getElementById(OUTPUT_BUTTON_ID);
if (button) button.hidden = !outputText();
}
// ---------------------------------------------------------------------------
// Conversation
// ---------------------------------------------------------------------------
const REVIEW_QUESTION = "Compare my solution with the kata's top-voted ones.";
// Not an error message: this is Codewars working as designed, and the reason not to
// route around it is that unlocking costs the reader the kata's honor. The script
// reads that page and never touches the unlock control.
const WITHHELD_NOTE = [
"Codewars withholds this kata's solutions until you have solved it, and unlocking them",
"early forfeits its honor and rank progress.",
"",
"Solve it first — this button only reads that page, it will never unlock it for you."
].join("\n");
// On demand rather than automatic: every press is a request to Codewars, for a page
// the reader's own standing depends on.
async function reviewTopSolutions() {
if (chat.active) {
chat.active.abort();
return;
}
if (!aiConfigured()) {
openSettings("Set a base URL and a model first.");
return;
}
openPanel({ summoned: true });
if (chat.solutions) {
ask(REVIEW_QUESTION);
return;
}
const button = document.querySelector(`#${PANEL_ID} [data-act="solutions"]`);
button?.setAttribute("data-busy", "true");
try {
const solutions = parseSolutions(await fetchSolutionsPage());
if (!solutions) {
appendMessage("assistant", WITHHELD_NOTE);
return;
}
if (!solutions.length) {
appendMessage("assistant", "Codewars lists no solutions for this kata in this language.");
return;
}
chat.solutions = solutions;
saveChat();
ask(REVIEW_QUESTION);
} catch (error) {
appendMessage("assistant", error.message);
} finally {
button?.removeAttribute("data-busy");
}
}
function ask(question) {
if (chat.active) {
chat.active.abort();
return;
}
if (!aiConfigured()) {
openSettings("Set a base URL and a model first.");
return;
}
const attached = attachments.items.slice();
const content = [turnContextBlock(), attachmentBlock(), question].filter(Boolean).join("\n\n");
// What this turn just told the model, remembered so the next one can leave it out.
// Rolled back with the turn itself if the request fails.
const carried = {
code: solutionEditor()?.getValue()?.trim() || chat.sentCode,
output: outputText() || chat.sentOutput,
solutions: chat.sentSolutions || Boolean(chat.solutions)
};
const previous = { code: chat.sentCode, output: chat.sentOutput, solutions: chat.sentSolutions };
appendMessage("user", question, attached);
clearAttachments();
chat.history.push({ role: "user", content });
chat.view.push({ role: "user", text: question, chips: attached.map((item) => ({ label: item.label })) });
chat.sentCode = carried.code;
chat.sentOutput = carried.output;
chat.sentSolutions = carried.solutions;
const target = appendMessage("assistant", "");
target.classList.add("cw-ai-cursor");
let answer = "";
let frame = 0;
const epoch = chat.epoch;
const flush = () => {
frame = 0;
target.innerHTML = renderMarkdown(answer);
scrollLogToEnd();
};
// Everything ahead of the newest turn has to be byte-identical to the last
// request or the provider's prompt cache misses and the whole conversation is
// read again. So the brief is built once and kept, and the history is only ever
// appended to — no sliding window, which would move the prefix on every turn.
if (!chat.brief || !chat.briefComplete) {
chat.brief = kataBriefBlock();
// Codewars hydrates the description late; a brief built before it landed is
// worth rebuilding once, and after that the prefix is frozen for the kata.
chat.briefComplete = Boolean(descriptionText());
}
// A conversation this long is already past what the model can use well. Cutting
// half of it at once rather than one turn at a time keeps the discarded prefix
// rare: a cut costs one cache miss, a sliding window costs one every turn.
if (chat.history.length > 40) chat.history = chat.history.slice(-20);
const messages = [
{ role: "system", content: tutorSystemPrompt() },
{ role: "system", content: chat.brief },
...chat.history
];
setBusy(true);
const request = requestCompletion(messages, {
onDelta: (piece) => {
answer += piece;
if (!frame) frame = window.requestAnimationFrame(flush);
}
});
chat.active = request;
request.promise
.then(() => {
if (epoch !== chat.epoch) return;
chat.history.push({ role: "assistant", content: answer });
chat.view.push({ role: "assistant", text: answer });
})
.catch((error) => {
if (epoch !== chat.epoch) return;
if (answer) {
// A stopped or truncated reply is still context worth keeping.
chat.history.push({ role: "assistant", content: answer });
chat.view.push({ role: "assistant", text: answer });
return;
}
target.remove();
appendMessage("error", error.message);
chat.history.pop();
chat.view.pop();
// The turn is gone, so what it carried was never seen: the next one has to
// send the editor and the run output again.
chat.sentCode = previous.code;
chat.sentOutput = previous.output;
chat.sentSolutions = previous.solutions;
})
.finally(() => {
if (frame) window.cancelAnimationFrame(frame);
if (epoch !== chat.epoch) return;
target.innerHTML = renderMarkdown(answer);
target.classList.remove("cw-ai-cursor");
chat.active = null;
setBusy(false);
scrollLogToEnd();
saveChat();
});
}
// Codewars keeps nothing of this, and the panel's log node does not survive a route
// change, so a conversation about a kata used to end when the reader left it — most
// of the way through a hint, if they went to look something up. It is kept per kata
// and per language, the same key the draft keeper uses, and offered back on return.
const CHAT_KEY = "prettier-codewars:chats";
const CHAT_LIMIT = 10;
const CHAT_TTL = 14 * 24 * 60 * 60 * 1000;
const CHAT_TURNS = 40;
function readChats() {
try {
const raw =
typeof GM_getValue === "function"
? GM_getValue(CHAT_KEY, null)
: window.localStorage.getItem(CHAT_KEY);
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
return parsed && typeof parsed === "object" ? parsed : {};
} catch (_error) {
return {};
}
}
function writeChats(map) {
const cutoff = Date.now() - CHAT_TTL;
const entries = Object.entries(map)
.filter(([, entry]) => (entry?.at || 0) > cutoff)
.sort((a, b) => (a[1].at || 0) - (b[1].at || 0))
.slice(-CHAT_LIMIT);
try {
if (typeof GM_setValue === "function") {
GM_setValue(CHAT_KEY, Object.fromEntries(entries));
} else {
window.localStorage.setItem(CHAT_KEY, JSON.stringify(Object.fromEntries(entries)));
}
} catch (_error) {
/* a full quota costs the next visit its history, not this session its reply */
}
}
function saveChat() {
const id = draftId();
if (!id) return;
const chats = readChats();
if (!chat.history.length) delete chats[id];
else {
chats[id] = {
history: chat.history.slice(-CHAT_TURNS),
view: chat.view.slice(-CHAT_TURNS),
brief: chat.brief,
briefComplete: chat.briefComplete,
sentCode: chat.sentCode,
sentOutput: chat.sentOutput,
sentSolutions: chat.sentSolutions,
solutions: chat.solutions,
at: Date.now()
};
}
writeChats(chats);
}
function forgetChat() {
const id = draftId();
if (!id) return;
const chats = readChats();
delete chats[id];
writeChats(chats);
}
function restoreChat() {
const id = draftId();
if (!id || chat.restored === id || chat.history.length || !logNode()) return;
chat.restored = id;
const entry = readChats()[id];
if (!entry?.history?.length) return;
chat.history = entry.history;
chat.view = entry.view || [];
chat.brief = entry.brief || "";
// A brief that was never complete is rebuilt on the next turn; one that was is the
// prefix the provider's cache still holds, so it is kept word for word.
chat.briefComplete = Boolean(entry.briefComplete);
chat.sentCode = entry.sentCode ?? null;
chat.sentOutput = entry.sentOutput ?? null;
chat.sentSolutions = Boolean(entry.sentSolutions);
chat.solutions = entry.solutions || null;
const log = logNode();
log.innerHTML = "";
chat.view.forEach((message) => appendMessage(message.role, message.text, message.chips));
scrollLogToEnd();
renderChips();
}
function clearChat({ forget = false } = {}) {
// Dropped synchronously, not left for the aborted request's finally: a message
// typed straight after "new conversation" would otherwise be read as a stop.
chat.active?.abort();
chat.active = null;
chat.epoch += 1;
setBusy(false);
chat.history = [];
chat.solutions = null;
chat.brief = "";
chat.briefComplete = false;
chat.sentCode = null;
chat.sentOutput = null;
chat.sentSolutions = false;
chat.view = [];
if (forget) forgetChat();
clearAttachments();
const log = logNode();
if (log) log.innerHTML = emptyStateHtml();
}
function emptyStateHtml() {
return `Select code or text anywhere on the page and press Ask to bring it here.
Ask for a hint, for the background you are missing, or for what an error means. You will not be given the solution.
Any OpenAI-compatible endpoint works. The base URL goes as far as /v1.
The key is kept in the userscript manager's own storage and is sent only to that endpoint.