From da6236661094100e22e9cc0304c2f1bbfca44b85 Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 26 Oct 2026 23:09:07 +0100 Subject: [PATCH 01/16] test: test-page harness (TH1) Mounts the real TestPage and engine in jsdom on fake timers, types through the words input, advances frames, and reads word/letter states, phase and live stats. Own vitest project (no global DOM query mocks). Typing driver moved to dev/typing-driver.ts, shared with __mt, plus compose for IME input. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NZNRfGDYsGYMUTp2zYUTrv --- docs/AGENT_BROWSER.md | 1 + docs/specs/solid-monkeytype/roadmap.md | 10 +- docs/specs/solid-monkeytype/spec.md | 2 +- frontend/__tests__/__harness__/mock-static.ts | 3 + frontend/__tests__/__harness__/test-page.tsx | 272 ++++++++++++++++++ .../components/pages/test/TestPage.spec.tsx | 213 ++++++++++++++ frontend/src/ts/dev/expose.ts | 3 +- frontend/src/ts/dev/test-helpers.ts | 50 ---- frontend/src/ts/dev/typing-driver.ts | 89 ++++++ frontend/vitest.config.ts | 22 ++ 10 files changed, 608 insertions(+), 57 deletions(-) create mode 100644 frontend/__tests__/__harness__/test-page.tsx create mode 100644 frontend/__tests__/components/pages/test/TestPage.spec.tsx create mode 100644 frontend/src/ts/dev/typing-driver.ts diff --git a/docs/AGENT_BROWSER.md b/docs/AGENT_BROWSER.md index 3c2896f294d2..d5ee391c5505 100644 --- a/docs/AGENT_BROWSER.md +++ b/docs/AGENT_BROWSER.md @@ -46,6 +46,7 @@ for (let i = 0; i < 30 && !window.__mt; i++) await new Promise((r) => setTimeout | `showModal(id)` | open a modal by `ModalId` | | `restartTest()` | restart the typing test | | `typeText(text, msPerChar?)` | type through the real input handlers; `"\n"` is a line break, `"\b"` a backspace. Paced with `msPerChar`, else all in one task | +| `compose(updates)` | IME composition through the real input handlers: each update is the composition so far (eg. `["k", "か"]`), the last one commits | | `wordsSnapshot()` | rendered `#words` children as a flat array: word entries `{ index, classes, letters: [{ text, icon?, classes }] }` mixed with sibling entries `{ sibling: className }` (newlines) | | `caretOffset()` | `{ dx, dy }` px from the caret to where it should be; a few px is on | | `checkLayout()` | test page layout problems, `[]` when right: active word inside the words wrapper, caret and words input on it, 3 full lines (unless all lines show), tape anchor at the tape margin. Run it after any change to the test UI | diff --git a/docs/specs/solid-monkeytype/roadmap.md b/docs/specs/solid-monkeytype/roadmap.md index 0a742528795a..e9f56ae5d46d 100644 --- a/docs/specs/solid-monkeytype/roadmap.md +++ b/docs/specs/solid-monkeytype/roadmap.md @@ -11,7 +11,7 @@ Companion to `spec.md`. Every step is one small change, behaviour-preserving apa Each step can start once the previous step's **bold** items are done. Everything listed on one step can run in parallel. - [ ] 1. **H1–H5** housekeeping, then start all at once: **T0** (merge #8507), **C1**, **EP1**, **EP2**, **L1**, **L2**, plus any of U1, U5, C5, EP4, L4–L11. -- [ ] 2. **TH1** test-page harness and **T1** letter → span + `data-ui-*`. Alongside: U2, U3, U4 (after U1), C2, C3. +- [ ] 2. **TH1** test-page harness, then **T1** letter → span + `data-ui-*`. Alongside: U2, U3, U4 (after U1), C2, C3. - [ ] 3. **T2** break joining. Alongside: EP3. - [ ] 4. **T3** caret boundary. Then L3 and L12 can start. - [ ] 5. **T4** word registry private. @@ -26,7 +26,7 @@ Each step can start once the previous step's **bold** items are done. Everything ``` T: test page (CRITICAL PATH, serial) T0 #8507 ─► T1 letter→span + data-ui-* ─► T2 break-joining ─► T3 caret boundary ─► T4 registry private ─► T5 delete test-ui ─► T6 engine split (T6a–c) ─► T7 drop page-mount workaround - TH1 test-page harness (with T0/T1, before T2) ▲ needs EP1, EP2, L1, L2 ▲ needs L3, L4, L5 + TH1 test-page harness (after T0, before T1) ▲ needs EP1, EP2, L1, L2 ▲ needs L3, L4, L5 │ C: config C1 ─► C2×5, C3 areas (parallel) ──────────────────────────► C4 delete Config + configEvent (needs T6) U: user data U1 ─► U2, U3, U4 (parallel); U5 anytime ─────────────────► U6 delete __nonReactive (needs T6a, T6c) @@ -43,7 +43,7 @@ Every track can start now. These are the only steps that wait on another track: | Step | Waits on | Why | |---|---|---| -| **TH1** test-page harness | T0 (H1 first if it extends the app-shell harness) | Builds on Words owning its layout | +| **TH1** test-page harness | T0 | Builds on Words owning its layout | | **T2** break joining | TH1 | The harness is the safety net from here on | | **T5** delete test-ui | EP1, EP2, L1, L2 | test-ui still calls the timer event, focus and funbox; they need new homes first | | **T6b** restart via flag | C1 | Restart reacts to config changes, so the store must update before the event fires | @@ -74,7 +74,7 @@ Fully independent from start to finish: H, U1–U5, C1–C3, C5, EP1, EP2, EP4, | Done | ID | Size | Step | Blocked by | Unblocks | |---|---|---|---|---|---| -| [ ] | TH1 | L · PR | **Test-page harness**: mount the real TestPage and engine in jsdom, type through WordsInput (input/beforeinput/keydown/composition), advance frames, and assert rendered words/letters via `data-ui-*`, engine phase, live stats and the result. Extends the existing render-words or app-shell harness. Grows with the engine: restart flow after T6b, finish → result/spinner after T6c | T0 (can pair with T1) | T2 onward: every later test-page step keeps it green | +| [x] | TH1 | L · PR | **Test-page harness** (`__tests__/__harness__/test-page.tsx`, own `test-page` vitest project): mounts the real TestPage and engine in jsdom on fake timers, types through WordsInput (input/beforeinput/keydown/composition, the driver `__mt` uses), advances frames, and reads word/letter states (semantic names, not classes), engine phase and live stats. Starter suite in `TestPage.spec.tsx`. Grows with the engine: restart flow after T6b, finish → result/spinner after T6c | T0 | T2 onward: every later test-page step keeps it green | ## Track T: test page (critical path) @@ -83,7 +83,7 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec | Done | ID | Size | Step | Blocked by | Removes / unblocks | |---|---|---|---|---|---| | [ ] | T0 | M · PR #8507 | Merge **#8507**: Words triggers its own layout (flush subscription, config/font/resize effects) | none | test-ui's layout calls | -| [ ] | T1 | L · PR | **`` → ``**, plus `data-ui-element` on words/word/typo hints; move the bundled theme and funbox CSS to the new selectors; old selectors keep working. Benchmark letter rendering with the profiling harness | T0 | The made-up element; proves the theme attribute approach before the caret measures letters | +| [ ] | T1 | L · PR | **`` → ``**, plus `data-ui-element` on words/word/typo hints; move the bundled theme and funbox CSS to the new selectors; old selectors keep working. `data-ui-state` values are the harness's state names; switch its word/letter helpers to `data-ui-*`, specs unchanged. Benchmark letter rendering with the profiling harness | T0, TH1 | The made-up element; proves the theme attribute approach before the caret measures letters | | [ ] | T2 | M · PR | **Break joining** computed in the layout pass, rendered as reactive class/style on Word; delete the break-joining module's DOM writes | T1, TH1 | The only outside writes into Word elements | | [ ] | T3 | L · PR | **Caret boundary**: Words is the only thing that drives the caret and pace caret; line-jump/tape-scroll handoff becomes internal to Words; pace-caret timing moves to a DOM-free module outputting a word/letter target; caret and pace caret get `data-ui-element`. Caret and words layout read the typed input from the render store (input writes it alongside the EventLog), not the engine's `getCurrentInput()`. Decide the caret internals here (reactive, imperative handle, or a mix) | T2 | Outside caret callers (test-ui, timer, focus, WordsInput) | | [ ] | T4 | S · commit | **Word registry private** to Words; only the synchronous overflow check stays exported | T3 | The public word-elements bridge | diff --git a/docs/specs/solid-monkeytype/spec.md b/docs/specs/solid-monkeytype/spec.md index 8780c176eeb8..cf5d95260cbf 100644 --- a/docs/specs/solid-monkeytype/spec.md +++ b/docs/specs/solid-monkeytype/spec.md @@ -233,7 +233,7 @@ Good tests check behaviour through the highest available seam: render, act like Seams, highest first: 1. **App-shell harness** (existing): renders the real route tree with fake auth, a test query client and controllable data. Used for routing, guards, loading steps and page lifecycle. Prior art: the app-shell routing spec and the loading-page timing spec. -2. **Test-page harness** (new, the main seam for the test): mounts the *real* TestPage with the real engine, types through WordsInput (input/beforeinput/keydown/composition), advances frames, and checks rendered words/letters (via `data-ui-*`), engine phase, live stats, the finish → result/spinner flow and the restart flow. Built early on the critical path, before the risky steps; every later test-page step keeps it green. Prior art: the render-words harness and the Words, WordsInput and TypoHints specs. +2. **Test-page harness** (`frontend/__tests__/__harness__/test-page.tsx`, usage in its header; the main seam for the test): mounts the *real* TestPage with the real engine, types through WordsInput (input/beforeinput/keydown/composition), advances frames, and checks rendered words/letters (via `data-ui-*`), engine phase, live stats, the finish → result/spinner flow and the restart flow. Built early on the critical path, before the risky steps; every later test-page step keeps it green. Prior art: the render-words harness and the Words, WordsInput and TypoHints specs. 3. **Engine, headless**: commands in, signals out: restart merging and test settings, finish/fail transitions, calculating → result, save status, tags read at finish. No DOM. 4. **Pure modules** (existing pattern): letters/display rules, typo-hint merging, keymap converter, stats as functions of an EventLog, word generation, config setter validation, config metadata. Prior art: the letters/typo-hints specs, the config and config-metadata specs, the stats specs. 5. **Store/state modules**: user collection mutations and `getUser()`, config store apply/reconcile, words-render buffer flush. Assert through their public getters. diff --git a/frontend/__tests__/__harness__/mock-static.ts b/frontend/__tests__/__harness__/mock-static.ts index 19bfdbd34ab8..322532984803 100644 --- a/frontend/__tests__/__harness__/mock-static.ts +++ b/frontend/__tests__/__harness__/mock-static.ts @@ -18,5 +18,8 @@ vi.mock("../../src/ts/utils/json-data", async (importOriginal) => { return { ...original, getLayout: async (name: string) => readJson(`/layouts/${name}.json`), + getLanguage: async (name: string) => readJson(`/languages/${name}.json`), + getCurrentLanguage: async (name: string) => + readJson(`/languages/${name}.json`), }; }); diff --git a/frontend/__tests__/__harness__/test-page.tsx b/frontend/__tests__/__harness__/test-page.tsx new file mode 100644 index 000000000000..3b87807ace3e --- /dev/null +++ b/frontend/__tests__/__harness__/test-page.tsx @@ -0,0 +1,272 @@ +/** + * Test-page harness: the real TestPage and test engine in jsdom, typed into + * through the words input like a browser does. The main seam for test page + * behaviour: render, type, advance frames, check what's on screen. + * + * Specs using it run in the `test-page` vitest project (see + * vitest.config.ts), which has no global DOM query mocks: the engine's + * vanilla modules query the real DOM. + * + * ```tsx + * afterEach(async () => cleanupTestPage()); + * + * it("marks a typo", async () => { + * const page = await renderTestPage({ words: ["the", "fox"] }); + * await page.type("tx"); + * expect(page.word(0).letters()).toEqual([ + * { char: "t", state: "correct" }, + * { char: "h", state: "incorrect" }, + * { char: "e", state: "untyped" }, + * ]); + * expect(page.phase()).toBe("active"); + * }); + * ``` + * + * - `words`: the test shows exactly these words, in order (a custom test). + * Without it the words come from english.json (read from disk), random. + * `config` overrides the default config, eg. `{ mode: "time", time: 15 }`. + * - Fake timers (setTimeout, setInterval, Date, performance and animation + * frames) for the whole spec file, from before anything loads. Animejs is + * real: the test timer and fades run on its rAF loop, so they move with the + * fake clock. `frame()` runs one + * animation frame, `advance(ms)` moves the clock (live stats tick, timed + * tests end). `renderTestPage` resolves once the first test is ready. + * - `type(text)` types through the words input (keydown, beforeinput, input, + * keyup per char; "\b" is backspace, "\n" Enter), `compose(updates)` like + * an IME. Both end with a frame, so the words are rendered: pass + * `{ flush: false }` to check before it (input never renders synchronously). + * - Reading: `word(i).letters()` / `word(i).state`, `phase()`, `liveStats()`. + * Their states are semantic, not css classes, so specs survive markup + * changes. + * - Mocked: firebase (signed out, so a finished test isn't saved), the theme + * controller (indexedDB), the chart (canvas). Stubbed in jsdom: matchMedia + * (nothing matches), scrolling. The engine's debug logs are muted. + * - The page mounts in a router with only the test page, so it reads as the + * test page (typing starts a test only there) and the result's links work. + */ +// static json (layouts, languages) read from disk +import "./mock-static"; +import type { Config as ConfigSchema } from "@monkeytype/schemas/configs"; + +import { cleanup, render } from "@solidjs/testing-library"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RegisteredRouter, + RouterProvider, +} from "@tanstack/solid-router"; +import { vi } from "vitest"; + +import { TestPage } from "../../src/ts/components/pages/test/TestPage"; +import { __testing as configTesting } from "../../src/ts/config/testing"; +import { compose, typeText } from "../../src/ts/dev/typing-driver"; +import { setRouter } from "../../src/ts/router/instance"; +import { + currentLiveStats, + isTestActive, + isResultCalculating, + isTestFinished, + isTestRestarting, +} from "../../src/ts/states/test"; +import * as CustomText from "../../src/ts/test/custom-text"; + +// fake timers, then jsdom gaps: matchMedia (ad controller, on import; no +// query matches) and scrolling (the test keeps itself in view) +vi.hoisted(() => { + // before any import: animejs keeps the Date.now and requestAnimationFrame + // it finds on import. Kept for the whole spec file, so it never loses them + vi.useFakeTimers({ + toFake: [ + "setTimeout", + "clearTimeout", + "setInterval", + "clearInterval", + "Date", + "performance", + "requestAnimationFrame", + "cancelAnimationFrame", + ], + }); + Element.prototype.scrollIntoView = () => undefined; + window.scrollTo = () => undefined; + // the engine logs every event and word at debug level + console.debug = () => undefined; + window.matchMedia = (query: string): MediaQueryList => + ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }) as unknown as MediaQueryList; +}); +vi.mock("../../src/ts/firebase", () => ({ + app: undefined, + Auth: undefined, + getAuthenticatedUser: () => null, + isAuthAvailable: () => false, +})); +// no canvas in jsdom; the result chart renders nothing +vi.mock("../../src/ts/components/common/ChartJs", () => ({ + ChartJs: () => null, +})); +// opens indexedDB on import +vi.mock("../../src/ts/controllers/theme-controller", () => ({ + randomizeTheme: vi.fn(), +})); + +export type TestPhase = "ready" | "active" | "finished"; + +export type LetterState = + | "untyped" + | "correct" + | "incorrect" + | "extra" + | "dead" + | "deadCorrect"; + +export type WordState = "active" | "typed" | "error" | "upcoming"; + +export type RenderTestPageOptions = { + /** The test shows exactly these words, in order (a custom test). */ + words?: string[]; + config?: Partial; +}; + +export type TestPageHarness = { + /** Types `text`; "\b" is backspace, "\n" Enter. Ends with a frame. */ + type: (text: string, options?: { flush?: boolean }) => Promise; + /** IME composition: each update is the composition so far, the last commits. */ + compose: (updates: string[], options?: { flush?: boolean }) => Promise; + /** Runs one animation frame. */ + frame: () => Promise; + /** Moves the clock by `ms`, running timers and frames on the way. */ + advance: (ms: number) => Promise; + word: (index: number) => { + letters: () => { char: string; state: LetterState }[]; + state: WordState; + }; + phase: () => TestPhase; + liveStats: () => { + wpm?: number; + raw?: number; + acc?: number; + seconds?: number; + }; +}; + +function letterState(letter: Element): LetterState { + const has = (name: string): boolean => letter.classList.contains(name); + if (has("dead")) return has("correct") ? "deadCorrect" : "dead"; + if (has("extra")) return "extra"; + if (has("incorrect")) return "incorrect"; + if (has("correct")) return "correct"; + return "untyped"; +} + +function wordState(word: Element): WordState { + const has = (name: string): boolean => word.classList.contains(name); + if (has("active")) return "active"; + if (has("error")) return "error"; + if (has("typed")) return "typed"; + return "upcoming"; +} + +async function frame(): Promise { + vi.advanceTimersToNextFrame(); + // let the frame's promise chains run + await vi.advanceTimersByTimeAsync(0); +} + +/** Mounts the test page and resolves once its first test is ready. */ +export async function renderTestPage( + options: RenderTestPageOptions = {}, +): Promise { + if (options.words !== undefined) { + // as the custom text modal's simple mode + CustomText.setMode("repeat"); + CustomText.setPipeDelimiter(false); + CustomText.setText(options.words); + CustomText.setLimitMode("word"); + CustomText.setLimitValue(options.words.length); + } + configTesting.replaceConfig({ + ...(options.words !== undefined ? { mode: "custom" } : {}), + ...options.config, + }); + + const root = createRootRoute(); + const router = createRouter({ + routeTree: root.addChildren([ + createRoute({ + getParentRoute: () => root, + path: "/", + staticData: { page: "test" }, + component: TestPage, + }), + ]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + setRouter(router as unknown as RegisteredRouter); + await router.load(); + const { container } = render(() => ); + + // the restart on mount: fade out, generate words, fade in + for (let i = 0; i < 50 && isTestRestarting(); i++) await frame(); + if (isTestRestarting()) throw new Error("The test page never got ready"); + await frame(); + + const wordElement = (index: number): Element => { + const el = container.querySelector( + `#words .word[data-wordindex="${index}"]`, + ); + if (el === null) throw new Error(`Word ${index} is not rendered`); + return el; + }; + + return { + async type(text, { flush = true } = {}) { + await typeText(text); + if (flush) await frame(); + }, + async compose(updates, { flush = true } = {}) { + compose(updates); + if (flush) await frame(); + }, + frame, + async advance(ms) { + await vi.advanceTimersByTimeAsync(ms); + }, + word: (index) => ({ + letters: () => + [...wordElement(index).querySelectorAll(":scope > letter")].map( + (letter) => ({ + char: letter.textContent ?? "", + state: letterState(letter), + }), + ), + get state() { + return wordState(wordElement(index)); + }, + }), + phase: () => + isTestFinished() ? "finished" : isTestActive() ? "active" : "ready", + liveStats: () => ({ + wpm: currentLiveStats.wpm, + raw: currentLiveStats.raw, + acc: currentLiveStats.acc, + seconds: currentLiveStats.seconds, + }), + }; +} + +/** Unmounts the page, ending its test. */ +export async function cleanupTestPage(): Promise { + // a finishing test can't be stopped: let it finish + for (let i = 0; i < 100 && isResultCalculating(); i++) await frame(); + cleanup(); + setRouter(undefined); +} diff --git a/frontend/__tests__/components/pages/test/TestPage.spec.tsx b/frontend/__tests__/components/pages/test/TestPage.spec.tsx new file mode 100644 index 000000000000..a56b3fef4e88 --- /dev/null +++ b/frontend/__tests__/components/pages/test/TestPage.spec.tsx @@ -0,0 +1,213 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + cleanupTestPage, + renderTestPage, +} from "../../../__harness__/test-page"; + +afterEach(async () => cleanupTestPage()); + +describe("TestPage", () => { + it("mounts a ready test showing the words", async () => { + const page = await renderTestPage({ words: ["the", "fox"] }); + + expect(page.phase()).toBe("ready"); + expect(page.word(0).state).toBe("active"); + expect(page.word(1).state).toBe("upcoming"); + expect(page.word(1).letters()).toEqual([ + { char: "f", state: "untyped" }, + { char: "o", state: "untyped" }, + { char: "x", state: "untyped" }, + ]); + }); + + it("starts the test on the first letter", async () => { + const page = await renderTestPage({ words: ["the", "fox"] }); + + await page.type("t"); + + expect(page.phase()).toBe("active"); + }); + + it("marks correct and incorrect letters", async () => { + const page = await renderTestPage({ words: ["the", "fox"] }); + + await page.type("tx"); + + expect(page.word(0).letters()).toEqual([ + { char: "t", state: "correct" }, + { char: "h", state: "incorrect" }, + { char: "e", state: "untyped" }, + ]); + }); + + it("adds extra letters past the end of the word", async () => { + const page = await renderTestPage({ words: ["the", "fox"] }); + + await page.type("thee"); + + expect(page.word(0).letters()).toEqual([ + { char: "t", state: "correct" }, + { char: "h", state: "correct" }, + { char: "e", state: "correct" }, + { char: "e", state: "extra" }, + ]); + }); + + it("renders input on the next frame, not synchronously", async () => { + const page = await renderTestPage({ words: ["the", "fox"] }); + + await page.type("t", { flush: false }); + expect(page.word(0).letters()[0]).toEqual({ char: "t", state: "untyped" }); + + await page.frame(); + expect(page.word(0).letters()[0]).toEqual({ char: "t", state: "correct" }); + }); + + describe("space", () => { + it("moves to the next word", async () => { + const page = await renderTestPage({ words: ["the", "fox", "ran"] }); + + await page.type("the "); + + expect(page.word(0).state).toBe("typed"); + expect(page.word(1).state).toBe("active"); + }); + + it("marks a mistyped word as an error", async () => { + const page = await renderTestPage({ words: ["the", "fox", "ran"] }); + + await page.type("th "); + + expect(page.word(0).state).toBe("error"); + expect(page.word(1).state).toBe("active"); + }); + }); + + describe("backspace", () => { + it("deletes the last letter", async () => { + const page = await renderTestPage({ words: ["the", "fox"] }); + + await page.type("tx\b"); + + expect(page.word(0).letters()).toEqual([ + { char: "t", state: "correct" }, + { char: "h", state: "untyped" }, + { char: "e", state: "untyped" }, + ]); + }); + + it("goes back into a mistyped word", async () => { + const page = await renderTestPage({ words: ["the", "fox", "ran"] }); + + await page.type("th \b"); + + expect(page.word(0).state).toBe("active"); + expect(page.word(1).state).toBe("upcoming"); + }); + + it("doesn't go back into a correct word", async () => { + const page = await renderTestPage({ words: ["the", "fox", "ran"] }); + + await page.type("the \b"); + + expect(page.word(0).state).toBe("typed"); + expect(page.word(1).state).toBe("active"); + }); + + it("goes back into a correct word in freedom mode", async () => { + const page = await renderTestPage({ + words: ["the", "fox", "ran"], + config: { freedomMode: true }, + }); + + await page.type("the \b"); + + expect(page.word(0).state).toBe("active"); + }); + }); + + it("stops on a wrong letter with stop on error", async () => { + const page = await renderTestPage({ + words: ["the", "fox"], + config: { stopOnError: "letter" }, + }); + + await page.type("txh"); + + expect(page.word(0).letters()).toEqual([ + { char: "t", state: "correct" }, + { char: "h", state: "correct" }, + { char: "e", state: "untyped" }, + ]); + }); + + it("commits an IME composition", async () => { + const page = await renderTestPage({ words: ["かな", "き"] }); + + await page.compose(["k", "か"]); + + expect(page.phase()).toBe("active"); + expect(page.word(0).letters()).toEqual([ + { char: "か", state: "correct" }, + { char: "な", state: "untyped" }, + ]); + }); + + it("ticks live stats every second", async () => { + const page = await renderTestPage({ words: ["the", "fox", "ran"] }); + + await page.type("the "); + expect(page.liveStats().seconds).toBe(0); + + await page.advance(1000); + + expect(page.liveStats().seconds).toBe(1); + expect(page.liveStats().wpm).toBeGreaterThan(0); + expect(page.liveStats().acc).toBe(100); + }); + + describe("finish", () => { + it("finishes on the last letter of a words test", async () => { + const page = await renderTestPage({ words: ["the", "fox"] }); + + await page.type("the fox"); + // the test fades out before it counts as finished + await page.advance(500); + + expect(page.phase()).toBe("finished"); + }); + + it("finishes when a timed test runs out", async () => { + const page = await renderTestPage({ config: { mode: "time", time: 2 } }); + + await page.type("a"); + await page.advance(1000); + expect(page.phase()).toBe("active"); + + await page.advance(1000); + // the test fades out before it counts as finished + await page.advance(500); + expect(page.phase()).toBe("finished"); + }); + }); + + it("starts each spec on a fresh test", async () => { + // the previous specs left typed, finished tests behind + const page = await renderTestPage({ words: ["the", "fox"] }); + + expect(page.phase()).toBe("ready"); + expect(page.liveStats()).toEqual({ + wpm: undefined, + raw: undefined, + acc: undefined, + seconds: undefined, + }); + expect( + page + .word(0) + .letters() + .map((l) => l.state), + ).toEqual(["untyped", "untyped", "untyped"]); + }); +}); diff --git a/frontend/src/ts/dev/expose.ts b/frontend/src/ts/dev/expose.ts index 66426f0741cb..59afdd021069 100644 --- a/frontend/src/ts/dev/expose.ts +++ b/frontend/src/ts/dev/expose.ts @@ -15,9 +15,9 @@ import { checkLayout, runCommand, setCustomText, - typeText, wordsSnapshot, } from "./test-helpers"; +import { compose, typeText } from "./typing-driver"; // Modules hot-updated since the page loaded. Kept on window: it survives this // module's own hot update, and the listener registers once per page. @@ -56,6 +56,7 @@ const mt = { showModal, restartTest, typeText, + compose, wordsSnapshot, caretOffset, checkLayout, diff --git a/frontend/src/ts/dev/test-helpers.ts b/frontend/src/ts/dev/test-helpers.ts index d1432fd40d61..0b38508a1a94 100644 --- a/frontend/src/ts/dev/test-helpers.ts +++ b/frontend/src/ts/dev/test-helpers.ts @@ -8,56 +8,6 @@ import { getCurrentInput } from "../test/events/data"; import { hideModal, showModal } from "../states/modals"; import { restartTest } from "../test/test-logic"; -/** "\n" types a line break, "\b" deletes the previous character. */ -function typeChar(char: string): void { - const input = getInputElement(); - if (document.activeElement !== input) input.focus(); - const key = char === "\n" ? "Enter" : char === "\b" ? "Backspace" : char; - input.dispatchEvent( - new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), - ); - - const inputType = - char === "\n" - ? "insertLineBreak" - : char === "\b" - ? "deleteContentBackward" - : "insertText"; - const data = inputType === "insertText" ? char : null; - const beforeInput = new InputEvent("beforeinput", { - inputType, - data, - bubbles: true, - cancelable: true, - }); - input.dispatchEvent(beforeInput); - if (!beforeInput.defaultPrevented) { - if (inputType === "deleteContentBackward") { - input.value = input.value.slice(0, -1); - } else { - input.setRangeText(char, input.selectionStart, input.selectionEnd, "end"); - } - input.dispatchEvent( - new InputEvent("input", { inputType, data, bubbles: true }), - ); - } - - input.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true })); -} - -/** - * Types `text` through the real input handlers, one character every - * `msPerChar` (0: all in one task). "\n" is a line break, "\b" a backspace. - */ -export async function typeText(text: string, msPerChar = 0): Promise { - for (const char of text) { - typeChar(char); - if (msPerChar > 0) { - await new Promise((resolve) => setTimeout(resolve, msPerChar)); - } - } -} - type LetterSnapshot = { text: string; icon?: string; classes: string }; type ChildSnapshot = | { index: number; classes: string; letters: LetterSnapshot[] } diff --git a/frontend/src/ts/dev/typing-driver.ts b/frontend/src/ts/dev/typing-driver.ts new file mode 100644 index 000000000000..d7f55dd6c572 --- /dev/null +++ b/frontend/src/ts/dev/typing-driver.ts @@ -0,0 +1,89 @@ +// Types through the real words input handlers, firing the events a browser +// fires. Shared by `window.__mt` (browser QA) and the test-page harness. +import { getInputElement } from "../input/input-element"; + +/** "\n" types a line break, "\b" deletes the previous character. */ +export function typeChar(char: string): void { + const input = getInputElement(); + if (document.activeElement !== input) input.focus(); + const key = char === "\n" ? "Enter" : char === "\b" ? "Backspace" : char; + input.dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), + ); + + const inputType = + char === "\n" + ? "insertLineBreak" + : char === "\b" + ? "deleteContentBackward" + : "insertText"; + const data = inputType === "insertText" ? char : null; + const beforeInput = new InputEvent("beforeinput", { + inputType, + data, + bubbles: true, + cancelable: true, + }); + input.dispatchEvent(beforeInput); + if (!beforeInput.defaultPrevented) { + if (inputType === "deleteContentBackward") { + input.value = input.value.slice(0, -1); + } else { + input.setRangeText(char, input.selectionStart, input.selectionEnd, "end"); + } + input.dispatchEvent( + new InputEvent("input", { inputType, data, bubbles: true }), + ); + } + + input.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true })); +} + +/** + * Types `text` through the real input handlers, one character every + * `msPerChar` (0: all in one task). "\n" is a line break, "\b" a backspace. + */ +export async function typeText(text: string, msPerChar = 0): Promise { + for (const char of text) { + typeChar(char); + if (msPerChar > 0) { + await new Promise((resolve) => setTimeout(resolve, msPerChar)); + } + } +} + +/** + * Composes text like an IME: each of `updates` is the composition so far + * (eg. ["k", "か"]), the last one is committed. + */ +export function compose(updates: string[]): void { + const input = getInputElement(); + if (document.activeElement !== input) input.focus(); + const start = input.selectionStart; + input.dispatchEvent( + new CompositionEvent("compositionstart", { data: "", bubbles: true }), + ); + for (const data of updates) { + input.dispatchEvent( + new CompositionEvent("compositionupdate", { data, bubbles: true }), + ); + const init = { + inputType: "insertCompositionText", + data, + isComposing: true, + bubbles: true, + }; + input.dispatchEvent( + new InputEvent("beforeinput", { ...init, cancelable: true }), + ); + // the composition replaces itself in the input + input.setRangeText(data, start, input.selectionEnd, "end"); + input.dispatchEvent(new InputEvent("input", init)); + } + input.dispatchEvent( + new CompositionEvent("compositionend", { + data: updates[updates.length - 1] ?? "", + bubbles: true, + }), + ); +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index fc24ca446be3..24dc20d881ec 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -39,6 +39,9 @@ const worktree = }, }; +// specs on the test-page harness (__tests__/__harness__/test-page.tsx) +const TEST_PAGE_SPECS = "__tests__/components/pages/test/TestPage.spec.tsx"; + const tanstackSolidNoExternal: (string | RegExp)[] = [ "@solidjs/meta", /@tanstack\/solid-.*/, @@ -87,6 +90,8 @@ export const projects: UserWorkspaceConfig[] = [ test: { name: { label: "jsx", color: "green" }, include: ["__tests__/**/*.spec.tsx"], + // the test-page project's: they need the real DOM queries + exclude: [TEST_PAGE_SPECS], environment: "jsdom", globalSetup: "__tests__/global-setup.ts", setupFiles: [ @@ -100,6 +105,23 @@ export const projects: UserWorkspaceConfig[] = [ resolve, ...worktree, }, + { + ssr: { + noExternal: tanstackSolidNoExternal, + }, + test: { + name: { label: "test-page", color: "magenta" }, + include: [TEST_PAGE_SPECS], + environment: "jsdom", + globalSetup: "__tests__/global-setup.ts", + // no mock-dom: the test page's modules query the real DOM + setupFiles: ["__tests__/__harness__/setup-jsx.ts"], + globals: true, + }, + plugins, + resolve, + ...worktree, + }, ]; export default defineConfig({ test: { From f67067c3027649ff35fd2a7a55275a23e18a59da Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 26 Oct 2026 23:12:48 +0100 Subject: [PATCH 02/16] test: run any *.page-spec.tsx on the test-page harness The test-page project included one spec file, so a new spec on the harness would land in the jsx project (mocked DOM queries) and fail obscurely. Specs on it are now named *.page-spec.tsx, and the harness throws if it loads with the mocked DOM. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NZNRfGDYsGYMUTp2zYUTrv --- docs/specs/solid-monkeytype/roadmap.md | 2 +- frontend/__tests__/__harness__/test-page.tsx | 12 +++++++++--- .../{TestPage.spec.tsx => TestPage.page-spec.tsx} | 0 frontend/vitest.config.ts | 8 ++------ 4 files changed, 12 insertions(+), 10 deletions(-) rename frontend/__tests__/components/pages/test/{TestPage.spec.tsx => TestPage.page-spec.tsx} (100%) diff --git a/docs/specs/solid-monkeytype/roadmap.md b/docs/specs/solid-monkeytype/roadmap.md index e9f56ae5d46d..1db4358d975d 100644 --- a/docs/specs/solid-monkeytype/roadmap.md +++ b/docs/specs/solid-monkeytype/roadmap.md @@ -74,7 +74,7 @@ Fully independent from start to finish: H, U1–U5, C1–C3, C5, EP1, EP2, EP4, | Done | ID | Size | Step | Blocked by | Unblocks | |---|---|---|---|---|---| -| [x] | TH1 | L · PR | **Test-page harness** (`__tests__/__harness__/test-page.tsx`, own `test-page` vitest project): mounts the real TestPage and engine in jsdom on fake timers, types through WordsInput (input/beforeinput/keydown/composition, the driver `__mt` uses), advances frames, and reads word/letter states (semantic names, not classes), engine phase and live stats. Starter suite in `TestPage.spec.tsx`. Grows with the engine: restart flow after T6b, finish → result/spinner after T6c | T0 | T2 onward: every later test-page step keeps it green | +| [x] | TH1 | L · PR | **Test-page harness** (`__tests__/__harness__/test-page.tsx`, own `test-page` vitest project): mounts the real TestPage and engine in jsdom on fake timers, types through WordsInput (input/beforeinput/keydown/composition, the driver `__mt` uses), advances frames, and reads word/letter states (semantic names, not classes), engine phase and live stats. Starter suite in `TestPage.page-spec.tsx`; specs on it are named `*.page-spec.tsx`. Grows with the engine: restart flow after T6b, finish → result/spinner after T6c | T0 | T2 onward: every later test-page step keeps it green | ## Track T: test page (critical path) diff --git a/frontend/__tests__/__harness__/test-page.tsx b/frontend/__tests__/__harness__/test-page.tsx index 3b87807ace3e..e5361c05ce4a 100644 --- a/frontend/__tests__/__harness__/test-page.tsx +++ b/frontend/__tests__/__harness__/test-page.tsx @@ -3,9 +3,9 @@ * through the words input like a browser does. The main seam for test page * behaviour: render, type, advance frames, check what's on screen. * - * Specs using it run in the `test-page` vitest project (see - * vitest.config.ts), which has no global DOM query mocks: the engine's - * vanilla modules query the real DOM. + * Name specs using it `*.page-spec.tsx`: they run in the `test-page` vitest + * project, which has no global DOM query mocks (the engine's vanilla modules + * query the real DOM). Loaded in another project, it throws. * * ```tsx * afterEach(async () => cleanupTestPage()); @@ -75,6 +75,12 @@ import * as CustomText from "../../src/ts/test/custom-text"; // fake timers, then jsdom gaps: matchMedia (ad controller, on import; no // query matches) and scrolling (the test keeps itself in view) vi.hoisted(() => { + // mock-dom's querySelector returns a detached div + if (document.querySelector("body") !== document.body) { + throw new Error( + "The test-page harness needs the real DOM: name the spec *.page-spec.tsx", + ); + } // before any import: animejs keeps the Date.now and requestAnimationFrame // it finds on import. Kept for the whole spec file, so it never loses them vi.useFakeTimers({ diff --git a/frontend/__tests__/components/pages/test/TestPage.spec.tsx b/frontend/__tests__/components/pages/test/TestPage.page-spec.tsx similarity index 100% rename from frontend/__tests__/components/pages/test/TestPage.spec.tsx rename to frontend/__tests__/components/pages/test/TestPage.page-spec.tsx diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 24dc20d881ec..d682ae7c5b90 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -39,9 +39,6 @@ const worktree = }, }; -// specs on the test-page harness (__tests__/__harness__/test-page.tsx) -const TEST_PAGE_SPECS = "__tests__/components/pages/test/TestPage.spec.tsx"; - const tanstackSolidNoExternal: (string | RegExp)[] = [ "@solidjs/meta", /@tanstack\/solid-.*/, @@ -90,8 +87,6 @@ export const projects: UserWorkspaceConfig[] = [ test: { name: { label: "jsx", color: "green" }, include: ["__tests__/**/*.spec.tsx"], - // the test-page project's: they need the real DOM queries - exclude: [TEST_PAGE_SPECS], environment: "jsdom", globalSetup: "__tests__/global-setup.ts", setupFiles: [ @@ -111,7 +106,8 @@ export const projects: UserWorkspaceConfig[] = [ }, test: { name: { label: "test-page", color: "magenta" }, - include: [TEST_PAGE_SPECS], + // specs on the test-page harness (__tests__/__harness__/test-page.tsx) + include: ["__tests__/**/*.page-spec.tsx"], environment: "jsdom", globalSetup: "__tests__/global-setup.ts", // no mock-dom: the test page's modules query the real DOM From 2557977620adf309a17787c7a5adae30b09b6bbe Mon Sep 17 00:00:00 2001 From: Miodec Date: Mon, 26 Oct 2026 23:19:30 +0100 Subject: [PATCH 03/16] refactor: inline the __mt helpers into expose.ts test-helpers.ts only fed window.__mt once typing moved to the shared typing driver. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NZNRfGDYsGYMUTp2zYUTrv --- frontend/src/ts/dev/expose.ts | 196 ++++++++++++++++++++++++++-- frontend/src/ts/dev/test-helpers.ts | 190 --------------------------- 2 files changed, 187 insertions(+), 199 deletions(-) delete mode 100644 frontend/src/ts/dev/test-helpers.ts diff --git a/frontend/src/ts/dev/expose.ts b/frontend/src/ts/dev/expose.ts index 59afdd021069..4724ef677eea 100644 --- a/frontend/src/ts/dev/expose.ts +++ b/frontend/src/ts/dev/expose.ts @@ -1,22 +1,19 @@ // Dev-only `window.__mt` handle for poking app internals from the browser console // or browser automation. Loaded via dynamic import behind `import.meta.env.DEV`. -import { getConfig } from "../config/store"; +import type { Command } from "../commandline/types"; +import { Config, getConfig } from "../config/store"; import { setConfig } from "../config/setters"; +import { getInputElement } from "../input/input-element"; import { setPreloading } from "../router/create-router"; import { getRouter } from "../router/instance"; import { navigate } from "../router/navigate"; import { getRoutePage } from "../states/router"; -import { showModal } from "../states/modals"; +import { hideModal, showModal } from "../states/modals"; +import * as CustomText from "../test/custom-text"; +import { getCurrentInput } from "../test/events/data"; import { restartTest } from "../test/test-logic"; import { quickLogin } from "./quick-login"; import { trackedSignals } from "./signal-tracker"; -import { - caretOffset, - checkLayout, - runCommand, - setCustomText, - wordsSnapshot, -} from "./test-helpers"; import { compose, typeText } from "./typing-driver"; // Modules hot-updated since the page loaded. Kept on window: it survives this @@ -31,6 +28,187 @@ if (window.__mtHotUpdates === undefined) { }); } +type LetterSnapshot = { text: string; icon?: string; classes: string }; +type ChildSnapshot = + | { index: number; classes: string; letters: LetterSnapshot[] } + | { sibling: string }; + +/** The rendered #words children: words with their letters, plus newline siblings. */ +function wordsSnapshot(): ChildSnapshot[] { + const words = document.querySelector("#words"); + if (words === null) return []; + return [...words.children].map((child): ChildSnapshot => { + if (!child.classList.contains("word")) return { sibling: child.className }; + return { + index: Number((child as HTMLElement).dataset["wordindex"]), + classes: child.className, + letters: [...child.querySelectorAll(":scope > letter")].map((letter) => ({ + text: letter.textContent ?? "", + icon: letter.querySelector("i")?.className, + classes: letter.className, + })), + }; + }); +} + +/** + * Px offset of the caret from where it should be: the start edge of the next + * letter to type, or the end edge of the last letter (left/right, mirrored for + * right to left words). Within a few px is on. + */ +function caretOffset(): { dx: number; dy: number } | undefined { + const caret = document.querySelector("#caret"); + const letters = document.querySelectorAll("#words .word.active letter"); + if (caret === null || letters.length === 0) return undefined; + const index = getCurrentInput().length; + const target = (letters[index] ?? letters[letters.length - 1]) as Element; + const targetRect = target.getBoundingClientRect(); + const caretRect = caret.getBoundingClientRect(); + const isRtl = getComputedStyle(target).direction === "rtl"; + const atStart = letters[index] !== undefined; + const targetX = atStart !== isRtl ? targetRect.left : targetRect.right; + return { + dx: Math.round(caretRect.left - targetX), + dy: Math.round(caretRect.top - targetRect.top), + }; +} + +/** Starts a custom test with `text`, as the custom text modal's simple mode. */ +async function setCustomText(text: string): Promise { + const words = text + .replace(/ +/g, " ") + .replace(/( *(\r\n|\r|\n) *)/g, "\n ") + .split(" ") + .filter((word) => word !== ""); + CustomText.setMode("repeat"); + CustomText.setPipeDelimiter(false); + CustomText.setText(words); + CustomText.setLimitMode("word"); + CustomText.setLimitValue(words.length); + setConfig("mode", "custom"); + await restartTest(); +} + +/** + * Layout problems on the test page, empty when it's laid out right: the active + * word inside the words wrapper, the caret and the words input on it, three + * full lines (unless all lines are shown), the tape anchor at the tape margin. + */ +function checkLayout(): string[] { + const wrapper = document.querySelector("#wordsWrapper"); + const words = document.querySelector("#words"); + const active = words?.querySelector(".word.active") ?? null; + if (wrapper === null || words === null || active === null) { + return ["no active word"]; + } + const issues: string[] = []; + const wr = wrapper.getBoundingClientRect(); + const ar = active.getBoundingClientRect(); + const px = Math.round; + + if (ar.top < wr.top - 1 || ar.bottom > wr.bottom + 1) { + issues.push( + `active word ${px(ar.top)}-${px(ar.bottom)} outside wrapper ${px(wr.top)}-${px(wr.bottom)}`, + ); + } + const caret = caretOffset(); + // a few px off is on, more at big font sizes + const caretTolerance = Math.max(4, ar.height * 0.1); + if ( + caret !== undefined && + (Math.abs(caret.dx) > caretTolerance || Math.abs(caret.dy) > caretTolerance) + ) { + issues.push(`caret off by ${caret.dx},${caret.dy}`); + } + const input = getInputElement().getBoundingClientRect(); + if ( + Math.abs(input.top + input.height / 2 - (ar.top + ar.height / 2)) > + ar.height + ) { + issues.push(`words input off the active word by ${px(input.top - ar.top)}`); + } + + if (Config.tapeMode !== "off") { + const margin = wr.left + (wr.width * Config.tapeMargin) / 100; + const letters = active.querySelectorAll("letter"); + const anchor = + Config.tapeMode === "letter" + ? letters[getCurrentInput().length]?.getBoundingClientRect().left + : ar.left; + if (anchor !== undefined && Math.abs(anchor - margin) > 3) { + issues.push(`tape anchor ${px(anchor - margin)} from the tape margin`); + } + } else if ( + Config.mode !== "zen" && + (!Config.showAllLines || Config.mode === "time") + ) { + const wordEls = [...words.querySelectorAll(".word")]; + const tops = [ + ...new Set(wordEls.map((el) => px(el.getBoundingClientRect().top))), + ].sort((a, b) => a - b); + const lineHeight = (tops[1] ?? 0) - (tops[0] ?? 0); + if (tops.length > 1 && Math.abs(wr.height - 3 * lineHeight) > 3) { + issues.push(`wrapper ${px(wr.height)}px, not 3 lines of ${lineHeight}px`); + } + const clipped = wordEls.find((el) => { + const r = el.getBoundingClientRect(); + return ( + r.top >= wr.top - 1 && r.top < wr.bottom - 2 && r.bottom > wr.bottom + 1 + ); + }); + if (clipped !== undefined) { + issues.push(`word ${clipped.dataset["wordindex"]} cut off at the bottom`); + } + } + + const marginTop = words.style.marginTop; + if (marginTop !== "" && marginTop !== "0px") { + issues.push(`#words margin-top ${marginTop} (line jump unfinished)`); + } + return issues; +} + +function findCommand(id: string, list: Command[]): Command | undefined { + for (const command of list) { + if (command.id === id) return command; + // dynamic lists (presets, tags, custom themes) fill in on open + command.subgroup?.beforeList?.(); + const found = + command.subgroup !== undefined + ? findCommand(id, command.subgroup.list) + : undefined; + if (found !== undefined) return found; + } + return undefined; +} + +/** + * Runs a commandline command like the commandline does: opens it (the test + * unfocuses, the out of focus warning blurs the words after 1s), executes the + * command, closes it. Input commands take `input` as typed (no validation). + * Ids: `change` subgroups, `set`, + * `setCustom` inputs (eg. setFontSizeCustom). + */ +async function runCommand(id: string, input?: string): Promise { + // lazy: the lists open indexedDB on import, expose loads in jsdom tests + const { commands } = await import("../commandline/lists"); + const command = findCommand(id, commands.list); + if (command?.exec === undefined) { + throw new Error(`No executable commandline command "${id}"`); + } + showModal("Commandline"); + // the out of focus warning shows 1s after unfocusing + await new Promise((resolve) => setTimeout(resolve, 1100)); + const exec = command.exec as (options: { input?: unknown }) => void; + exec({ + input: + input !== undefined && "inputValueConvert" in command + ? (command.inputValueConvert as (value: string) => unknown)(input) + : input, + }); + hideModal("Commandline"); +} + function findSignal(name: string): (typeof trackedSignals)[number] { const matches = trackedSignals.filter((s) => s.name === name); if (matches.length === 0) { diff --git a/frontend/src/ts/dev/test-helpers.ts b/frontend/src/ts/dev/test-helpers.ts deleted file mode 100644 index 0b38508a1a94..000000000000 --- a/frontend/src/ts/dev/test-helpers.ts +++ /dev/null @@ -1,190 +0,0 @@ -// Typing test helpers for `window.__mt` (browser QA and profiling). -import type { Command } from "../commandline/types"; -import { setConfig } from "../config/setters"; -import { Config } from "../config/store"; -import { getInputElement } from "../input/input-element"; -import * as CustomText from "../test/custom-text"; -import { getCurrentInput } from "../test/events/data"; -import { hideModal, showModal } from "../states/modals"; -import { restartTest } from "../test/test-logic"; - -type LetterSnapshot = { text: string; icon?: string; classes: string }; -type ChildSnapshot = - | { index: number; classes: string; letters: LetterSnapshot[] } - | { sibling: string }; - -/** The rendered #words children: words with their letters, plus newline siblings. */ -export function wordsSnapshot(): ChildSnapshot[] { - const words = document.querySelector("#words"); - if (words === null) return []; - return [...words.children].map((child): ChildSnapshot => { - if (!child.classList.contains("word")) return { sibling: child.className }; - return { - index: Number((child as HTMLElement).dataset["wordindex"]), - classes: child.className, - letters: [...child.querySelectorAll(":scope > letter")].map((letter) => ({ - text: letter.textContent ?? "", - icon: letter.querySelector("i")?.className, - classes: letter.className, - })), - }; - }); -} - -/** - * Px offset of the caret from where it should be: the start edge of the next - * letter to type, or the end edge of the last letter (left/right, mirrored for - * right to left words). Within a few px is on. - */ -export function caretOffset(): { dx: number; dy: number } | undefined { - const caret = document.querySelector("#caret"); - const letters = document.querySelectorAll("#words .word.active letter"); - if (caret === null || letters.length === 0) return undefined; - const index = getCurrentInput().length; - const target = (letters[index] ?? letters[letters.length - 1]) as Element; - const targetRect = target.getBoundingClientRect(); - const caretRect = caret.getBoundingClientRect(); - const isRtl = getComputedStyle(target).direction === "rtl"; - const atStart = letters[index] !== undefined; - const targetX = atStart !== isRtl ? targetRect.left : targetRect.right; - return { - dx: Math.round(caretRect.left - targetX), - dy: Math.round(caretRect.top - targetRect.top), - }; -} - -/** Starts a custom test with `text`, as the custom text modal's simple mode. */ -export async function setCustomText(text: string): Promise { - const words = text - .replace(/ +/g, " ") - .replace(/( *(\r\n|\r|\n) *)/g, "\n ") - .split(" ") - .filter((word) => word !== ""); - CustomText.setMode("repeat"); - CustomText.setPipeDelimiter(false); - CustomText.setText(words); - CustomText.setLimitMode("word"); - CustomText.setLimitValue(words.length); - setConfig("mode", "custom"); - await restartTest(); -} - -/** - * Layout problems on the test page, empty when it's laid out right: the active - * word inside the words wrapper, the caret and the words input on it, three - * full lines (unless all lines are shown), the tape anchor at the tape margin. - */ -export function checkLayout(): string[] { - const wrapper = document.querySelector("#wordsWrapper"); - const words = document.querySelector("#words"); - const active = words?.querySelector(".word.active") ?? null; - if (wrapper === null || words === null || active === null) { - return ["no active word"]; - } - const issues: string[] = []; - const wr = wrapper.getBoundingClientRect(); - const ar = active.getBoundingClientRect(); - const px = Math.round; - - if (ar.top < wr.top - 1 || ar.bottom > wr.bottom + 1) { - issues.push( - `active word ${px(ar.top)}-${px(ar.bottom)} outside wrapper ${px(wr.top)}-${px(wr.bottom)}`, - ); - } - const caret = caretOffset(); - // a few px off is on, more at big font sizes - const caretTolerance = Math.max(4, ar.height * 0.1); - if ( - caret !== undefined && - (Math.abs(caret.dx) > caretTolerance || Math.abs(caret.dy) > caretTolerance) - ) { - issues.push(`caret off by ${caret.dx},${caret.dy}`); - } - const input = getInputElement().getBoundingClientRect(); - if ( - Math.abs(input.top + input.height / 2 - (ar.top + ar.height / 2)) > - ar.height - ) { - issues.push(`words input off the active word by ${px(input.top - ar.top)}`); - } - - if (Config.tapeMode !== "off") { - const margin = wr.left + (wr.width * Config.tapeMargin) / 100; - const letters = active.querySelectorAll("letter"); - const anchor = - Config.tapeMode === "letter" - ? letters[getCurrentInput().length]?.getBoundingClientRect().left - : ar.left; - if (anchor !== undefined && Math.abs(anchor - margin) > 3) { - issues.push(`tape anchor ${px(anchor - margin)} from the tape margin`); - } - } else if ( - Config.mode !== "zen" && - (!Config.showAllLines || Config.mode === "time") - ) { - const wordEls = [...words.querySelectorAll(".word")]; - const tops = [ - ...new Set(wordEls.map((el) => px(el.getBoundingClientRect().top))), - ].sort((a, b) => a - b); - const lineHeight = (tops[1] ?? 0) - (tops[0] ?? 0); - if (tops.length > 1 && Math.abs(wr.height - 3 * lineHeight) > 3) { - issues.push(`wrapper ${px(wr.height)}px, not 3 lines of ${lineHeight}px`); - } - const clipped = wordEls.find((el) => { - const r = el.getBoundingClientRect(); - return ( - r.top >= wr.top - 1 && r.top < wr.bottom - 2 && r.bottom > wr.bottom + 1 - ); - }); - if (clipped !== undefined) { - issues.push(`word ${clipped.dataset["wordindex"]} cut off at the bottom`); - } - } - - const marginTop = words.style.marginTop; - if (marginTop !== "" && marginTop !== "0px") { - issues.push(`#words margin-top ${marginTop} (line jump unfinished)`); - } - return issues; -} - -function findCommand(id: string, list: Command[]): Command | undefined { - for (const command of list) { - if (command.id === id) return command; - // dynamic lists (presets, tags, custom themes) fill in on open - command.subgroup?.beforeList?.(); - const found = - command.subgroup !== undefined - ? findCommand(id, command.subgroup.list) - : undefined; - if (found !== undefined) return found; - } - return undefined; -} - -/** - * Runs a commandline command like the commandline does: opens it (the test - * unfocuses, the out of focus warning blurs the words after 1s), executes the - * command, closes it. Input commands take `input` as typed (no validation). - * Ids: `change` subgroups, `set`, - * `setCustom` inputs (eg. setFontSizeCustom). - */ -export async function runCommand(id: string, input?: string): Promise { - // lazy: the lists open indexedDB on import, expose loads in jsdom tests - const { commands } = await import("../commandline/lists"); - const command = findCommand(id, commands.list); - if (command?.exec === undefined) { - throw new Error(`No executable commandline command "${id}"`); - } - showModal("Commandline"); - // the out of focus warning shows 1s after unfocusing - await new Promise((resolve) => setTimeout(resolve, 1100)); - const exec = command.exec as (options: { input?: unknown }) => void; - exec({ - input: - input !== undefined && "inputValueConvert" in command - ? (command.inputValueConvert as (value: string) => unknown)(input) - : input, - }); - hideModal("Commandline"); -} From 8ce4a446055c778e50025aa6bd4afbdee329a020 Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 27 Oct 2026 00:14:42 +0100 Subject: [PATCH 04/16] feat: data-ui-* attributes on test words, words, letters and typo hints (T1) Themes target data-ui-element/-state/-variant: words list their modes, words carry their status and data-ui-active, letters their status and tab/newline variant. The harness reads them instead of classes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NZNRfGDYsGYMUTp2zYUTrv --- frontend/__tests__/__harness__/test-page.tsx | 59 +++++-------- .../pages/test/TestPage.page-spec.tsx | 20 +++-- .../components/pages/test/TypoHints.spec.tsx | 6 +- .../components/pages/test/Words.spec.tsx | 88 +++++++++++++++++-- .../ts/components/pages/test/TypoHints.tsx | 9 +- .../src/ts/components/pages/test/Word.tsx | 9 ++ .../ts/components/pages/test/WordLetter.tsx | 13 ++- .../src/ts/components/pages/test/Words.tsx | 14 +++ frontend/src/ts/states/word-elements.ts | 3 + 9 files changed, 164 insertions(+), 57 deletions(-) diff --git a/frontend/__tests__/__harness__/test-page.tsx b/frontend/__tests__/__harness__/test-page.tsx index e5361c05ce4a..004f9289e749 100644 --- a/frontend/__tests__/__harness__/test-page.tsx +++ b/frontend/__tests__/__harness__/test-page.tsx @@ -59,6 +59,9 @@ import { } from "@tanstack/solid-router"; import { vi } from "vitest"; +import type { WordStatus } from "../../src/ts/components/pages/test/Word"; +import type { LetterStatus } from "../../src/ts/components/pages/test/WordLetter"; + import { TestPage } from "../../src/ts/components/pages/test/TestPage"; import { __testing as configTesting } from "../../src/ts/config/testing"; import { compose, typeText } from "../../src/ts/dev/typing-driver"; @@ -70,6 +73,7 @@ import { isTestFinished, isTestRestarting, } from "../../src/ts/states/test"; +import { LETTER_SELECTOR } from "../../src/ts/states/word-elements"; import * as CustomText from "../../src/ts/test/custom-text"; // fake timers, then jsdom gaps: matchMedia (ad controller, on import; no @@ -126,16 +130,6 @@ vi.mock("../../src/ts/controllers/theme-controller", () => ({ export type TestPhase = "ready" | "active" | "finished"; -export type LetterState = - | "untyped" - | "correct" - | "incorrect" - | "extra" - | "dead" - | "deadCorrect"; - -export type WordState = "active" | "typed" | "error" | "upcoming"; - export type RenderTestPageOptions = { /** The test shows exactly these words, in order (a custom test). */ words?: string[]; @@ -152,8 +146,9 @@ export type TestPageHarness = { /** Moves the clock by `ms`, running timers and frames on the way. */ advance: (ms: number) => Promise; word: (index: number) => { - letters: () => { char: string; state: LetterState }[]; - state: WordState; + letters: () => { char: string; state: LetterStatus }[]; + state: WordStatus; + isActive: boolean; }; phase: () => TestPhase; liveStats: () => { @@ -164,23 +159,6 @@ export type TestPageHarness = { }; }; -function letterState(letter: Element): LetterState { - const has = (name: string): boolean => letter.classList.contains(name); - if (has("dead")) return has("correct") ? "deadCorrect" : "dead"; - if (has("extra")) return "extra"; - if (has("incorrect")) return "incorrect"; - if (has("correct")) return "correct"; - return "untyped"; -} - -function wordState(word: Element): WordState { - const has = (name: string): boolean => word.classList.contains(name); - if (has("active")) return "active"; - if (has("error")) return "error"; - if (has("typed")) return "typed"; - return "upcoming"; -} - async function frame(): Promise { vi.advanceTimersToNextFrame(); // let the frame's promise chains run @@ -227,7 +205,7 @@ export async function renderTestPage( const wordElement = (index: number): Element => { const el = container.querySelector( - `#words .word[data-wordindex="${index}"]`, + `#words [data-ui-element="word"][data-wordindex="${index}"]`, ); if (el === null) throw new Error(`Word ${index} is not rendered`); return el; @@ -248,14 +226,21 @@ export async function renderTestPage( }, word: (index) => ({ letters: () => - [...wordElement(index).querySelectorAll(":scope > letter")].map( - (letter) => ({ - char: letter.textContent ?? "", - state: letterState(letter), - }), - ), + [ + ...wordElement(index).querySelectorAll( + `:scope > ${LETTER_SELECTOR}`, + ), + ].map((letter) => ({ + char: letter.textContent ?? "", + state: letter.dataset["uiState"] as LetterStatus, + })), get state() { - return wordState(wordElement(index)); + return (wordElement(index) as HTMLElement).dataset[ + "uiState" + ] as WordStatus; + }, + get isActive() { + return wordElement(index).hasAttribute("data-ui-active"); }, }), phase: () => diff --git a/frontend/__tests__/components/pages/test/TestPage.page-spec.tsx b/frontend/__tests__/components/pages/test/TestPage.page-spec.tsx index a56b3fef4e88..514b446b4157 100644 --- a/frontend/__tests__/components/pages/test/TestPage.page-spec.tsx +++ b/frontend/__tests__/components/pages/test/TestPage.page-spec.tsx @@ -12,8 +12,9 @@ describe("TestPage", () => { const page = await renderTestPage({ words: ["the", "fox"] }); expect(page.phase()).toBe("ready"); - expect(page.word(0).state).toBe("active"); - expect(page.word(1).state).toBe("upcoming"); + expect(page.word(0).isActive).toBe(true); + expect(page.word(1).isActive).toBe(false); + expect(page.word(1).state).toBe("untyped"); expect(page.word(1).letters()).toEqual([ { char: "f", state: "untyped" }, { char: "o", state: "untyped" }, @@ -71,7 +72,7 @@ describe("TestPage", () => { await page.type("the "); expect(page.word(0).state).toBe("typed"); - expect(page.word(1).state).toBe("active"); + expect(page.word(1).isActive).toBe(true); }); it("marks a mistyped word as an error", async () => { @@ -79,8 +80,8 @@ describe("TestPage", () => { await page.type("th "); - expect(page.word(0).state).toBe("error"); - expect(page.word(1).state).toBe("active"); + expect(page.word(0).state).toBe("typedIncorrectly"); + expect(page.word(1).isActive).toBe(true); }); }); @@ -102,8 +103,9 @@ describe("TestPage", () => { await page.type("th \b"); - expect(page.word(0).state).toBe("active"); - expect(page.word(1).state).toBe("upcoming"); + expect(page.word(0).isActive).toBe(true); + expect(page.word(1).isActive).toBe(false); + expect(page.word(1).state).toBe("untyped"); }); it("doesn't go back into a correct word", async () => { @@ -112,7 +114,7 @@ describe("TestPage", () => { await page.type("the \b"); expect(page.word(0).state).toBe("typed"); - expect(page.word(1).state).toBe("active"); + expect(page.word(1).isActive).toBe(true); }); it("goes back into a correct word in freedom mode", async () => { @@ -123,7 +125,7 @@ describe("TestPage", () => { await page.type("the \b"); - expect(page.word(0).state).toBe("active"); + expect(page.word(0).isActive).toBe(true); }); }); diff --git a/frontend/__tests__/components/pages/test/TypoHints.spec.tsx b/frontend/__tests__/components/pages/test/TypoHints.spec.tsx index 08a3396917e9..138d30d4fd75 100644 --- a/frontend/__tests__/components/pages/test/TypoHints.spec.tsx +++ b/frontend/__tests__/components/pages/test/TypoHints.spec.tsx @@ -51,7 +51,11 @@ function renderWord(text: string): HTMLElement { } function hintsOf(container: HTMLElement): HTMLElement[] { - return [...container.querySelectorAll(".word > .hints > hint")]; + return [ + ...container.querySelectorAll( + '.word > .hints > [data-ui-element="typoHint"]', + ), + ]; } beforeEach(() => { diff --git a/frontend/__tests__/components/pages/test/Words.spec.tsx b/frontend/__tests__/components/pages/test/Words.spec.tsx index 8312f6a2d224..6bf996cdfaf0 100644 --- a/frontend/__tests__/components/pages/test/Words.spec.tsx +++ b/frontend/__tests__/components/pages/test/Words.spec.tsx @@ -7,6 +7,7 @@ import { setTestFocusState } from "../../../../src/ts/states/test"; import { getLineBreakElements, getWordElement, + LETTER_SELECTOR, } from "../../../../src/ts/states/word-elements"; import { appendRenderedWord, @@ -39,20 +40,20 @@ function wordEls(container: HTMLElement): HTMLElement[] { /** sorted class names per letter */ function letterClasses(word: HTMLElement | undefined): string[] { - return [...(word?.querySelectorAll("letter") ?? [])].map((l) => + return [...(word?.querySelectorAll(LETTER_SELECTOR) ?? [])].map((l) => [...l.classList].sort().join(" "), ); } /** sorted icon classes per letter, "" for letters without an icon */ function letterIcons(word: HTMLElement | undefined): string[] { - return [...(word?.querySelectorAll("letter") ?? [])].map((l) => + return [...(word?.querySelectorAll(LETTER_SELECTOR) ?? [])].map((l) => [...(l.querySelector("i")?.classList ?? [])].sort().join(" "), ); } function letterTexts(word: HTMLElement | undefined): string[] { - return [...(word?.querySelectorAll("letter") ?? [])].map( + return [...(word?.querySelectorAll(LETTER_SELECTOR) ?? [])].map( (l) => l.textContent ?? "", ); } @@ -76,6 +77,9 @@ afterEach(() => { setConfigStore("indicateTypos", defaults.indicateTypos); setConfigStore("compositionDisplay", defaults.compositionDisplay); setConfigStore("blindMode", defaults.blindMode); + setConfigStore("highlightMode", defaults.highlightMode); + setConfigStore("flipTestColors", defaults.flipTestColors); + setConfigStore("colorfulMode", defaults.colorfulMode); setConfigStore("showOutOfFocusWarning", defaults.showOutOfFocusWarning); setTestFocusState("focused"); resetWordsRender(); @@ -278,18 +282,92 @@ describe("Words", () => { appendRenderedWord("abc"); flush(); const before = [ - ...(wordEls(container)[0]?.querySelectorAll("letter") ?? []), + ...(wordEls(container)[0]?.querySelectorAll(LETTER_SELECTOR) ?? []), ]; setWordInput(0, "ab"); flush(); const after = [ - ...(wordEls(container)[0]?.querySelectorAll("letter") ?? []), + ...(wordEls(container)[0]?.querySelectorAll(LETTER_SELECTOR) ?? []), ]; expect(after).toEqual(before); expect(after.every((l, i) => l === before[i])).toBe(true); }); }); + describe("theme attributes", () => { + function attrs(el: Element | null | undefined): Record { + return Object.fromEntries( + [...(el?.attributes ?? [])] + .filter((attr) => attr.name.startsWith("data-ui-")) + .map((attr) => [attr.name, attr.value]), + ); + } + + it("marks words with their status and the active one", () => { + const container = renderWords(); + appendRenderedWord("ab"); + appendRenderedWord("cd"); + appendRenderedWord("ef"); + appendRenderedWord("gh"); + setWordInput(0, "ab"); + setWordTyped(0, true); + setWordInput(1, "cx"); + setWordTyped(1, true); + setActiveWord(2); + flush(); + + expect(wordEls(container).map(attrs)).toEqual([ + { "data-ui-element": "word", "data-ui-state": "typed" }, + { "data-ui-element": "word", "data-ui-state": "typedIncorrectly" }, + { + "data-ui-element": "word", + "data-ui-state": "untyped", + "data-ui-active": "", + }, + { "data-ui-element": "word", "data-ui-state": "untyped" }, + ]); + }); + + it("marks letters with their status and tab/newline variant", () => { + const container = renderWords(); + appendRenderedWord("a\tb\n"); + setWordInput(0, "x"); + flush(); + + expect( + [ + ...(wordEls(container)[0]?.querySelectorAll(LETTER_SELECTOR) ?? []), + ].map(attrs), + ).toEqual([ + { "data-ui-element": "letter", "data-ui-state": "incorrect" }, + { + "data-ui-element": "letter", + "data-ui-state": "untyped", + "data-ui-variant": "tab", + }, + { "data-ui-element": "letter", "data-ui-state": "untyped" }, + { + "data-ui-element": "letter", + "data-ui-state": "untyped", + "data-ui-variant": "newline", + }, + ]); + }); + + it("lists the words modes in data-ui-variant", () => { + setConfigStore("highlightMode", "next_two_words"); + setConfigStore("blindMode", true); + setConfigStore("flipTestColors", true); + setConfigStore("colorfulMode", true); + const container = renderWords(); + + expect(attrs(container)).toEqual({ + "data-ui-element": "words", + "data-ui-variant": "highlight-next-two-words blind flipped colorful", + }); + }); + }); + describe("structure", () => { function childClasses(container: HTMLElement): string[] { return [...container.children].map((el) => diff --git a/frontend/src/ts/components/pages/test/TypoHints.tsx b/frontend/src/ts/components/pages/test/TypoHints.tsx index 6fdc452fa314..11e1cd7fe6f7 100644 --- a/frontend/src/ts/components/pages/test/TypoHints.tsx +++ b/frontend/src/ts/components/pages/test/TypoHints.tsx @@ -8,6 +8,7 @@ import { } from "solid-js"; import { getConfig } from "../../../config/store"; +import { LETTER_SELECTOR } from "../../../states/word-elements"; import { RenderedWord, trackTypoHintsLayout, @@ -106,10 +107,9 @@ export function TypoHints(props: { word: RenderedWord }): JSXElement { const container = containerRef; const singleHints = key.hints; if (container === undefined || singleHints.length === 0) return; - const letters = - container.parentElement?.querySelectorAll( - ":scope > letter", - ); + const letters = container.parentElement?.querySelectorAll( + `:scope > ${LETTER_SELECTOR}`, + ); const hintElements = container.children; const boxes = singleHints.map((hint, i): TypoHintBox => { const letter = letters?.[hint.letterIndex]; @@ -133,6 +133,7 @@ export function TypoHints(props: { word: RenderedWord }): JSXElement { {(hint) => ( diff --git a/frontend/src/ts/components/pages/test/Word.tsx b/frontend/src/ts/components/pages/test/Word.tsx index 5d0f41875e84..0776e6d8124c 100644 --- a/frontend/src/ts/components/pages/test/Word.tsx +++ b/frontend/src/ts/components/pages/test/Word.tsx @@ -11,6 +11,8 @@ import { createLetters, createZenLetters } from "./letters"; import { TypoHints } from "./TypoHints"; import { WordLetter } from "./WordLetter"; +export type WordStatus = "untyped" | "typed" | "typedIncorrectly"; + /** A rendered word: its letters and hints container. */ export function Word(props: { word: RenderedWord; @@ -39,6 +41,10 @@ export function Word(props: { props.word.isTyped && !wordsRenderState.rendersLettersFromInput && props.word.input !== props.word.text; + const status = (): WordStatus => { + if (isIncorrect()) return "typedIncorrectly"; + return props.word.isTyped ? "typed" : "untyped"; + }; let wordRef: HTMLDivElement | undefined; onMount(() => { @@ -54,6 +60,9 @@ export function Word(props: { ref={(el) => (wordRef = el)} class="word" data-wordindex={props.word.index} + data-ui-element="word" + data-ui-state={status()} + data-ui-active={props.isActive(props.word.index) ? "" : undefined} // per-class toggles: classes and styles added imperatively // (break-joining, caret) must survive re-renders // oxlint-disable-next-line monkeytype-rules/no-classlist diff --git a/frontend/src/ts/components/pages/test/WordLetter.tsx b/frontend/src/ts/components/pages/test/WordLetter.tsx index ad705c6eb8f5..9a15c5757051 100644 --- a/frontend/src/ts/components/pages/test/WordLetter.tsx +++ b/frontend/src/ts/components/pages/test/WordLetter.tsx @@ -76,9 +76,20 @@ function letterClass(letter: RenderedLetter): string { return cls.slice(1); } +function letterVariant(text: string): string | undefined { + if (text === "\t") return "tab"; + if (text === "\n") return "newline"; + return undefined; +} + export function WordLetter(props: { letter: RenderedLetter }): JSXElement { return ( - + { }; } +/** The theme-facing modes of #words, as data-ui-variant tokens. */ +function wordsVariant(): string { + const variants = [ + `highlight-${getConfig.highlightMode.replaceAll("_", "-")}`, + ]; + if (getConfig.blindMode) variants.push("blind"); + if (getConfig.flipTestColors) variants.push("flipped"); + if (getConfig.colorfulMode) variants.push("colorful"); + if (isJoiningScript()) variants.push("joiningScript"); + return variants.join(" "); +} + /** Renders #words with the test words, and lays them out. */ export function Words(props: { wordsWrapperRef?: () => HTMLElement | undefined; @@ -75,6 +87,8 @@ export function Words(props: {
(wordsRef = el)} + data-ui-element="words" + data-ui-variant={wordsVariant()} class={cn("full-width", { ...wordsModeClasses(), flipped: getConfig.flipTestColors, diff --git a/frontend/src/ts/states/word-elements.ts b/frontend/src/ts/states/word-elements.ts index cd7367172ad4..5fff38761716 100644 --- a/frontend/src/ts/states/word-elements.ts +++ b/frontend/src/ts/states/word-elements.ts @@ -1,3 +1,6 @@ +/** Selects rendered letters (test words, words history, replay). */ +export const LETTER_SELECTOR = '[data-ui-element="letter"]'; + /** * Rendered word elements by word index, registered by the Word component * while mounted. Lets imperative code (caret, layout) find a word without From 75c080134ec7dfd7620a1a123bb6b7d98e37f42e Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 27 Oct 2026 00:14:54 +0100 Subject: [PATCH 05/16] refactor: find letters by data-ui-element, not the letter tag (T1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NZNRfGDYsGYMUTp2zYUTrv --- .../test/words-wrapper-height.spec.ts | 2 +- .../ts/components/pages/test/words-layout.ts | 19 ++++++++++++++----- frontend/src/ts/dev/expose.ts | 19 ++++++++++++------- frontend/src/ts/elements/caret.ts | 10 +++++----- frontend/src/ts/test/break-joining.ts | 3 ++- 5 files changed, 34 insertions(+), 19 deletions(-) diff --git a/frontend/__tests__/test/words-wrapper-height.spec.ts b/frontend/__tests__/test/words-wrapper-height.spec.ts index c6774777d715..bbe17e39ebea 100644 --- a/frontend/__tests__/test/words-wrapper-height.spec.ts +++ b/frontend/__tests__/test/words-wrapper-height.spec.ts @@ -57,7 +57,7 @@ function renderWords(count: number): HTMLElement { for (let i = 0; i < count; i++) { words.insertAdjacentHTML( "beforeend", - `
a
`, + `
a
`, ); registerWordElement(i, words.lastElementChild as HTMLElement); } diff --git a/frontend/src/ts/components/pages/test/words-layout.ts b/frontend/src/ts/components/pages/test/words-layout.ts index 6ed8469b3c7c..d687cec82b03 100644 --- a/frontend/src/ts/components/pages/test/words-layout.ts +++ b/frontend/src/ts/components/pages/test/words-layout.ts @@ -25,6 +25,7 @@ import { import { getLineBreakElements, getWordElement as getRenderedWordElement, + LETTER_SELECTOR, } from "../../../states/word-elements"; import { firstMountedIndex, @@ -148,8 +149,15 @@ function getNlCharWidth( wordsRenderState.rendersLettersFromInput ? word.input : word.text, ); if (chars[chars.length - 1] !== "\n") return 0; - const nlChar = lastWordInLine.el.qsa("letter")[chars.length - 1]; - if (nlChar === undefined || nlChar.hasClass("incorrect")) return 0; + const nlChar = lastWordInLine.el.qsa(LETTER_SELECTOR)[chars.length - 1]; + const nlStatus = nlChar?.getAttribute("data-ui-state"); + if ( + nlChar === undefined || + nlStatus === "incorrect" || + nlStatus === "extra" + ) { + return 0; + } const letterComputedStyle = window.getComputedStyle(nlChar.native); const letterMargin = parseFloat(letterComputedStyle.marginLeft) + @@ -183,11 +191,12 @@ function getTopAndHeightWithDifferentData( top: number; height: number; } { - const lettersEls = activeWord.qsa("letter"); + const lettersEls = activeWord.qsa(LETTER_SELECTOR); const domLettersCount = lettersEls.length; const nodes = []; for (let i = domLettersCount; i < data.length; i++) { const tempLetter = document.createElement("letter"); + tempLetter.dataset["uiElement"] = "letter"; const displayData = data[i] === " " ? "_" : data[i]; tempLetter.textContent = displayData as string; nodes.push(tempLetter); @@ -645,13 +654,13 @@ export function createWordsLayout(options: { let currentWordWidth = 0; const inputLength = getCurrentInput().length; if (Config.tapeMode === "letter" && inputLength > 0) { - const letters = activeWordEl.qsa("letter"); + const letters = activeWordEl.qsa(LETTER_SELECTOR); let lastPositiveLetterWidth = 0; for (let i = 0; i < inputLength; i++) { const letter = letters[i]; if ( (Config.blindMode || Config.hideExtraLetters) && - letter?.hasClass("extra") + letter?.getAttribute("data-ui-state") === "extra" ) { continue; } diff --git a/frontend/src/ts/dev/expose.ts b/frontend/src/ts/dev/expose.ts index 4724ef677eea..5fe9687421ff 100644 --- a/frontend/src/ts/dev/expose.ts +++ b/frontend/src/ts/dev/expose.ts @@ -8,6 +8,7 @@ import { setPreloading } from "../router/create-router"; import { getRouter } from "../router/instance"; import { navigate } from "../router/navigate"; import { getRoutePage } from "../states/router"; +import { LETTER_SELECTOR } from "../states/word-elements"; import { hideModal, showModal } from "../states/modals"; import * as CustomText from "../test/custom-text"; import { getCurrentInput } from "../test/events/data"; @@ -42,11 +43,13 @@ function wordsSnapshot(): ChildSnapshot[] { return { index: Number((child as HTMLElement).dataset["wordindex"]), classes: child.className, - letters: [...child.querySelectorAll(":scope > letter")].map((letter) => ({ - text: letter.textContent ?? "", - icon: letter.querySelector("i")?.className, - classes: letter.className, - })), + letters: [...child.querySelectorAll(`:scope > ${LETTER_SELECTOR}`)].map( + (letter) => ({ + text: letter.textContent ?? "", + icon: letter.querySelector("i")?.className, + classes: letter.className, + }), + ), }; }); } @@ -58,7 +61,9 @@ function wordsSnapshot(): ChildSnapshot[] { */ function caretOffset(): { dx: number; dy: number } | undefined { const caret = document.querySelector("#caret"); - const letters = document.querySelectorAll("#words .word.active letter"); + const letters = document.querySelectorAll( + `#words .word.active ${LETTER_SELECTOR}`, + ); if (caret === null || letters.length === 0) return undefined; const index = getCurrentInput().length; const target = (letters[index] ?? letters[letters.length - 1]) as Element; @@ -130,7 +135,7 @@ function checkLayout(): string[] { if (Config.tapeMode !== "off") { const margin = wr.left + (wr.width * Config.tapeMargin) / 100; - const letters = active.querySelectorAll("letter"); + const letters = active.querySelectorAll(LETTER_SELECTOR); const anchor = Config.tapeMode === "letter" ? letters[getCurrentInput().length]?.getBoundingClientRect().left diff --git a/frontend/src/ts/elements/caret.ts b/frontend/src/ts/elements/caret.ts index c543b27b789b..08d92b7d39e4 100644 --- a/frontend/src/ts/elements/caret.ts +++ b/frontend/src/ts/elements/caret.ts @@ -5,7 +5,7 @@ import { isWordRightToLeft } from "../utils/strings"; import { EasingParam, JSAnimation } from "animejs"; import { ElementWithUtils } from "../utils/dom"; import * as TestWords from "../test/test-words"; -import { getWordElement } from "../states/word-elements"; +import { getWordElement, LETTER_SELECTOR } from "../states/word-elements"; import { isRtlCheckedByLetter } from "../states/test"; let lockedMainCaretInTape = true; @@ -14,13 +14,13 @@ let caretDebug = false; export function toggleCaretDebug(): void { caretDebug = !caretDebug; if (!caretDebug) { - for (const l of document.querySelectorAll(".word letter")) { + for (const l of document.querySelectorAll(`.word ${LETTER_SELECTOR}`)) { l.classList.remove("debugCaret"); l.classList.remove("debugCaretTarget"); l.classList.remove("debugCaretTarget2"); } } else { - for (const l of document.querySelectorAll(".word letter")) { + for (const l of document.querySelectorAll(`.word ${LETTER_SELECTOR}`)) { l.classList.add("debugCaret"); } } @@ -393,7 +393,7 @@ export class Caret { isLanguageRightToLeft: boolean; isDirectionReversed: boolean; }): { left: number; top: number; width: number } { - const letters = options.word?.qsa("letter"); + const letters = options.word?.qsa(LETTER_SELECTOR); if (letters.length === 0) { throw new Error( @@ -411,7 +411,7 @@ export class Caret { if (caretDebug) { if (this.id === "paceCaret") { - for (const l of document.querySelectorAll(".word letter")) { + for (const l of document.querySelectorAll(`.word ${LETTER_SELECTOR}`)) { l.classList.remove("debugCaretTarget"); l.classList.remove("debugCaretTarget2"); l.classList.add("debugCaret"); diff --git a/frontend/src/ts/test/break-joining.ts b/frontend/src/ts/test/break-joining.ts index cb44cf394695..339c4c847fe3 100644 --- a/frontend/src/ts/test/break-joining.ts +++ b/frontend/src/ts/test/break-joining.ts @@ -1,5 +1,6 @@ import { Config } from "../config/store"; import { isJoiningScript } from "../states/test"; +import { LETTER_SELECTOR } from "../states/word-elements"; import { ElementWithUtils } from "../utils/dom"; function canBreak(wordEl: ElementWithUtils): boolean { @@ -12,7 +13,7 @@ function canBreak(wordEl: ElementWithUtils): boolean { function applyIfNeeded(wordEl: ElementWithUtils): void { if (!canBreak(wordEl)) return; - const letters = wordEl.qsa("letter"); + const letters = wordEl.qsa(LETTER_SELECTOR); const firstTop = Math.floor(letters[0]?.getOffsetTop() ?? 0); const isWrapped = letters.some( (l) => Math.floor(l.getOffsetTop()) !== firstTop, From 879942815a2a1de38da26379bc812790bb540998 Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 27 Oct 2026 00:15:11 +0100 Subject: [PATCH 06/16] feat: data-ui-* attributes on result words history and replay letters (T1) Letters carry their final outcome in data-ui-state and what was fixed while typing in data-ui-corrections. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NZNRfGDYsGYMUTp2zYUTrv --- .../pages/test/result/result-words.spec.ts | 38 +++++++++++++++++++ .../pages/test/result/ResultReplay.tsx | 14 ++++++- .../pages/test/result/ResultWordsHistory.tsx | 11 +++++- .../pages/test/result/result-words.ts | 37 +++++++++++++++++- 4 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 frontend/__tests__/components/pages/test/result/result-words.spec.ts diff --git a/frontend/__tests__/components/pages/test/result/result-words.spec.ts b/frontend/__tests__/components/pages/test/result/result-words.spec.ts new file mode 100644 index 000000000000..df282fec31cc --- /dev/null +++ b/frontend/__tests__/components/pages/test/result/result-words.spec.ts @@ -0,0 +1,38 @@ +// static json (layouts...) read from disk, the test state module loads some +import "../../../../__harness__/mock-static"; +import { describe, expect, it } from "vitest"; + +import { letterAttributes } from "../../../../../src/ts/components/pages/test/result/result-words"; + +describe("letterAttributes", () => { + it.each([ + [{}, "untyped", undefined], + [{ state: "" as const }, "untyped", undefined], + [{ state: "correct" as const }, "correct", undefined], + [{ state: "corrected" as const }, "correct", "corrected"], + [{ state: "incorrect" as const }, "incorrect", undefined], + [{ state: "incorrect" as const, extra: true }, "extra", undefined], + ])("maps %o to state %s", (letter, state, corrections) => { + expect(letterAttributes(letter)).toEqual({ + "data-ui-element": "letter", + "data-ui-state": state, + "data-ui-corrections": corrections, + }); + }); + + it("lists both corrections", () => { + expect( + letterAttributes({ state: "corrected", extraCorrected: true })[ + "data-ui-corrections" + ], + ).toBe("corrected extraCorrected"); + }); + + it("marks extra letters typed after a letter and deleted", () => { + expect( + letterAttributes({ state: "incorrect", extraCorrected: true })[ + "data-ui-corrections" + ], + ).toBe("extraCorrected"); + }); +}); diff --git a/frontend/src/ts/components/pages/test/result/ResultReplay.tsx b/frontend/src/ts/components/pages/test/result/ResultReplay.tsx index 87d543846fd3..c1db913adb2e 100644 --- a/frontend/src/ts/components/pages/test/result/ResultReplay.tsx +++ b/frontend/src/ts/components/pages/test/result/ResultReplay.tsx @@ -7,7 +7,12 @@ import { AnimeShow } from "../../../common/anime"; import { buildBalloonHtmlProperties } from "../../../common/Balloon"; import { Fa } from "../../../common/Fa"; import { jumpToLetter, replayState, togglePlayback } from "./replay"; -import { letterClass, wordClass, wordsClass } from "./result-words"; +import { + letterAttributes, + letterClass, + wordClass, + wordsClass, +} from "./result-words"; const playButtonText = { start: "Start replay", @@ -47,14 +52,19 @@ export function ResultReplay(): JSXElement {
{(word, wordIndex) => ( -
+
{(letter, letterIndex) => ( jumpToLetter(wordIndex(), letterIndex()) diff --git a/frontend/src/ts/components/pages/test/result/ResultWordsHistory.tsx b/frontend/src/ts/components/pages/test/result/ResultWordsHistory.tsx index 0d954a715be0..919f1d6edf91 100644 --- a/frontend/src/ts/components/pages/test/result/ResultWordsHistory.tsx +++ b/frontend/src/ts/components/pages/test/result/ResultWordsHistory.tsx @@ -34,7 +34,12 @@ import { AnimeShow } from "../../../common/anime"; import { buildBalloonHtmlProperties } from "../../../common/Balloon"; import { Fa } from "../../../common/Fa"; import * as ResultWordHighlight from "./result-word-highlight"; -import { letterClass, wordClass, wordsClass } from "./result-words"; +import { + letterAttributes, + letterClass, + wordClass, + wordsClass, +} from "./result-words"; type Heatmap = { steps: { val: number; colorId: number }[]; @@ -181,7 +186,7 @@ export function ResultWordsHistory(props: { eventLog: EventLog }): JSXElement { )}
-
+
{(word) => } @@ -260,6 +265,7 @@ function Word(props: { error: props.word.error, })} style={{ color: color() }} + data-ui-element="word" // read by result-word-highlight data-input={props.word.input} onMouseEnter={() => setHovered(true)} @@ -269,6 +275,7 @@ function Word(props: { {(letter) => ( 0 ? corrections.join(" ") : undefined; +} + +/** The theme-facing data-ui-* attributes of a result letter. */ +export function letterAttributes(letter: ResultLetter): { + "data-ui-element": "letter"; + "data-ui-state": ResultLetterStatus; + "data-ui-corrections": string | undefined; +} { + return { + "data-ui-element": "letter", + "data-ui-state": letterStatus(letter), + "data-ui-corrections": letterCorrections(letter), + }; +} + +export function letterClass(letter: ResultLetter): string { return cn({ "text-text": letter.state === "correct" || letter.state === "corrected", "text-error": letter.state === "incorrect", From 54f940ea7756ee4be794a83c03344fcdec778895 Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 27 Oct 2026 00:17:29 +0100 Subject: [PATCH 07/16] refactor: name the result words containers wordsHistory and replayWords (T1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NZNRfGDYsGYMUTp2zYUTrv --- frontend/src/ts/components/pages/test/result/ResultReplay.tsx | 2 +- .../src/ts/components/pages/test/result/ResultWordsHistory.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/ts/components/pages/test/result/ResultReplay.tsx b/frontend/src/ts/components/pages/test/result/ResultReplay.tsx index c1db913adb2e..753125c8520e 100644 --- a/frontend/src/ts/components/pages/test/result/ResultReplay.tsx +++ b/frontend/src/ts/components/pages/test/result/ResultReplay.tsx @@ -52,7 +52,7 @@ export function ResultReplay(): JSXElement {
{(word, wordIndex) => ( diff --git a/frontend/src/ts/components/pages/test/result/ResultWordsHistory.tsx b/frontend/src/ts/components/pages/test/result/ResultWordsHistory.tsx index 919f1d6edf91..9e477d646bbb 100644 --- a/frontend/src/ts/components/pages/test/result/ResultWordsHistory.tsx +++ b/frontend/src/ts/components/pages/test/result/ResultWordsHistory.tsx @@ -186,7 +186,7 @@ export function ResultWordsHistory(props: { eventLog: EventLog }): JSXElement { )}
-
+
{(word) => } From 0689bc7b2cb3109c7ef057273e0bb16ef994e36c Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 27 Oct 2026 00:23:37 +0100 Subject: [PATCH 08/16] refactor: bundled themes target data-ui-* instead of #words, classes and letter (T1) Class selectors map to every state they matched: .correct to correct and deadCorrect, .incorrect to incorrect and extra, .word.typed to typed and typedIncorrectly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NZNRfGDYsGYMUTp2zYUTrv --- frontend/static/themes/aurora.css | 67 ++++++++++++++++---- frontend/static/themes/dark_note.css | 39 +++++++++--- frontend/static/themes/fire.css | 67 ++++++++++++++++---- frontend/static/themes/grape.css | 67 ++++++++++++++++---- frontend/static/themes/phantom.css | 78 ++++++++++++++++++++---- frontend/static/themes/rainbow_trail.css | 78 +++++++++++++++++++----- frontend/static/themes/shadow.css | 56 +++++++++++++++-- frontend/static/themes/trance.css | 67 ++++++++++++++++---- 8 files changed, 436 insertions(+), 83 deletions(-) diff --git a/frontend/static/themes/aurora.css b/frontend/static/themes/aurora.css index 48cce1699745..1b038e52530a 100644 --- a/frontend/static/themes/aurora.css +++ b/frontend/static/themes/aurora.css @@ -58,30 +58,75 @@ a:not(.button):not([data-ui-variant="button"]):hover { animation: aurora 5s linear infinite; } -#words { +[data-ui-element="words"] { --correct-letter-animation: aurora 5s linear infinite; } -#words.flipped { +[data-ui-element="words"][data-ui-variant~="flipped"] { --untyped-letter-color: var(--sub-color); --correct-letter-animation: none; --untyped-letter-animation: aurora 5s linear infinite; } -#words.highlight-off .word letter, -#words.highlight-off .word.typed letter { +[data-ui-element="words"][data-ui-variant~="highlight-off"] + [data-ui-element="word"] + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-off"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: aurora 5s linear infinite; } -#words .word.typed letter.correct, -#words.blind .word.typed letter, -#words.highlight-word .word.typed letter, -#words.highlight-next-word .word.typed letter, -#words.highlight-next-two-words .word.typed letter, -#words.highlight-next-three-words .word.typed letter { +[data-ui-element="words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), +[data-ui-element="words"][data-ui-variant~="blind"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-word"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-word"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-two-words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-three-words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: aurora 5s linear infinite; } -#words.flipped .word.typed letter { +[data-ui-element="words"][data-ui-variant~="flipped"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: none; } diff --git a/frontend/static/themes/dark_note.css b/frontend/static/themes/dark_note.css index b522f4a7aa3e..53a76b08cee0 100644 --- a/frontend/static/themes/dark_note.css +++ b/frontend/static/themes/dark_note.css @@ -55,26 +55,51 @@ body::before { } /* restyling error displays to be strike-through instead of underlined */ -#wordsWrapper .word.error { +[data-ui-element="words"] + [data-ui-element="word"][data-ui-state="typedIncorrectly"] { border-bottom: none; } -#words .word.error { +[data-ui-element="words"] + [data-ui-element="word"][data-ui-state="typedIncorrectly"] { text-shadow: none; } -#words:not(.blind) .word letter.incorrect, -#words:not(.blind).colorfulMode .word letter.incorrect { +[data-ui-element="words"]:not([data-ui-variant~="blind"]) + [data-ui-element="word"] + [data-ui-element="letter"]:is( + [data-ui-state="incorrect"], + [data-ui-state="extra"] + ), +[data-ui-element="words"]:not( + [data-ui-variant~="blind"] + )[data-ui-variant~="colorful"] + [data-ui-element="word"] + [data-ui-element="letter"]:is( + [data-ui-state="incorrect"], + [data-ui-state="extra"] + ) { text-decoration: line-through; text-decoration-color: var(--error-color); text-decoration-thickness: 2px; } -#words:not(.blind) .word letter.incorrect:not(.extra), -#words:not(.blind).colorfulMode .word letter.incorrect:not(.extra) { +[data-ui-element="words"]:not([data-ui-variant~="blind"]) + [data-ui-element="word"] + [data-ui-element="letter"][data-ui-state="incorrect"], +[data-ui-element="words"]:not( + [data-ui-variant~="blind"] + )[data-ui-variant~="colorful"] + [data-ui-element="word"] + [data-ui-element="letter"][data-ui-state="incorrect"] { color: var(--current-color); } -#words:not(.blind) .word.error letter:not(.correct):not(.incorrect)::after { +[data-ui-element="words"]:not([data-ui-variant~="blind"]) + [data-ui-element="word"][data-ui-state="typedIncorrectly"] + [data-ui-element="letter"]:is( + [data-ui-state="untyped"], + [data-ui-state="dead"] + )::after { background: var(--sub-color); } diff --git a/frontend/static/themes/fire.css b/frontend/static/themes/fire.css index a36d33d1f9bc..4cfe3a57baa7 100644 --- a/frontend/static/themes/fire.css +++ b/frontend/static/themes/fire.css @@ -64,30 +64,75 @@ a:not(.button):not([data-ui-variant="button"]):hover { animation: fire 5s linear infinite; } -#words { +[data-ui-element="words"] { --correct-letter-animation: fire 5s linear infinite; } -#words.flipped { +[data-ui-element="words"][data-ui-variant~="flipped"] { --untyped-letter-color: var(--sub-color); --correct-letter-animation: none; --untyped-letter-animation: fire 5s linear infinite; } -#words.highlight-off .word letter, -#words.highlight-off .word.typed letter { +[data-ui-element="words"][data-ui-variant~="highlight-off"] + [data-ui-element="word"] + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-off"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: fire 5s linear infinite; } -#words .word.typed letter.correct, -#words.blind .word.typed letter, -#words.highlight-word .word.typed letter, -#words.highlight-next-word .word.typed letter, -#words.highlight-next-two-words .word.typed letter, -#words.highlight-next-three-words .word.typed letter { +[data-ui-element="words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), +[data-ui-element="words"][data-ui-variant~="blind"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-word"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-word"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-two-words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-three-words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: fire 5s linear infinite; } -#words.flipped .word.typed letter { +[data-ui-element="words"][data-ui-variant~="flipped"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: none; } diff --git a/frontend/static/themes/grape.css b/frontend/static/themes/grape.css index 0fc62c15ecf5..93f39ac9c476 100644 --- a/frontend/static/themes/grape.css +++ b/frontend/static/themes/grape.css @@ -59,30 +59,75 @@ a:not(.button):not([data-ui-variant="button"]):hover { animation: grape 5s linear infinite; } -#words { +[data-ui-element="words"] { --correct-letter-animation: grape 5s linear infinite; } -#words.flipped { +[data-ui-element="words"][data-ui-variant~="flipped"] { --untyped-letter-color: var(--sub-color); --correct-letter-animation: none; --untyped-letter-animation: grape 5s linear infinite; } -#words.highlight-off .word letter, -#words.highlight-off .word.typed letter { +[data-ui-element="words"][data-ui-variant~="highlight-off"] + [data-ui-element="word"] + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-off"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: grape 5s linear infinite; } -#words .word.typed letter.correct, -#words.blind .word.typed letter, -#words.highlight-word .word.typed letter, -#words.highlight-next-word .word.typed letter, -#words.highlight-next-two-words .word.typed letter, -#words.highlight-next-three-words .word.typed letter { +[data-ui-element="words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), +[data-ui-element="words"][data-ui-variant~="blind"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-word"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-word"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-two-words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-three-words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: grape 5s linear infinite; } -#words.flipped .word.typed letter { +[data-ui-element="words"][data-ui-variant~="flipped"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: none; } diff --git a/frontend/static/themes/phantom.css b/frontend/static/themes/phantom.css index 074c1507f2aa..87c80faae826 100644 --- a/frontend/static/themes/phantom.css +++ b/frontend/static/themes/phantom.css @@ -49,7 +49,7 @@ } } -#words { +[data-ui-element="words"] { --correct-letter-color: var(--text-color); --untyped-letter-color: #565f89; } @@ -135,26 +135,80 @@ button.text:hover, animation: phantom-glow 3s linear infinite; } -#words.highlight-off .word letter, -#words.highlight-off .word.typed letter { +[data-ui-element="words"][data-ui-variant~="highlight-off"] + [data-ui-element="word"] + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-off"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: phantom-glow 5s linear infinite; } [data-ui-element="resultStatSubValue"], -#words .word letter.correct, -#words .word.typed letter.correct, -#words.blind .word.typed letter, -#words.highlight-word .word.typed letter, -#words.highlight-next-word .word.typed letter, -#words.highlight-next-two-words .word.typed letter, -#words.highlight-next-three-words .word.typed letter { +[data-ui-element="words"] + [data-ui-element="word"] + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), +[data-ui-element="words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), +[data-ui-element="words"][data-ui-variant~="blind"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-word"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-word"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-two-words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-three-words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: phantom-glow 5s linear infinite; } -#words.flipped .word.typed letter { +[data-ui-element="words"][data-ui-variant~="flipped"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: none; } -.incorrect { +[data-ui-element="words"] + [data-ui-element="letter"]:is( + [data-ui-state="incorrect"], + [data-ui-state="extra"] + ) { animation: phantom-glow-incorrect 5s linear infinite; } diff --git a/frontend/static/themes/rainbow_trail.css b/frontend/static/themes/rainbow_trail.css index c02ed93e0adb..950ec01b7183 100644 --- a/frontend/static/themes/rainbow_trail.css +++ b/frontend/static/themes/rainbow_trail.css @@ -63,7 +63,7 @@ footer button:hover > .relative > i, transition: all 1s ease; } -#words { +[data-ui-element="words"] { --correct-letter-animation: rainbow-infinite-loop 5s linear infinite; --incorrect-letter-animation: rainbow-error-infinite-loop 1s alternate ease-in-out infinite; @@ -71,35 +71,85 @@ footer button:hover > .relative > i, infinite; } -#words.highlight-off .word letter, -#words.highlight-off .word.typed letter { +[data-ui-element="words"][data-ui-variant~="highlight-off"] + [data-ui-element="word"] + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-off"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: rainbow-infinite-loop 5s linear infinite; } -#words .word.typed letter.correct, -#words.blind .word.typed letter, -#words.highlight-word .word.typed letter, -#words.highlight-next-word .word.typed letter, -#words.highlight-next-two-words .word.typed letter, -#words.highlight-next-three-words .word.typed letter, -#replayWords .word .text-text { +[data-ui-element="words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), +[data-ui-element="words"][data-ui-variant~="blind"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-word"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-word"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-two-words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-three-words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="replayWords"] + [data-ui-element="word"] + [data-ui-element="letter"][data-ui-state="correct"] { animation: rainbow 7.5s ease-in-out forwards; } -#words:not(.blind) .word.error { +[data-ui-element="words"]:not([data-ui-variant~="blind"]) + [data-ui-element="word"][data-ui-state="typedIncorrectly"] { animation: rainbow-error 7.5s ease-in-out forwards; } -#words.highlight-word .word.active letter { +[data-ui-element="words"][data-ui-variant~="highlight-word"] + [data-ui-element="word"][data-ui-active] + [data-ui-element="letter"] { animation: var(--correct-letter-animation); } -#words.flipped { +[data-ui-element="words"][data-ui-variant~="flipped"] { --correct-letter-animation: none; --untyped-letter-animation: rainbow-infinite-loop 5s linear infinite; } -#words.flipped .word.typed letter { +[data-ui-element="words"][data-ui-variant~="flipped"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: none; } diff --git a/frontend/static/themes/shadow.css b/frontend/static/themes/shadow.css index 77bed69211c4..da18230d9d45 100644 --- a/frontend/static/themes/shadow.css +++ b/frontend/static/themes/shadow.css @@ -33,12 +33,56 @@ a:not(.button):not([data-ui-variant="button"]):hover { animation: shadow-repeat 3s linear infinite forwards; } -#typingTest .word letter.correct, -#typingTest #words.blind:where(:not(.highlight-off)) .word.typed letter, -#typingTest #words.highlight-word .word.typed letter.correct, -#typingTest #words.highlight-next-word .word.typed letter.correct, -#typingTest #words.highlight-next-two-words .word.typed letter.correct, -#typingTest #words.highlight-next-three-words .word.typed letter.correct { +[data-ui-element="words"] + [data-ui-element="word"] + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), +[data-ui-element="words"][data-ui-variant~="blind"]:where( + :not([data-ui-variant~="highlight-off"]) + ) + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-word"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), +[data-ui-element="words"][data-ui-variant~="highlight-next-word"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), +[data-ui-element="words"][data-ui-variant~="highlight-next-two-words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), +[data-ui-element="words"][data-ui-variant~="highlight-next-three-words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ) { color: var(--correct-letter-color); animation: shadow 5s linear 1 forwards; } diff --git a/frontend/static/themes/trance.css b/frontend/static/themes/trance.css index 97776935ceb4..2c2a56fabd19 100644 --- a/frontend/static/themes/trance.css +++ b/frontend/static/themes/trance.css @@ -47,30 +47,75 @@ a:not(.button):not([data-ui-variant="button"]):hover { animation: trance 5s linear infinite; } -#words { +[data-ui-element="words"] { --correct-letter-animation: trance 5s linear infinite; } -#words.flipped { +[data-ui-element="words"][data-ui-variant~="flipped"] { --untyped-letter-color: var(--sub-color); --correct-letter-animation: none; --untyped-letter-animation: trance 5s linear infinite; } -#words.highlight-off .word letter, -#words.highlight-off .word.typed letter { +[data-ui-element="words"][data-ui-variant~="highlight-off"] + [data-ui-element="word"] + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-off"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: trance 5s linear infinite; } -#words .word.typed letter.correct, -#words.blind .word.typed letter, -#words.highlight-word .word.typed letter, -#words.highlight-next-word .word.typed letter, -#words.highlight-next-two-words .word.typed letter, -#words.highlight-next-three-words .word.typed letter { +[data-ui-element="words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), +[data-ui-element="words"][data-ui-variant~="blind"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-word"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-word"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-two-words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-next-three-words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: trance 5s linear infinite; } -#words.flipped .word.typed letter { +[data-ui-element="words"][data-ui-variant~="flipped"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { animation: none; } From 9768cfb2945224cd093724b5bdc56797c3e6e7d5 Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 27 Oct 2026 00:23:38 +0100 Subject: [PATCH 09/16] refactor: funbox css targets data-ui-* instead of #words and letter (T1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NZNRfGDYsGYMUTp2zYUTrv --- frontend/static/funbox/choo_choo.css | 8 +++++--- frontend/static/funbox/earthquake.css | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/frontend/static/funbox/choo_choo.css b/frontend/static/funbox/choo_choo.css index 5e0f9ca897e7..4950d9398400 100644 --- a/frontend/static/funbox/choo_choo.css +++ b/frontend/static/funbox/choo_choo.css @@ -12,12 +12,14 @@ } } -#words { +[data-ui-element="words"] { --correct-letter-animation: choochoo 2s infinite linear; --untyped-letter-animation: choochoo 2s infinite linear; } -#words letter, -#words.joiningScript .word letter { +[data-ui-element="words"] [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="joiningScript"] + [data-ui-element="word"] + [data-ui-element="letter"] { display: inline-block; } diff --git a/frontend/static/funbox/earthquake.css b/frontend/static/funbox/earthquake.css index 427ae640b5c9..40b22998eec2 100644 --- a/frontend/static/funbox/earthquake.css +++ b/frontend/static/funbox/earthquake.css @@ -34,14 +34,16 @@ } } -#words { +[data-ui-element="words"] { --correct-letter-animation: shake_dat_ass 0.25s infinite linear; --untyped-letter-animation: shake_dat_ass 0.25s infinite linear; --incorrect-letter-animation: shake_dat_ass 0.25s infinite linear; --extra-letter-animation: shake_dat_ass 0.25s infinite linear; } -#words letter, -#words.joiningScript .word letter { +[data-ui-element="words"] [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="joiningScript"] + [data-ui-element="word"] + [data-ui-element="letter"] { display: inline-block; } From 969b50d959f137b6992d61376e0c76c297b7c50d Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 27 Oct 2026 00:28:02 +0100 Subject: [PATCH 10/16] docs: theme attributes as built in T1, tag swap moves to CL3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NZNRfGDYsGYMUTp2zYUTrv --- GLOSSARY.md | 2 +- docs/specs/solid-monkeytype/roadmap.md | 8 ++++---- docs/specs/solid-monkeytype/spec.md | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/GLOSSARY.md b/GLOSSARY.md index cf719efb05fc..0e2f8646bef4 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -100,5 +100,5 @@ _Avoid_: restarting setting, test-affecting config ## Theming **Theme attributes**: -The `data-ui-element`, `data-ui-state` and `data-ui-variant` attributes on themeable parts of the site, which themes and custom CSS target. Ids and class names are not part of them and can change. +The `data-ui-element`, `data-ui-state` and `data-ui-variant` attributes on themeable parts of the site, which themes and custom CSS target, plus the boolean or list attributes for facts independent of the state (`data-ui-active`, `data-ui-corrections`). Ids and class names are not part of them and can change. _Avoid_: theme selectors, theme classes, theme hooks diff --git a/docs/specs/solid-monkeytype/roadmap.md b/docs/specs/solid-monkeytype/roadmap.md index 1db4358d975d..17973444d87c 100644 --- a/docs/specs/solid-monkeytype/roadmap.md +++ b/docs/specs/solid-monkeytype/roadmap.md @@ -11,7 +11,7 @@ Companion to `spec.md`. Every step is one small change, behaviour-preserving apa Each step can start once the previous step's **bold** items are done. Everything listed on one step can run in parallel. - [ ] 1. **H1–H5** housekeeping, then start all at once: **T0** (merge #8507), **C1**, **EP1**, **EP2**, **L1**, **L2**, plus any of U1, U5, C5, EP4, L4–L11. -- [ ] 2. **TH1** test-page harness, then **T1** letter → span + `data-ui-*`. Alongside: U2, U3, U4 (after U1), C2, C3. +- [ ] 2. **TH1** test-page harness, then **T1** `data-ui-*` on words and letters. Alongside: U2, U3, U4 (after U1), C2, C3. - [ ] 3. **T2** break joining. Alongside: EP3. - [ ] 4. **T3** caret boundary. Then L3 and L12 can start. - [ ] 5. **T4** word registry private. @@ -25,7 +25,7 @@ Each step can start once the previous step's **bold** items are done. Everything ``` T: test page (CRITICAL PATH, serial) - T0 #8507 ─► T1 letter→span + data-ui-* ─► T2 break-joining ─► T3 caret boundary ─► T4 registry private ─► T5 delete test-ui ─► T6 engine split (T6a–c) ─► T7 drop page-mount workaround + T0 #8507 ─► T1 words/letters data-ui-* ─► T2 break-joining ─► T3 caret boundary ─► T4 registry private ─► T5 delete test-ui ─► T6 engine split (T6a–c) ─► T7 drop page-mount workaround TH1 test-page harness (after T0, before T1) ▲ needs EP1, EP2, L1, L2 ▲ needs L3, L4, L5 │ C: config C1 ─► C2×5, C3 areas (parallel) ──────────────────────────► C4 delete Config + configEvent (needs T6) @@ -83,7 +83,7 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec | Done | ID | Size | Step | Blocked by | Removes / unblocks | |---|---|---|---|---|---| | [ ] | T0 | M · PR #8507 | Merge **#8507**: Words triggers its own layout (flush subscription, config/font/resize effects) | none | test-ui's layout calls | -| [ ] | T1 | L · PR | **`` → ``**, plus `data-ui-element` on words/word/typo hints; move the bundled theme and funbox CSS to the new selectors; old selectors keep working. `data-ui-state` values are the harness's state names; switch its word/letter helpers to `data-ui-*`, specs unchanged. Benchmark letter rendering with the profiling harness | T0, TH1 | The made-up element; proves the theme attribute approach before the caret measures letters | +| [x] | T1 | L · PR | **Theme attributes on the test and result words**: words (`data-ui-variant` lists its modes), word (`WordStatus`, `data-ui-active`), letter (`LetterStatus`, tab/newline variant), typo hint; result `wordsHistory`/`replayWords`, word, letter (`ResultLetterStatus`, `data-ui-corrections`). ``/`` keep their tags and classes so old selectors keep working (spans in CL3); code finds letters by `LETTER_SELECTOR`. Bundled theme and funbox CSS moved over (computed styles unchanged across highlight/blind/flipped/colorful). Harness reads `data-ui-*` (`state` + `isActive`). Keystroke benchmark: no change | T0, TH1 | The made-up element; proves the theme attribute approach before the caret measures letters | | [ ] | T2 | M · PR | **Break joining** computed in the layout pass, rendered as reactive class/style on Word; delete the break-joining module's DOM writes | T1, TH1 | The only outside writes into Word elements | | [ ] | T3 | L · PR | **Caret boundary**: Words is the only thing that drives the caret and pace caret; line-jump/tape-scroll handoff becomes internal to Words; pace-caret timing moves to a DOM-free module outputting a word/letter target; caret and pace caret get `data-ui-element`. Caret and words layout read the typed input from the render store (input writes it alongside the EventLog), not the engine's `getCurrentInput()`. Decide the caret internals here (reactive, imperative handle, or a mix) | T2 | Outside caret callers (test-ui, timer, focus, WordsInput) | | [ ] | T4 | S · commit | **Word registry private** to Words; only the synchronous overflow check stays exported | T3 | The public word-elements bridge | @@ -147,7 +147,7 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec |---|---|---|---|---| | [ ] | CL1 | M · PR | Delete the DOM-wrapper utility library, the ref-with-utils hook and `onDOMReady` | T7, all L, U2 | | [ ] | CL2 | M · PR | DOM-query / `innerHTML` lint ban, with the server-HTML allowlist (notifications, simple modal, version history, quote-search highlight) | CL1 | -| [ ] | CL3 | M · PR | **Announced removal of legacy theme selectors** (old ids, classes, `letter`): the single break for user custom CSS; delete remaining SCSS | T1, L11, L12 | +| [ ] | CL3 | M · PR | **Announced removal of legacy theme selectors** (old ids, classes, `letter`): the single break for user custom CSS; ``/`` become ``s (live test, words history, replay) and drop their state classes; delete remaining SCSS | T1, L11, L12 | | [ ] | CL4 | S · commit | Update coding standards and agent docs to describe only the final patterns | CL1 | ## Track TR: tribe (last) diff --git a/docs/specs/solid-monkeytype/spec.md b/docs/specs/solid-monkeytype/spec.md index cf5d95260cbf..72d38cb3deaa 100644 --- a/docs/specs/solid-monkeytype/spec.md +++ b/docs/specs/solid-monkeytype/spec.md @@ -84,7 +84,7 @@ Users should see no behaviour change, except where a bug at today's seams disapp 42. As a theme author, I want to target stable `data-ui-element` / `data-ui-state` / `data-ui-variant` attributes, so that markup refactors don't break my theme. 43. As a theme author, I want every themeable part of the test (words, word, letter, caret, pace caret, typo hint, live stats, result parts) to carry a `data-ui-element`, so that I can style all of it. -44. As a theme author, I want letter and word states (correct, incorrect, extra, typed, active, blind…) exposed as `data-ui-state`, so that I don't depend on internal class names. +44. As a theme author, I want letter and word states (correct, incorrect, extra, typed…), the active word and the words modes (blind, highlight, flipped…) exposed as `data-ui-*` attributes, so that I don't depend on internal class names. 45. As a user with custom CSS, I want old selectors to keep working until a single announced removal, so that I have time to move my CSS over. 46. As a funbox CSS author, I want funbox styling to keep a stable hook (body-level funbox state and the funbox stylesheet), so that funbox styles apply. @@ -197,8 +197,8 @@ Users should see no behaviour change, except where a bug at today's seams disapp ### Theming and styling -- **Theme-facing API**: `data-ui-element` (identity), `data-ui-state` (eg. correct, incorrect, extra, typed, active, blind) and `data-ui-variant`. Navigation already uses `data-ui-element`/`data-ui-variant`, and some themes already target it. Every themeable component gets them, including the test page: words, word, letter, caret, pace caret, typo hint, live stats, result parts. Ids and classes are internal and can change freely. -- **`` goes away**: letters render as `` (inline by default, so no extra CSS). Benchmark letter rendering with the profiling harness when switching states to attributes. Any other made-up elements get the same treatment. +- **Theme-facing API**: `data-ui-element` (identity), `data-ui-state` (one value from the component's status type, eg. a letter's `LetterStatus`, a word's `WordStatus`) and `data-ui-variant` (space-separated where several apply, matched with `~=`, eg. the words modes `highlight-word blind flipped colorful joiningScript`). Facts independent of the state get their own attribute: `data-ui-active` on the active word, `data-ui-corrections` (`corrected`, `extraCorrected`) on result letters. The result words containers are `wordsHistory` and `replayWords`; their words and letters share the `word`/`letter` names. Navigation already uses `data-ui-element`/`data-ui-variant`, and some themes already target it. Every themeable component gets them, including the test page: words, word, letter, caret, pace caret, typo hint, live stats, result parts. Ids and classes are internal and can change freely. +- **`` goes away**: letters render as `` (inline by default, so no extra CSS). Any other made-up elements (``) get the same treatment. The attributes come first (T1), on the old tags with their classes, so legacy selectors keep working; the tags become spans in CL3, the single announced break. Code finds letters by `LETTER_SELECTOR`, never the tag. - **Migration of themes**: each conversion PR adds the attributes and moves the bundled themes and funbox CSS over. Old ids/classes/`letter` selectors keep working until one announced removal step at the end, which is the single break for user custom CSS. - **Styling**: all component styling is Tailwind through the class-merge helper. SCSS remains only for keyframes and base styles that must target `data-ui-*` (eg. letter colours from theme variables, blind mode); the test, caret and letter SCSS is rewritten against `data-ui-*` and the SCSS files are deleted by the end. @@ -279,7 +279,7 @@ From config (C1): | Q7 | Test-page harness is the main test seam | | Q8 | Tribe lands after everything else is Solid | | Q9 | Spec and roadmap live in `docs/specs/solid-monkeytype/`, committed; no tracker | -| Q10 | `` becomes `` | +| Q10 | `` becomes ``, in CL3; T1 adds the attributes to the old tag | | Q11 | Engine owns its state module | | Q12 | All styling to Tailwind; SCSS only for keyframes and `data-ui-*` base styles | | Q14 | Lint rules land when each layer is clean | From b91bb9e98f82e3b49bf5437dea0d8aeff085b0b8 Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 27 Oct 2026 12:04:43 +0100 Subject: [PATCH 11/16] refactor: remaining theme and funbox css targets data-ui-*, adds rtl variant and wordsWrapper (T1) Leaves taro/modern_ink .word.error (also styles result words, which have no error attribute) and funbox read_ahead_disabled/#wordsInput (L2). Drops phantom's .word .active rule, which matched nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NZNRfGDYsGYMUTp2zYUTrv --- .../__tests__/components/pages/test/Words.spec.tsx | 13 +++++++++++-- frontend/src/ts/components/pages/test/TestPage.tsx | 1 + frontend/src/ts/components/pages/test/Words.tsx | 1 + frontend/static/funbox/asl.css | 2 +- frontend/static/funbox/backwards.css | 7 ++++--- frontend/static/funbox/crt.css | 6 ++++-- frontend/static/funbox/simon_says.css | 2 +- frontend/static/funbox/tts.css | 2 +- frontend/static/themes/chaos_theory.css | 2 +- frontend/static/themes/dark_note.css | 2 +- frontend/static/themes/phantom.css | 4 ---- frontend/static/themes/rgb.css | 6 +++--- frontend/static/themes/solarized_osaka.css | 2 +- 13 files changed, 30 insertions(+), 20 deletions(-) diff --git a/frontend/__tests__/components/pages/test/Words.spec.tsx b/frontend/__tests__/components/pages/test/Words.spec.tsx index 6bf996cdfaf0..c7ed87676c91 100644 --- a/frontend/__tests__/components/pages/test/Words.spec.tsx +++ b/frontend/__tests__/components/pages/test/Words.spec.tsx @@ -3,7 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { setConfigStore } from "../../../../src/ts/config/store"; import { getDefaultConfig } from "../../../../src/ts/constants/default-config"; -import { setTestFocusState } from "../../../../src/ts/states/test"; +import { + setIsLanguageRightToLeft, + setJoiningScript, + setTestFocusState, +} from "../../../../src/ts/states/test"; import { getLineBreakElements, getWordElement, @@ -82,6 +86,8 @@ afterEach(() => { setConfigStore("colorfulMode", defaults.colorfulMode); setConfigStore("showOutOfFocusWarning", defaults.showOutOfFocusWarning); setTestFocusState("focused"); + setJoiningScript(false); + setIsLanguageRightToLeft(false); resetWordsRender(); frames.clear(); vi.unstubAllGlobals(); @@ -359,11 +365,14 @@ describe("Words", () => { setConfigStore("blindMode", true); setConfigStore("flipTestColors", true); setConfigStore("colorfulMode", true); + setJoiningScript(true); + setIsLanguageRightToLeft(true); const container = renderWords(); expect(attrs(container)).toEqual({ "data-ui-element": "words", - "data-ui-variant": "highlight-next-two-words blind flipped colorful", + "data-ui-variant": + "highlight-next-two-words blind flipped colorful joiningScript rtl", }); }); }); diff --git a/frontend/src/ts/components/pages/test/TestPage.tsx b/frontend/src/ts/components/pages/test/TestPage.tsx index 793e27926482..4c2e2c97d381 100644 --- a/frontend/src/ts/components/pages/test/TestPage.tsx +++ b/frontend/src/ts/components/pages/test/TestPage.tsx @@ -207,6 +207,7 @@ export function TestPage(): JSXElement {
(wordsWrapperRef = el)} class={cn("content-grid full-width", wordsModeClasses(), { hidden: isWordsWrapperHidden(), diff --git a/frontend/src/ts/components/pages/test/Words.tsx b/frontend/src/ts/components/pages/test/Words.tsx index 1709a012ca39..92b66b528eff 100644 --- a/frontend/src/ts/components/pages/test/Words.tsx +++ b/frontend/src/ts/components/pages/test/Words.tsx @@ -58,6 +58,7 @@ function wordsVariant(): string { if (getConfig.flipTestColors) variants.push("flipped"); if (getConfig.colorfulMode) variants.push("colorful"); if (isJoiningScript()) variants.push("joiningScript"); + if (isLanguageRightToLeft()) variants.push("rtl"); return variants.join(" "); } diff --git a/frontend/static/funbox/asl.css b/frontend/static/funbox/asl.css index fac24919085d..da640150eef9 100644 --- a/frontend/static/funbox/asl.css +++ b/frontend/static/funbox/asl.css @@ -3,6 +3,6 @@ src: url("/webfonts/GallaudetRegular.woff2") format("woff2"); } -#wordsWrapper { +[data-ui-element="wordsWrapper"] { font-family: Gallaudet !important; } diff --git a/frontend/static/funbox/backwards.css b/frontend/static/funbox/backwards.css index 3591008f5669..5ecc25cb6266 100644 --- a/frontend/static/funbox/backwards.css +++ b/frontend/static/funbox/backwards.css @@ -1,11 +1,12 @@ -#words { +[data-ui-element="words"] { direction: rtl; } -#words.rightToLeftTest { +[data-ui-element="words"][data-ui-variant~="rtl"] { direction: ltr; } -#words.joiningScript .word { +[data-ui-element="words"][data-ui-variant~="joiningScript"] + [data-ui-element="word"] { unicode-bidi: bidi-override; } diff --git a/frontend/static/funbox/crt.css b/frontend/static/funbox/crt.css index 8a76e16f5976..48aa33cd80da 100644 --- a/frontend/static/funbox/crt.css +++ b/frontend/static/funbox/crt.css @@ -51,8 +51,10 @@ body.crtmode footer, body.crtmode #result, body.crtmode #popups, body.crtmode [data-ui-element="notifications"], -body.crtmode #words .word.error, -body.crtmode #words .word .hints hint, +body.crtmode + [data-ui-element="words"] + [data-ui-element="word"][data-ui-state="typedIncorrectly"], +body.crtmode [data-ui-element="words"] [data-ui-element="typoHint"], body.crtmode input, body.crtmode button, body.crtmode textarea, diff --git a/frontend/static/funbox/simon_says.css b/frontend/static/funbox/simon_says.css index 7771195e9910..97d99ccf1d72 100644 --- a/frontend/static/funbox/simon_says.css +++ b/frontend/static/funbox/simon_says.css @@ -1,3 +1,3 @@ -#words { +[data-ui-element="words"] { --untyped-letter-color: transparent !important; } diff --git a/frontend/static/funbox/tts.css b/frontend/static/funbox/tts.css index 7771195e9910..97d99ccf1d72 100644 --- a/frontend/static/funbox/tts.css +++ b/frontend/static/funbox/tts.css @@ -1,3 +1,3 @@ -#words { +[data-ui-element="words"] { --untyped-letter-color: transparent !important; } diff --git a/frontend/static/themes/chaos_theory.css b/frontend/static/themes/chaos_theory.css index d1ef620de959..78a3fffd0631 100644 --- a/frontend/static/themes/chaos_theory.css +++ b/frontend/static/themes/chaos_theory.css @@ -13,7 +13,7 @@ transition: 0.5s; } -#words .incorrect.extra { +[data-ui-element="words"] [data-ui-element="letter"][data-ui-state="extra"] { transform: rotateY(180deg); unicode-bidi: bidi-override; direction: rtl; diff --git a/frontend/static/themes/dark_note.css b/frontend/static/themes/dark_note.css index 53a76b08cee0..889be49af1e6 100644 --- a/frontend/static/themes/dark_note.css +++ b/frontend/static/themes/dark_note.css @@ -6,7 +6,7 @@ --current-color: var(--text-color); } -.colorfulMode { +[data-ui-element="words"][data-ui-variant~="colorful"] { --current-color: var(--main-color); } diff --git a/frontend/static/themes/phantom.css b/frontend/static/themes/phantom.css index 87c80faae826..ebc244a9b379 100644 --- a/frontend/static/themes/phantom.css +++ b/frontend/static/themes/phantom.css @@ -113,10 +113,6 @@ button.text:hover, animation: phantom-glow 5s linear infinite; } -.word .active { - animation: phantom-glow 5s linear infinite; -} - .scrollToTopButton:hover { background: var(--caret-color); color: var(--bg-color); diff --git a/frontend/static/themes/rgb.css b/frontend/static/themes/rgb.css index a8014a8a7c38..b2674ec55046 100644 --- a/frontend/static/themes/rgb.css +++ b/frontend/static/themes/rgb.css @@ -75,7 +75,7 @@ [data-ui-element="resultStatValue"], [data-nav-item]:hover, footer button:hover, -#words.flipped .word, +[data-ui-element="words"][data-ui-variant~="flipped"] [data-ui-element="word"], footer a:hover { animation: rgb 5s linear infinite; } @@ -106,11 +106,11 @@ header [data-nav-item="account"]:hover [data-ui-element="navAvatar"] svg path { animation: rgb-fill 5s linear infinite; } -#words { +[data-ui-element="words"] { --correct-letter-animation: rgb 5s linear infinite; } -#words.flipped { +[data-ui-element="words"][data-ui-variant~="flipped"] { --correct-letter-animation: none; --untyped-letter-animation: rgb 5s linear infinite; } diff --git a/frontend/static/themes/solarized_osaka.css b/frontend/static/themes/solarized_osaka.css index 86bc806960ad..09ba005c521f 100644 --- a/frontend/static/themes/solarized_osaka.css +++ b/frontend/static/themes/solarized_osaka.css @@ -1,4 +1,4 @@ -#words { +[data-ui-element="words"] { --correct-letter-color: var(--text-color); --untyped-letter-color: #586e75; } From 7973daddb6aa79355fe2695984de00d407579bd5 Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 27 Oct 2026 12:04:52 +0100 Subject: [PATCH 12/16] docs: rtl variant, wordsWrapper and leftover legacy selectors (T1, L2) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NZNRfGDYsGYMUTp2zYUTrv --- docs/specs/solid-monkeytype/roadmap.md | 4 ++-- docs/specs/solid-monkeytype/spec.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/specs/solid-monkeytype/roadmap.md b/docs/specs/solid-monkeytype/roadmap.md index 17973444d87c..71c8d5548848 100644 --- a/docs/specs/solid-monkeytype/roadmap.md +++ b/docs/specs/solid-monkeytype/roadmap.md @@ -83,7 +83,7 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec | Done | ID | Size | Step | Blocked by | Removes / unblocks | |---|---|---|---|---|---| | [ ] | T0 | M · PR #8507 | Merge **#8507**: Words triggers its own layout (flush subscription, config/font/resize effects) | none | test-ui's layout calls | -| [x] | T1 | L · PR | **Theme attributes on the test and result words**: words (`data-ui-variant` lists its modes), word (`WordStatus`, `data-ui-active`), letter (`LetterStatus`, tab/newline variant), typo hint; result `wordsHistory`/`replayWords`, word, letter (`ResultLetterStatus`, `data-ui-corrections`). ``/`` keep their tags and classes so old selectors keep working (spans in CL3); code finds letters by `LETTER_SELECTOR`. Bundled theme and funbox CSS moved over (computed styles unchanged across highlight/blind/flipped/colorful). Harness reads `data-ui-*` (`state` + `isActive`). Keystroke benchmark: no change | T0, TH1 | The made-up element; proves the theme attribute approach before the caret measures letters | +| [x] | T1 | L · PR | **Theme attributes on the test and result words**: words (`data-ui-variant` lists its modes), word (`WordStatus`, `data-ui-active`), letter (`LetterStatus`, tab/newline variant), typo hint; result `wordsHistory`/`replayWords`, word, letter (`ResultLetterStatus`, `data-ui-corrections`). ``/`` keep their tags and classes so old selectors keep working (spans in CL3); code finds letters by `LETTER_SELECTOR`. Bundled theme and funbox CSS moved over (computed styles unchanged across highlight/blind/flipped/colorful), adding the `rtl` words variant and `wordsWrapper`; left: taro/modern_ink `.word.error` (also hits result words, which have no error attribute), and funbox `read_ahead_disabled`/`#wordsInput` (L2). Harness reads `data-ui-*` (`state` + `isActive`). Keystroke benchmark: no change | T0, TH1 | The made-up element; proves the theme attribute approach before the caret measures letters | | [ ] | T2 | M · PR | **Break joining** computed in the layout pass, rendered as reactive class/style on Word; delete the break-joining module's DOM writes | T1, TH1 | The only outside writes into Word elements | | [ ] | T3 | L · PR | **Caret boundary**: Words is the only thing that drives the caret and pace caret; line-jump/tape-scroll handoff becomes internal to Words; pace-caret timing moves to a DOM-free module outputting a word/letter target; caret and pace caret get `data-ui-element`. Caret and words layout read the typed input from the render store (input writes it alongside the EventLog), not the engine's `getCurrentInput()`. Decide the caret internals here (reactive, imperative handle, or a mix) | T2 | Outside caret callers (test-ui, timer, focus, WordsInput) | | [ ] | T4 | S · commit | **Word registry private** to Words; only the synchronous overflow check stays exported | T3 | The public word-elements bridge | @@ -129,7 +129,7 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec | Done | ID | Size | Step | Blocked by | |---|---|---|---|---| | [ ] | L1 | M · PR | Focus mode becomes a signal; layout binds focus state; cursor hiding goes to CSS; one component owns the mouse-move exit | none (before T5) | -| [ ] | L2 | L · PR | Funbox component owns funbox document state (body state, stylesheet link, CRT scanline); funbox functions stop touching the DOM and become engine plugin hooks, getting input as arguments instead of importing the EventLog; display tweaks via `data-ui-*` / funbox CSS | none (before T5) | +| [ ] | L2 | L · PR | Funbox component owns funbox document state (body state, stylesheet link, CRT scanline); funbox functions stop touching the DOM and become engine plugin hooks, getting input as arguments instead of importing the EventLog; display tweaks via `data-ui-*` / funbox CSS (incl. moving read_ahead's `.read_ahead_disabled` and nausea/round_round_baby's `#wordsInput` selectors) | none (before T5) | | [ ] | L3 | M · PR | Monkey power as a component owning its canvas, positioned from the caret | T3 | | [ ] | L4 | M · PR | Result word highlight becomes part of words history, driven by a highlighted-range signal from the chart | none | | [ ] | L5 | M · PR | Screenshot-mode signal; elements hide through state; capture uses the result ref | none | diff --git a/docs/specs/solid-monkeytype/spec.md b/docs/specs/solid-monkeytype/spec.md index 72d38cb3deaa..fddb33a03b38 100644 --- a/docs/specs/solid-monkeytype/spec.md +++ b/docs/specs/solid-monkeytype/spec.md @@ -197,7 +197,7 @@ Users should see no behaviour change, except where a bug at today's seams disapp ### Theming and styling -- **Theme-facing API**: `data-ui-element` (identity), `data-ui-state` (one value from the component's status type, eg. a letter's `LetterStatus`, a word's `WordStatus`) and `data-ui-variant` (space-separated where several apply, matched with `~=`, eg. the words modes `highlight-word blind flipped colorful joiningScript`). Facts independent of the state get their own attribute: `data-ui-active` on the active word, `data-ui-corrections` (`corrected`, `extraCorrected`) on result letters. The result words containers are `wordsHistory` and `replayWords`; their words and letters share the `word`/`letter` names. Navigation already uses `data-ui-element`/`data-ui-variant`, and some themes already target it. Every themeable component gets them, including the test page: words, word, letter, caret, pace caret, typo hint, live stats, result parts. Ids and classes are internal and can change freely. +- **Theme-facing API**: `data-ui-element` (identity), `data-ui-state` (one value from the component's status type, eg. a letter's `LetterStatus`, a word's `WordStatus`) and `data-ui-variant` (space-separated where several apply, matched with `~=`, eg. the words modes `highlight-word blind flipped colorful joiningScript rtl`). Facts independent of the state get their own attribute: `data-ui-active` on the active word, `data-ui-corrections` (`corrected`, `extraCorrected`) on result letters. The result words containers are `wordsHistory` and `replayWords`; their words and letters share the `word`/`letter` names. Navigation already uses `data-ui-element`/`data-ui-variant`, and some themes already target it. Every themeable component gets them, including the test page: words wrapper, words, word, letter, caret, pace caret, typo hint, live stats, result parts. Ids and classes are internal and can change freely. - **`` goes away**: letters render as `` (inline by default, so no extra CSS). Any other made-up elements (``) get the same treatment. The attributes come first (T1), on the old tags with their classes, so legacy selectors keep working; the tags become spans in CL3, the single announced break. Code finds letters by `LETTER_SELECTOR`, never the tag. - **Migration of themes**: each conversion PR adds the attributes and moves the bundled themes and funbox CSS over. Old ids/classes/`letter` selectors keep working until one announced removal step at the end, which is the single break for user custom CSS. - **Styling**: all component styling is Tailwind through the class-merge helper. SCSS remains only for keyframes and base styles that must target `data-ui-*` (eg. letter colours from theme variables, blind mode); the test, caret and letter SCSS is rewritten against `data-ui-*` and the SCSS files are deleted by the end. From ee5016c3d5434088c631a98ed0718bd134732850 Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 27 Oct 2026 12:10:07 +0100 Subject: [PATCH 13/16] feat: result words carry data-ui-state, taro and modern_ink target it (T1) History and replay words get the test's WordStatus: typed once committed, typedIncorrectly where they show .error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NZNRfGDYsGYMUTp2zYUTrv --- .../pages/test/result/result-words.spec.ts | 20 ++++++++++++++++++- .../pages/test/result/ResultReplay.tsx | 3 ++- .../pages/test/result/ResultWordsHistory.tsx | 3 ++- .../ts/components/pages/test/result/replay.ts | 8 ++++++++ .../pages/test/result/result-words.ts | 18 +++++++++++++++++ frontend/src/ts/test/words-history.ts | 3 +++ frontend/static/themes/modern_ink.css | 2 +- frontend/static/themes/taro.css | 2 +- 8 files changed, 54 insertions(+), 5 deletions(-) diff --git a/frontend/__tests__/components/pages/test/result/result-words.spec.ts b/frontend/__tests__/components/pages/test/result/result-words.spec.ts index df282fec31cc..22178a082159 100644 --- a/frontend/__tests__/components/pages/test/result/result-words.spec.ts +++ b/frontend/__tests__/components/pages/test/result/result-words.spec.ts @@ -2,7 +2,10 @@ import "../../../../__harness__/mock-static"; import { describe, expect, it } from "vitest"; -import { letterAttributes } from "../../../../../src/ts/components/pages/test/result/result-words"; +import { + letterAttributes, + wordAttributes, +} from "../../../../../src/ts/components/pages/test/result/result-words"; describe("letterAttributes", () => { it.each([ @@ -36,3 +39,18 @@ describe("letterAttributes", () => { ).toBe("extraCorrected"); }); }); + +describe("wordAttributes", () => { + it.each([ + [{ typed: false, error: false }, "untyped"], + [{ typed: true, error: false }, "typed"], + [{ typed: true, error: true }, "typedIncorrectly"], + // last word of a timed test: not committed, still an error + [{ typed: false, error: true }, "typedIncorrectly"], + ])("maps %o to state %s", (word, state) => { + expect(wordAttributes(word)).toEqual({ + "data-ui-element": "word", + "data-ui-state": state, + }); + }); +}); diff --git a/frontend/src/ts/components/pages/test/result/ResultReplay.tsx b/frontend/src/ts/components/pages/test/result/ResultReplay.tsx index 753125c8520e..4481a14cf7db 100644 --- a/frontend/src/ts/components/pages/test/result/ResultReplay.tsx +++ b/frontend/src/ts/components/pages/test/result/ResultReplay.tsx @@ -10,6 +10,7 @@ import { jumpToLetter, replayState, togglePlayback } from "./replay"; import { letterAttributes, letterClass, + wordAttributes, wordClass, wordsClass, } from "./result-words"; @@ -58,7 +59,7 @@ export function ResultReplay(): JSXElement { {(word, wordIndex) => (
{(letter, letterIndex) => ( diff --git a/frontend/src/ts/components/pages/test/result/ResultWordsHistory.tsx b/frontend/src/ts/components/pages/test/result/ResultWordsHistory.tsx index 9e477d646bbb..9ae32ad37ce5 100644 --- a/frontend/src/ts/components/pages/test/result/ResultWordsHistory.tsx +++ b/frontend/src/ts/components/pages/test/result/ResultWordsHistory.tsx @@ -37,6 +37,7 @@ import * as ResultWordHighlight from "./result-word-highlight"; import { letterAttributes, letterClass, + wordAttributes, wordClass, wordsClass, } from "./result-words"; @@ -265,7 +266,7 @@ function Word(props: { error: props.word.error, })} style={{ color: color() }} - data-ui-element="word" + {...wordAttributes(props.word)} // read by result-word-highlight data-input={props.word.input} onMouseEnter={() => setHovered(true)} diff --git a/frontend/src/ts/components/pages/test/result/replay.ts b/frontend/src/ts/components/pages/test/result/replay.ts index 23989f54a69c..33f1dbefadcc 100644 --- a/frontend/src/ts/components/pages/test/result/replay.ts +++ b/frontend/src/ts/components/pages/test/result/replay.ts @@ -19,6 +19,8 @@ type ReplayLetter = { type ReplayWord = { letters: ReplayLetter[]; + // committed with space or newline + typed: boolean; error: boolean; }; @@ -151,6 +153,7 @@ function initializeReplayPrompt(): void { setReplayState( "words", loaded.words.slice(0, wordCount + 1).map((word) => ({ + typed: false, error: false, letters: [...word].map((char) => ({ char, state: "", extra: false })), })), @@ -229,11 +232,15 @@ function handleDisplayLogic(item: Replay, nosound = false): void { }); } else if (item.action === "submitCorrectWord") { if (!nosound) playSound(); + updateReplayWord(wordPos, (word) => { + word.typed = true; + }); wordPos++; curPos = 0; } else if (item.action === "submitErrorWord") { if (!nosound) playSound(true); updateReplayWord(wordPos, (word) => { + word.typed = true; word.error = true; }); wordPos++; @@ -246,6 +253,7 @@ function handleDisplayLogic(item: Replay, nosound = false): void { curPos = letters.length; while (curPos > 0 && letters[curPos - 1]?.state === "") curPos--; updateReplayWord(wordPos, (word) => { + word.typed = false; word.error = false; }); } diff --git a/frontend/src/ts/components/pages/test/result/result-words.ts b/frontend/src/ts/components/pages/test/result/result-words.ts index 9c8cbb8bf837..3745cc29229e 100644 --- a/frontend/src/ts/components/pages/test/result/result-words.ts +++ b/frontend/src/ts/components/pages/test/result/result-words.ts @@ -3,6 +3,7 @@ import { isLanguageRightToLeft, } from "../../../../states/test"; import { cn } from "../../../../utils/cn"; +import type { WordStatus } from "../Word"; // shared by the words history and the replay @@ -54,6 +55,23 @@ export function letterAttributes(letter: ResultLetter): { }; } +type ResultWord = { + // committed with space or newline + typed: boolean; + error: boolean; +}; + +/** The theme-facing data-ui-* attributes of a result word, as the test's. */ +export function wordAttributes(word: ResultWord): { + "data-ui-element": "word"; + "data-ui-state": WordStatus; +} { + let status: WordStatus = "untyped"; + if (word.error) status = "typedIncorrectly"; + else if (word.typed) status = "typed"; + return { "data-ui-element": "word", "data-ui-state": status }; +} + export function letterClass(letter: ResultLetter): string { return cn({ "text-text": letter.state === "correct" || letter.state === "corrected", diff --git a/frontend/src/ts/test/words-history.ts b/frontend/src/ts/test/words-history.ts index 123ec6c40405..0806710f6ceb 100644 --- a/frontend/src/ts/test/words-history.ts +++ b/frontend/src/ts/test/words-history.ts @@ -45,6 +45,8 @@ export type HistoryWord = { // what the user typed, spaces replaced with underscores. Read by result-word-highlight. input: string; burst?: number; + // committed with space or newline + typed: boolean; error: boolean; letters: HistoryLetter[]; }; @@ -153,6 +155,7 @@ export function buildWordsHistory(eventLog: EventLog): HistoryWord[] { words.push({ input: inputAttribute.replace(/ /g, "_"), burst: burstHistory[i], + typed: input !== undefined && stripCommit(input) !== input, error: input !== target && shouldShowError, letters: buildWordLetters(input, corrected, target), }); diff --git a/frontend/static/themes/modern_ink.css b/frontend/static/themes/modern_ink.css index 9ec47c901fc4..6b342e02c86b 100644 --- a/frontend/static/themes/modern_ink.css +++ b/frontend/static/themes/modern_ink.css @@ -9,6 +9,6 @@ ); } -.word.error { +[data-ui-element="word"][data-ui-state="typedIncorrectly"] { border-bottom: solid 2px #ff0000; } diff --git a/frontend/static/themes/taro.css b/frontend/static/themes/taro.css index 44eeec5abc5b..d10f67a91676 100644 --- a/frontend/static/themes/taro.css +++ b/frontend/static/themes/taro.css @@ -1,4 +1,4 @@ -.word.error { +[data-ui-element="word"][data-ui-state="typedIncorrectly"] { border-bottom: dotted 2px var(--text-color); } From 7757c0dd972e37941fee56eaa8788e0535ccd914 Mon Sep 17 00:00:00 2001 From: Miodec Date: Tue, 27 Oct 2026 12:10:18 +0100 Subject: [PATCH 14/16] docs: result word state (T1) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NZNRfGDYsGYMUTp2zYUTrv --- docs/specs/solid-monkeytype/roadmap.md | 2 +- docs/specs/solid-monkeytype/spec.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/solid-monkeytype/roadmap.md b/docs/specs/solid-monkeytype/roadmap.md index 71c8d5548848..0a8c8f05f5d2 100644 --- a/docs/specs/solid-monkeytype/roadmap.md +++ b/docs/specs/solid-monkeytype/roadmap.md @@ -83,7 +83,7 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec | Done | ID | Size | Step | Blocked by | Removes / unblocks | |---|---|---|---|---|---| | [ ] | T0 | M · PR #8507 | Merge **#8507**: Words triggers its own layout (flush subscription, config/font/resize effects) | none | test-ui's layout calls | -| [x] | T1 | L · PR | **Theme attributes on the test and result words**: words (`data-ui-variant` lists its modes), word (`WordStatus`, `data-ui-active`), letter (`LetterStatus`, tab/newline variant), typo hint; result `wordsHistory`/`replayWords`, word, letter (`ResultLetterStatus`, `data-ui-corrections`). ``/`` keep their tags and classes so old selectors keep working (spans in CL3); code finds letters by `LETTER_SELECTOR`. Bundled theme and funbox CSS moved over (computed styles unchanged across highlight/blind/flipped/colorful), adding the `rtl` words variant and `wordsWrapper`; left: taro/modern_ink `.word.error` (also hits result words, which have no error attribute), and funbox `read_ahead_disabled`/`#wordsInput` (L2). Harness reads `data-ui-*` (`state` + `isActive`). Keystroke benchmark: no change | T0, TH1 | The made-up element; proves the theme attribute approach before the caret measures letters | +| [x] | T1 | L · PR | **Theme attributes on the test and result words**: words (`data-ui-variant` lists its modes), word (`WordStatus`, `data-ui-active`), letter (`LetterStatus`, tab/newline variant), typo hint; result `wordsHistory`/`replayWords`, word (`WordStatus`, typed = committed), letter (`ResultLetterStatus`, `data-ui-corrections`). ``/`` keep their tags and classes so old selectors keep working (spans in CL3); code finds letters by `LETTER_SELECTOR`. Bundled theme and funbox CSS moved over (computed styles unchanged across highlight/blind/flipped/colorful), adding the `rtl` words variant and `wordsWrapper`; left: funbox `read_ahead_disabled`/`#wordsInput` (L2). Harness reads `data-ui-*` (`state` + `isActive`). Keystroke benchmark: no change | T0, TH1 | The made-up element; proves the theme attribute approach before the caret measures letters | | [ ] | T2 | M · PR | **Break joining** computed in the layout pass, rendered as reactive class/style on Word; delete the break-joining module's DOM writes | T1, TH1 | The only outside writes into Word elements | | [ ] | T3 | L · PR | **Caret boundary**: Words is the only thing that drives the caret and pace caret; line-jump/tape-scroll handoff becomes internal to Words; pace-caret timing moves to a DOM-free module outputting a word/letter target; caret and pace caret get `data-ui-element`. Caret and words layout read the typed input from the render store (input writes it alongside the EventLog), not the engine's `getCurrentInput()`. Decide the caret internals here (reactive, imperative handle, or a mix) | T2 | Outside caret callers (test-ui, timer, focus, WordsInput) | | [ ] | T4 | S · commit | **Word registry private** to Words; only the synchronous overflow check stays exported | T3 | The public word-elements bridge | diff --git a/docs/specs/solid-monkeytype/spec.md b/docs/specs/solid-monkeytype/spec.md index fddb33a03b38..5daf1befe597 100644 --- a/docs/specs/solid-monkeytype/spec.md +++ b/docs/specs/solid-monkeytype/spec.md @@ -197,7 +197,7 @@ Users should see no behaviour change, except where a bug at today's seams disapp ### Theming and styling -- **Theme-facing API**: `data-ui-element` (identity), `data-ui-state` (one value from the component's status type, eg. a letter's `LetterStatus`, a word's `WordStatus`) and `data-ui-variant` (space-separated where several apply, matched with `~=`, eg. the words modes `highlight-word blind flipped colorful joiningScript rtl`). Facts independent of the state get their own attribute: `data-ui-active` on the active word, `data-ui-corrections` (`corrected`, `extraCorrected`) on result letters. The result words containers are `wordsHistory` and `replayWords`; their words and letters share the `word`/`letter` names. Navigation already uses `data-ui-element`/`data-ui-variant`, and some themes already target it. Every themeable component gets them, including the test page: words wrapper, words, word, letter, caret, pace caret, typo hint, live stats, result parts. Ids and classes are internal and can change freely. +- **Theme-facing API**: `data-ui-element` (identity), `data-ui-state` (one value from the component's status type, eg. a letter's `LetterStatus`, a word's `WordStatus`) and `data-ui-variant` (space-separated where several apply, matched with `~=`, eg. the words modes `highlight-word blind flipped colorful joiningScript rtl`). Facts independent of the state get their own attribute: `data-ui-active` on the active word, `data-ui-corrections` (`corrected`, `extraCorrected`) on result letters. The result words containers are `wordsHistory` and `replayWords`; their words and letters share the `word`/`letter` names, and words share `WordStatus` (typed means committed, in the replay too). Navigation already uses `data-ui-element`/`data-ui-variant`, and some themes already target it. Every themeable component gets them, including the test page: words wrapper, words, word, letter, caret, pace caret, typo hint, live stats, result parts. Ids and classes are internal and can change freely. - **`` goes away**: letters render as `` (inline by default, so no extra CSS). Any other made-up elements (``) get the same treatment. The attributes come first (T1), on the old tags with their classes, so legacy selectors keep working; the tags become spans in CL3, the single announced break. Code finds letters by `LETTER_SELECTOR`, never the tag. - **Migration of themes**: each conversion PR adds the attributes and moves the bundled themes and funbox CSS over. Old ids/classes/`letter` selectors keep working until one announced removal step at the end, which is the single break for user custom CSS. - **Styling**: all component styling is Tailwind through the class-merge helper. SCSS remains only for keyframes and base styles that must target `data-ui-*` (eg. letter colours from theme variables, blind mode); the test, caret and letter SCSS is rewritten against `data-ui-*` and the SCSS files are deleted by the end. From ae05d761500e7414f2734edb221084ec38f29e30 Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 28 Oct 2026 13:40:53 +0100 Subject: [PATCH 15/16] fix(themes): stylelint duplicate selector and descending specificity (T1) (@miodec) Co-Authored-By: Claude Opus 5.5 --- frontend/static/themes/dark_note.css | 4 ---- frontend/static/themes/phantom.css | 33 +++++++++++++--------------- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/frontend/static/themes/dark_note.css b/frontend/static/themes/dark_note.css index 889be49af1e6..fce1db91a413 100644 --- a/frontend/static/themes/dark_note.css +++ b/frontend/static/themes/dark_note.css @@ -58,10 +58,6 @@ body::before { [data-ui-element="words"] [data-ui-element="word"][data-ui-state="typedIncorrectly"] { border-bottom: none; -} - -[data-ui-element="words"] - [data-ui-element="word"][data-ui-state="typedIncorrectly"] { text-shadow: none; } diff --git a/frontend/static/themes/phantom.css b/frontend/static/themes/phantom.css index ebc244a9b379..cdb806bcdd47 100644 --- a/frontend/static/themes/phantom.css +++ b/frontend/static/themes/phantom.css @@ -131,19 +131,18 @@ button.text:hover, animation: phantom-glow 3s linear infinite; } -[data-ui-element="words"][data-ui-variant~="highlight-off"] - [data-ui-element="word"] - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-off"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { - animation: phantom-glow 5s linear infinite; +[data-ui-element="words"] + [data-ui-element="letter"]:is( + [data-ui-state="incorrect"], + [data-ui-state="extra"] + ) { + animation: phantom-glow-incorrect 5s linear infinite; } [data-ui-element="resultStatSubValue"], +[data-ui-element="words"][data-ui-variant~="highlight-off"] + [data-ui-element="word"] + [data-ui-element="letter"], [data-ui-element="words"] [data-ui-element="word"] [data-ui-element="letter"]:is( @@ -184,6 +183,12 @@ button.text:hover, ) [data-ui-element="letter"], [data-ui-element="words"][data-ui-variant~="highlight-next-three-words"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], +[data-ui-element="words"][data-ui-variant~="highlight-off"] [data-ui-element="word"]:is( [data-ui-state="typed"], [data-ui-state="typedIncorrectly"] @@ -200,11 +205,3 @@ button.text:hover, [data-ui-element="letter"] { animation: none; } - -[data-ui-element="words"] - [data-ui-element="letter"]:is( - [data-ui-state="incorrect"], - [data-ui-state="extra"] - ) { - animation: phantom-glow-incorrect 5s linear infinite; -} From 4d5b4f2491681f33500863ace6b8bfd909ecda13 Mon Sep 17 00:00:00 2001 From: Miodec Date: Wed, 28 Oct 2026 14:02:40 +0100 Subject: [PATCH 16/16] refactor(themes): nest data-ui-* word and letter rules (T1) (@miodec) Co-Authored-By: Claude Opus 5.5 --- frontend/static/themes/aurora.css | 112 ++++++++----------- frontend/static/themes/dark_note.css | 87 +++++++-------- frontend/static/themes/fire.css | 112 ++++++++----------- frontend/static/themes/grape.css | 112 ++++++++----------- frontend/static/themes/phantom.css | 130 +++++++++------------- frontend/static/themes/rainbow_trail.css | 135 ++++++++++------------- frontend/static/themes/rgb.css | 13 ++- frontend/static/themes/shadow.css | 82 +++++--------- frontend/static/themes/trance.css | 112 ++++++++----------- 9 files changed, 383 insertions(+), 512 deletions(-) diff --git a/frontend/static/themes/aurora.css b/frontend/static/themes/aurora.css index 1b038e52530a..36b875eac367 100644 --- a/frontend/static/themes/aurora.css +++ b/frontend/static/themes/aurora.css @@ -60,73 +60,55 @@ a:not(.button):not([data-ui-variant="button"]):hover { [data-ui-element="words"] { --correct-letter-animation: aurora 5s linear infinite; -} -[data-ui-element="words"][data-ui-variant~="flipped"] { - --untyped-letter-color: var(--sub-color); - --correct-letter-animation: none; - --untyped-letter-animation: aurora 5s linear infinite; -} + &[data-ui-variant~="flipped"] { + --untyped-letter-color: var(--sub-color); + --correct-letter-animation: none; + --untyped-letter-animation: aurora 5s linear infinite; + } -[data-ui-element="words"][data-ui-variant~="highlight-off"] - [data-ui-element="word"] - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-off"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { - animation: aurora 5s linear infinite; -} + &[data-ui-variant~="highlight-off"] + [data-ui-element="word"] + [data-ui-element="letter"], + &[data-ui-variant~="highlight-off"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: aurora 5s linear infinite; + } -[data-ui-element="words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"]:is( - [data-ui-state="correct"], - [data-ui-state="deadCorrect"] - ), -[data-ui-element="words"][data-ui-variant~="blind"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-word"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-word"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-two-words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-three-words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { - animation: aurora 5s linear infinite; -} + & + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), + &:is( + [data-ui-variant~="blind"], + [data-ui-variant~="highlight-word"], + [data-ui-variant~="highlight-next-word"], + [data-ui-variant~="highlight-next-two-words"], + [data-ui-variant~="highlight-next-three-words"] + ) + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: aurora 5s linear infinite; + } -[data-ui-element="words"][data-ui-variant~="flipped"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { - animation: none; + &[data-ui-variant~="flipped"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: none; + } } diff --git a/frontend/static/themes/dark_note.css b/frontend/static/themes/dark_note.css index fce1db91a413..a1e351703a53 100644 --- a/frontend/static/themes/dark_note.css +++ b/frontend/static/themes/dark_note.css @@ -6,10 +6,6 @@ --current-color: var(--text-color); } -[data-ui-element="words"][data-ui-variant~="colorful"] { - --current-color: var(--main-color); -} - button:not(.accountButtonAndMenu *), .button:not(.accountButtonAndMenu *), [data-ui-element="testConfig"] { @@ -54,48 +50,49 @@ body::before { --roundness: 0; /* making command line sharp */ } -/* restyling error displays to be strike-through instead of underlined */ -[data-ui-element="words"] - [data-ui-element="word"][data-ui-state="typedIncorrectly"] { - border-bottom: none; - text-shadow: none; -} +[data-ui-element="words"] { + &[data-ui-variant~="colorful"] { + --current-color: var(--main-color); + } -[data-ui-element="words"]:not([data-ui-variant~="blind"]) - [data-ui-element="word"] - [data-ui-element="letter"]:is( - [data-ui-state="incorrect"], - [data-ui-state="extra"] - ), -[data-ui-element="words"]:not( - [data-ui-variant~="blind"] - )[data-ui-variant~="colorful"] - [data-ui-element="word"] - [data-ui-element="letter"]:is( - [data-ui-state="incorrect"], - [data-ui-state="extra"] - ) { - text-decoration: line-through; - text-decoration-color: var(--error-color); - text-decoration-thickness: 2px; -} + /* restyling error displays to be strike-through instead of underlined */ + & [data-ui-element="word"][data-ui-state="typedIncorrectly"] { + border-bottom: none; + text-shadow: none; + } -[data-ui-element="words"]:not([data-ui-variant~="blind"]) - [data-ui-element="word"] - [data-ui-element="letter"][data-ui-state="incorrect"], -[data-ui-element="words"]:not( - [data-ui-variant~="blind"] - )[data-ui-variant~="colorful"] - [data-ui-element="word"] - [data-ui-element="letter"][data-ui-state="incorrect"] { - color: var(--current-color); -} + &:not([data-ui-variant~="blind"]) + [data-ui-element="word"] + [data-ui-element="letter"]:is( + [data-ui-state="incorrect"], + [data-ui-state="extra"] + ), + &:not([data-ui-variant~="blind"])[data-ui-variant~="colorful"] + [data-ui-element="word"] + [data-ui-element="letter"]:is( + [data-ui-state="incorrect"], + [data-ui-state="extra"] + ) { + text-decoration: line-through; + text-decoration-color: var(--error-color); + text-decoration-thickness: 2px; + } + + &:not([data-ui-variant~="blind"]) + [data-ui-element="word"] + [data-ui-element="letter"][data-ui-state="incorrect"], + &:not([data-ui-variant~="blind"])[data-ui-variant~="colorful"] + [data-ui-element="word"] + [data-ui-element="letter"][data-ui-state="incorrect"] { + color: var(--current-color); + } -[data-ui-element="words"]:not([data-ui-variant~="blind"]) - [data-ui-element="word"][data-ui-state="typedIncorrectly"] - [data-ui-element="letter"]:is( - [data-ui-state="untyped"], - [data-ui-state="dead"] - )::after { - background: var(--sub-color); + &:not([data-ui-variant~="blind"]) + [data-ui-element="word"][data-ui-state="typedIncorrectly"] + [data-ui-element="letter"]:is( + [data-ui-state="untyped"], + [data-ui-state="dead"] + )::after { + background: var(--sub-color); + } } diff --git a/frontend/static/themes/fire.css b/frontend/static/themes/fire.css index 4cfe3a57baa7..3889427eb88c 100644 --- a/frontend/static/themes/fire.css +++ b/frontend/static/themes/fire.css @@ -66,73 +66,55 @@ a:not(.button):not([data-ui-variant="button"]):hover { [data-ui-element="words"] { --correct-letter-animation: fire 5s linear infinite; -} -[data-ui-element="words"][data-ui-variant~="flipped"] { - --untyped-letter-color: var(--sub-color); - --correct-letter-animation: none; - --untyped-letter-animation: fire 5s linear infinite; -} + &[data-ui-variant~="flipped"] { + --untyped-letter-color: var(--sub-color); + --correct-letter-animation: none; + --untyped-letter-animation: fire 5s linear infinite; + } -[data-ui-element="words"][data-ui-variant~="highlight-off"] - [data-ui-element="word"] - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-off"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { - animation: fire 5s linear infinite; -} + &[data-ui-variant~="highlight-off"] + [data-ui-element="word"] + [data-ui-element="letter"], + &[data-ui-variant~="highlight-off"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: fire 5s linear infinite; + } -[data-ui-element="words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"]:is( - [data-ui-state="correct"], - [data-ui-state="deadCorrect"] - ), -[data-ui-element="words"][data-ui-variant~="blind"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-word"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-word"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-two-words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-three-words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { - animation: fire 5s linear infinite; -} + & + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), + &:is( + [data-ui-variant~="blind"], + [data-ui-variant~="highlight-word"], + [data-ui-variant~="highlight-next-word"], + [data-ui-variant~="highlight-next-two-words"], + [data-ui-variant~="highlight-next-three-words"] + ) + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: fire 5s linear infinite; + } -[data-ui-element="words"][data-ui-variant~="flipped"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { - animation: none; + &[data-ui-variant~="flipped"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: none; + } } diff --git a/frontend/static/themes/grape.css b/frontend/static/themes/grape.css index 93f39ac9c476..b8fb77a18bac 100644 --- a/frontend/static/themes/grape.css +++ b/frontend/static/themes/grape.css @@ -61,73 +61,55 @@ a:not(.button):not([data-ui-variant="button"]):hover { [data-ui-element="words"] { --correct-letter-animation: grape 5s linear infinite; -} -[data-ui-element="words"][data-ui-variant~="flipped"] { - --untyped-letter-color: var(--sub-color); - --correct-letter-animation: none; - --untyped-letter-animation: grape 5s linear infinite; -} + &[data-ui-variant~="flipped"] { + --untyped-letter-color: var(--sub-color); + --correct-letter-animation: none; + --untyped-letter-animation: grape 5s linear infinite; + } -[data-ui-element="words"][data-ui-variant~="highlight-off"] - [data-ui-element="word"] - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-off"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { - animation: grape 5s linear infinite; -} + &[data-ui-variant~="highlight-off"] + [data-ui-element="word"] + [data-ui-element="letter"], + &[data-ui-variant~="highlight-off"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: grape 5s linear infinite; + } -[data-ui-element="words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"]:is( - [data-ui-state="correct"], - [data-ui-state="deadCorrect"] - ), -[data-ui-element="words"][data-ui-variant~="blind"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-word"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-word"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-two-words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-three-words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { - animation: grape 5s linear infinite; -} + & + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), + &:is( + [data-ui-variant~="blind"], + [data-ui-variant~="highlight-word"], + [data-ui-variant~="highlight-next-word"], + [data-ui-variant~="highlight-next-two-words"], + [data-ui-variant~="highlight-next-three-words"] + ) + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: grape 5s linear infinite; + } -[data-ui-element="words"][data-ui-variant~="flipped"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { - animation: none; + &[data-ui-variant~="flipped"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: none; + } } diff --git a/frontend/static/themes/phantom.css b/frontend/static/themes/phantom.css index cdb806bcdd47..4c54b2ad7c9a 100644 --- a/frontend/static/themes/phantom.css +++ b/frontend/static/themes/phantom.css @@ -49,11 +49,6 @@ } } -[data-ui-element="words"] { - --correct-letter-color: var(--text-color); - --untyped-letter-color: #565f89; -} - header:not([data-focused]) [data-ui-element="logoText"] { color: var(--text-color); animation: phantom-glow 3s infinite alternate; @@ -131,77 +126,62 @@ button.text:hover, animation: phantom-glow 3s linear infinite; } -[data-ui-element="words"] - [data-ui-element="letter"]:is( - [data-ui-state="incorrect"], - [data-ui-state="extra"] - ) { - animation: phantom-glow-incorrect 5s linear infinite; -} - -[data-ui-element="resultStatSubValue"], -[data-ui-element="words"][data-ui-variant~="highlight-off"] - [data-ui-element="word"] - [data-ui-element="letter"], -[data-ui-element="words"] - [data-ui-element="word"] - [data-ui-element="letter"]:is( - [data-ui-state="correct"], - [data-ui-state="deadCorrect"] - ), -[data-ui-element="words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"]:is( - [data-ui-state="correct"], - [data-ui-state="deadCorrect"] - ), -[data-ui-element="words"][data-ui-variant~="blind"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-word"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-word"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-two-words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-three-words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-off"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { +[data-ui-element="resultStatSubValue"] { animation: phantom-glow 5s linear infinite; } -[data-ui-element="words"][data-ui-variant~="flipped"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { - animation: none; +[data-ui-element="words"] { + --correct-letter-color: var(--text-color); + --untyped-letter-color: #565f89; + + & + [data-ui-element="letter"]:is( + [data-ui-state="incorrect"], + [data-ui-state="extra"] + ) { + animation: phantom-glow-incorrect 5s linear infinite; + } + + &[data-ui-variant~="highlight-off"] + [data-ui-element="word"] + [data-ui-element="letter"], + & + [data-ui-element="word"] + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), + & + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), + &:is( + [data-ui-variant~="blind"], + [data-ui-variant~="highlight-word"], + [data-ui-variant~="highlight-next-word"], + [data-ui-variant~="highlight-next-two-words"], + [data-ui-variant~="highlight-next-three-words"], + [data-ui-variant~="highlight-off"] + ) + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: phantom-glow 5s linear infinite; + } + + &[data-ui-variant~="flipped"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: none; + } } diff --git a/frontend/static/themes/rainbow_trail.css b/frontend/static/themes/rainbow_trail.css index 950ec01b7183..7f2fa806a2ed 100644 --- a/frontend/static/themes/rainbow_trail.css +++ b/frontend/static/themes/rainbow_trail.css @@ -69,88 +69,73 @@ footer button:hover > .relative > i, ease-in-out infinite; --extra-letter-animation: rainbow-error-infinite-loop 1s alternate ease-in-out infinite; -} -[data-ui-element="words"][data-ui-variant~="highlight-off"] - [data-ui-element="word"] - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-off"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { - animation: rainbow-infinite-loop 5s linear infinite; -} + &[data-ui-variant~="highlight-off"] + [data-ui-element="word"] + [data-ui-element="letter"], + &[data-ui-variant~="highlight-off"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: rainbow-infinite-loop 5s linear infinite; + } -[data-ui-element="words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"]:is( - [data-ui-state="correct"], - [data-ui-state="deadCorrect"] - ), -[data-ui-element="words"][data-ui-variant~="blind"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-word"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-word"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-two-words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-three-words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="replayWords"] - [data-ui-element="word"] - [data-ui-element="letter"][data-ui-state="correct"] { - animation: rainbow 7.5s ease-in-out forwards; -} + & + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), + &:is( + [data-ui-variant~="blind"], + [data-ui-variant~="highlight-word"], + [data-ui-variant~="highlight-next-word"], + [data-ui-variant~="highlight-next-two-words"], + [data-ui-variant~="highlight-next-three-words"] + ) + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: rainbow 7.5s ease-in-out forwards; + } -[data-ui-element="words"]:not([data-ui-variant~="blind"]) - [data-ui-element="word"][data-ui-state="typedIncorrectly"] { - animation: rainbow-error 7.5s ease-in-out forwards; -} + &:not([data-ui-variant~="blind"]) + [data-ui-element="word"][data-ui-state="typedIncorrectly"] { + animation: rainbow-error 7.5s ease-in-out forwards; + } -[data-ui-element="words"][data-ui-variant~="highlight-word"] - [data-ui-element="word"][data-ui-active] - [data-ui-element="letter"] { - animation: var(--correct-letter-animation); -} + &[data-ui-variant~="highlight-word"] + [data-ui-element="word"][data-ui-active] + [data-ui-element="letter"] { + animation: var(--correct-letter-animation); + } + + &[data-ui-variant~="flipped"] { + --correct-letter-animation: none; + --untyped-letter-animation: rainbow-infinite-loop 5s linear infinite; + } -[data-ui-element="words"][data-ui-variant~="flipped"] { - --correct-letter-animation: none; - --untyped-letter-animation: rainbow-infinite-loop 5s linear infinite; + &[data-ui-variant~="flipped"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: none; + } } -[data-ui-element="words"][data-ui-variant~="flipped"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { - animation: none; +[data-ui-element="replayWords"] + [data-ui-element="word"] + [data-ui-element="letter"][data-ui-state="correct"] { + animation: rainbow 7.5s ease-in-out forwards; } footer button:hover { diff --git a/frontend/static/themes/rgb.css b/frontend/static/themes/rgb.css index b2674ec55046..9910a9dd340b 100644 --- a/frontend/static/themes/rgb.css +++ b/frontend/static/themes/rgb.css @@ -75,7 +75,6 @@ [data-ui-element="resultStatValue"], [data-nav-item]:hover, footer button:hover, -[data-ui-element="words"][data-ui-variant~="flipped"] [data-ui-element="word"], footer a:hover { animation: rgb 5s linear infinite; } @@ -108,9 +107,13 @@ header [data-nav-item="account"]:hover [data-ui-element="navAvatar"] svg path { [data-ui-element="words"] { --correct-letter-animation: rgb 5s linear infinite; -} -[data-ui-element="words"][data-ui-variant~="flipped"] { - --correct-letter-animation: none; - --untyped-letter-animation: rgb 5s linear infinite; + &[data-ui-variant~="flipped"] { + --correct-letter-animation: none; + --untyped-letter-animation: rgb 5s linear infinite; + + & [data-ui-element="word"] { + animation: rgb 5s linear infinite; + } + } } diff --git a/frontend/static/themes/shadow.css b/frontend/static/themes/shadow.css index da18230d9d45..5093b14f24bf 100644 --- a/frontend/static/themes/shadow.css +++ b/frontend/static/themes/shadow.css @@ -33,56 +33,34 @@ a:not(.button):not([data-ui-variant="button"]):hover { animation: shadow-repeat 3s linear infinite forwards; } -[data-ui-element="words"] - [data-ui-element="word"] - [data-ui-element="letter"]:is( - [data-ui-state="correct"], - [data-ui-state="deadCorrect"] - ), -[data-ui-element="words"][data-ui-variant~="blind"]:where( - :not([data-ui-variant~="highlight-off"]) - ) - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-word"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"]:is( - [data-ui-state="correct"], - [data-ui-state="deadCorrect"] - ), -[data-ui-element="words"][data-ui-variant~="highlight-next-word"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"]:is( - [data-ui-state="correct"], - [data-ui-state="deadCorrect"] - ), -[data-ui-element="words"][data-ui-variant~="highlight-next-two-words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"]:is( - [data-ui-state="correct"], - [data-ui-state="deadCorrect"] - ), -[data-ui-element="words"][data-ui-variant~="highlight-next-three-words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"]:is( - [data-ui-state="correct"], - [data-ui-state="deadCorrect"] - ) { - color: var(--correct-letter-color); - animation: shadow 5s linear 1 forwards; +[data-ui-element="words"] { + & + [data-ui-element="word"] + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), + &[data-ui-variant~="blind"]:where(:not([data-ui-variant~="highlight-off"])) + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"], + &:is( + [data-ui-variant~="highlight-word"], + [data-ui-variant~="highlight-next-word"], + [data-ui-variant~="highlight-next-two-words"], + [data-ui-variant~="highlight-next-three-words"] + ) + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ) { + color: var(--correct-letter-color); + animation: shadow 5s linear 1 forwards; + } } diff --git a/frontend/static/themes/trance.css b/frontend/static/themes/trance.css index 2c2a56fabd19..7a9220bce3ce 100644 --- a/frontend/static/themes/trance.css +++ b/frontend/static/themes/trance.css @@ -49,73 +49,55 @@ a:not(.button):not([data-ui-variant="button"]):hover { [data-ui-element="words"] { --correct-letter-animation: trance 5s linear infinite; -} -[data-ui-element="words"][data-ui-variant~="flipped"] { - --untyped-letter-color: var(--sub-color); - --correct-letter-animation: none; - --untyped-letter-animation: trance 5s linear infinite; -} + &[data-ui-variant~="flipped"] { + --untyped-letter-color: var(--sub-color); + --correct-letter-animation: none; + --untyped-letter-animation: trance 5s linear infinite; + } -[data-ui-element="words"][data-ui-variant~="highlight-off"] - [data-ui-element="word"] - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-off"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { - animation: trance 5s linear infinite; -} + &[data-ui-variant~="highlight-off"] + [data-ui-element="word"] + [data-ui-element="letter"], + &[data-ui-variant~="highlight-off"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: trance 5s linear infinite; + } -[data-ui-element="words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"]:is( - [data-ui-state="correct"], - [data-ui-state="deadCorrect"] - ), -[data-ui-element="words"][data-ui-variant~="blind"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-word"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-word"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-two-words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"], -[data-ui-element="words"][data-ui-variant~="highlight-next-three-words"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { - animation: trance 5s linear infinite; -} + & + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"]:is( + [data-ui-state="correct"], + [data-ui-state="deadCorrect"] + ), + &:is( + [data-ui-variant~="blind"], + [data-ui-variant~="highlight-word"], + [data-ui-variant~="highlight-next-word"], + [data-ui-variant~="highlight-next-two-words"], + [data-ui-variant~="highlight-next-three-words"] + ) + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: trance 5s linear infinite; + } -[data-ui-element="words"][data-ui-variant~="flipped"] - [data-ui-element="word"]:is( - [data-ui-state="typed"], - [data-ui-state="typedIncorrectly"] - ) - [data-ui-element="letter"] { - animation: none; + &[data-ui-variant~="flipped"] + [data-ui-element="word"]:is( + [data-ui-state="typed"], + [data-ui-state="typedIncorrectly"] + ) + [data-ui-element="letter"] { + animation: none; + } }