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 f349b85d9eb1..b16dfa1896c8 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 (`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 | @@ -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 760a670502b1..98459177b599 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 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. @@ -285,7 +285,7 @@ From commandline metadata (C5): generated config commands use the settings label | 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 | diff --git a/frontend/__tests__/__harness__/test-page.tsx b/frontend/__tests__/__harness__/test-page.tsx index 8545a7da8228..748d0fc82e8c 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, deleteWord, 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[]; @@ -154,8 +148,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: () => { @@ -166,23 +161,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 @@ -229,7 +207,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; @@ -254,14 +232,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 862df59ec36b..3f2462708b98 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); }); }); @@ -139,7 +141,7 @@ describe("TestPage", () => { await page.deleteWord(); - expect(page.word(0).state).toBe("active"); + expect(page.word(0).isActive).toBe(true); expect(page.word(1).letters()).toEqual([ { char: "f", state: "untyped" }, { char: "o", state: "untyped" }, 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..c7ed87676c91 100644 --- a/frontend/__tests__/components/pages/test/Words.spec.tsx +++ b/frontend/__tests__/components/pages/test/Words.spec.tsx @@ -3,10 +3,15 @@ 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, + LETTER_SELECTOR, } from "../../../../src/ts/states/word-elements"; import { appendRenderedWord, @@ -39,20 +44,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,8 +81,13 @@ 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"); + setJoiningScript(false); + setIsLanguageRightToLeft(false); resetWordsRender(); frames.clear(); vi.unstubAllGlobals(); @@ -278,18 +288,95 @@ 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); + 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 joiningScript rtl", + }); + }); + }); + describe("structure", () => { function childClasses(container: HTMLElement): string[] { return [...container.children].map((el) => 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..22178a082159 --- /dev/null +++ b/frontend/__tests__/components/pages/test/result/result-words.spec.ts @@ -0,0 +1,56 @@ +// static json (layouts...) read from disk, the test state module loads some +import "../../../../__harness__/mock-static"; +import { describe, expect, it } from "vitest"; + +import { + letterAttributes, + wordAttributes, +} 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"); + }); +}); + +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/__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/TestPage.tsx b/frontend/src/ts/components/pages/test/TestPage.tsx index 40eed1783db0..6a7459e5c552 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/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"); + if (isLanguageRightToLeft()) variants.push("rtl"); + return variants.join(" "); +} + /** Renders #words with the test words, and lays them out. */ export function Words(props: { wordsWrapperRef?: () => HTMLElement | undefined; @@ -75,6 +88,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/components/pages/test/result/ResultReplay.tsx b/frontend/src/ts/components/pages/test/result/ResultReplay.tsx index 87d543846fd3..4481a14cf7db 100644 --- a/frontend/src/ts/components/pages/test/result/ResultReplay.tsx +++ b/frontend/src/ts/components/pages/test/result/ResultReplay.tsx @@ -7,7 +7,13 @@ 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, + wordAttributes, + wordClass, + wordsClass, +} from "./result-words"; const playButtonText = { start: "Start replay", @@ -47,14 +53,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..9ae32ad37ce5 100644 --- a/frontend/src/ts/components/pages/test/result/ResultWordsHistory.tsx +++ b/frontend/src/ts/components/pages/test/result/ResultWordsHistory.tsx @@ -34,7 +34,13 @@ 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, + wordAttributes, + wordClass, + wordsClass, +} from "./result-words"; type Heatmap = { steps: { val: number; colorId: number }[]; @@ -181,7 +187,7 @@ export function ResultWordsHistory(props: { eventLog: EventLog }): JSXElement { )}
-
+
{(word) => } @@ -260,6 +266,7 @@ function Word(props: { error: props.word.error, })} style={{ color: color() }} + {...wordAttributes(props.word)} // read by result-word-highlight data-input={props.word.input} onMouseEnter={() => setHovered(true)} @@ -269,6 +276,7 @@ function Word(props: { {(letter) => ( ({ + typed: false, error: false, // oxlint-disable-next-line no-misused-spread -- split into code points on purpose letters: [...word].map((char) => ({ char, state: "", extra: false })), @@ -230,11 +233,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++; @@ -247,6 +254,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 4ec478e2dd64..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 @@ -17,11 +18,61 @@ export function wordsClass(): string { export const wordClass = "word relative mt-[0.18rem] mr-[0.6rem] mb-[0.15rem] ml-0"; -export function letterClass(letter: { +type ResultLetter = { state?: "" | "correct" | "corrected" | "incorrect"; extra?: boolean; extraCorrected?: boolean; -}): string { +}; + +export type ResultLetterStatus = "untyped" | "correct" | "incorrect" | "extra"; + +function letterStatus(letter: ResultLetter): ResultLetterStatus { + if (letter.extra) return "extra"; + if (letter.state === "incorrect") return "incorrect"; + if (letter.state === "correct" || letter.state === "corrected") { + return "correct"; + } + return "untyped"; +} + +function letterCorrections(letter: ResultLetter): string | undefined { + const corrections = []; + if (letter.state === "corrected") corrections.push("corrected"); + if (letter.extraCorrected) corrections.push("extraCorrected"); + return corrections.length > 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), + }; +} + +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", "text-error": letter.state === "incorrect", 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 8ab9e47222f0..8e2155c7679a 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/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 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, 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/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/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/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/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; } 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/aurora.css b/frontend/static/themes/aurora.css index 48cce1699745..36b875eac367 100644 --- a/frontend/static/themes/aurora.css +++ b/frontend/static/themes/aurora.css @@ -58,30 +58,57 @@ 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 { - --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; + } -#words.highlight-off .word letter, -#words.highlight-off .word.typed 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; + } -#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 { - 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; + } -#words.flipped .word.typed 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/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 b522f4a7aa3e..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); } -.colorfulMode { - --current-color: var(--main-color); -} - button:not(.accountButtonAndMenu *), .button:not(.accountButtonAndMenu *), [data-ui-element="testConfig"] { @@ -54,27 +50,49 @@ body::before { --roundness: 0; /* making command line sharp */ } -/* restyling error displays to be strike-through instead of underlined */ -#wordsWrapper .word.error { - border-bottom: none; -} +[data-ui-element="words"] { + &[data-ui-variant~="colorful"] { + --current-color: var(--main-color); + } -#words .word.error { - text-shadow: none; -} + /* restyling error displays to be strike-through instead of underlined */ + & [data-ui-element="word"][data-ui-state="typedIncorrectly"] { + border-bottom: none; + text-shadow: none; + } -#words:not(.blind) .word letter.incorrect, -#words:not(.blind).colorfulMode .word letter.incorrect { - 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"]: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; + } -#words:not(.blind) .word letter.incorrect:not(.extra), -#words:not(.blind).colorfulMode .word letter.incorrect:not(.extra) { - color: var(--current-color); -} + &: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); + } -#words:not(.blind) .word.error letter:not(.correct):not(.incorrect)::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 a36d33d1f9bc..3889427eb88c 100644 --- a/frontend/static/themes/fire.css +++ b/frontend/static/themes/fire.css @@ -64,30 +64,57 @@ 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 { - --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; + } -#words.highlight-off .word letter, -#words.highlight-off .word.typed 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; + } -#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 { - 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; + } -#words.flipped .word.typed 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 0fc62c15ecf5..b8fb77a18bac 100644 --- a/frontend/static/themes/grape.css +++ b/frontend/static/themes/grape.css @@ -59,30 +59,57 @@ 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 { - --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; + } -#words.highlight-off .word letter, -#words.highlight-off .word.typed 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; + } -#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 { - 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; + } -#words.flipped .word.typed 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/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/phantom.css b/frontend/static/themes/phantom.css index 074c1507f2aa..4c54b2ad7c9a 100644 --- a/frontend/static/themes/phantom.css +++ b/frontend/static/themes/phantom.css @@ -49,11 +49,6 @@ } } -#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; @@ -113,10 +108,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); @@ -135,26 +126,62 @@ button.text:hover, animation: phantom-glow 3s linear infinite; } -#words.highlight-off .word letter, -#words.highlight-off .word.typed letter { +[data-ui-element="resultStatSubValue"] { 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 { - animation: phantom-glow 5s linear infinite; -} +[data-ui-element="words"] { + --correct-letter-color: var(--text-color); + --untyped-letter-color: #565f89; -#words.flipped .word.typed letter { - animation: none; -} + & + [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; + } -.incorrect { - animation: phantom-glow-incorrect 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 c02ed93e0adb..7f2fa806a2ed 100644 --- a/frontend/static/themes/rainbow_trail.css +++ b/frontend/static/themes/rainbow_trail.css @@ -63,44 +63,79 @@ 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; --extra-letter-animation: rainbow-error-infinite-loop 1s alternate ease-in-out infinite; -} -#words.highlight-off .word letter, -#words.highlight-off .word.typed 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; + } -#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 { - 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; + } -#words:not(.blind) .word.error { - 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; + } -#words.highlight-word .word.active 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); + } -#words.flipped { - --correct-letter-animation: none; - --untyped-letter-animation: rainbow-infinite-loop 5s linear infinite; + &[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; + } } -#words.flipped .word.typed 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 a8014a8a7c38..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, -#words.flipped .word, footer a:hover { animation: rgb 5s linear infinite; } @@ -106,11 +105,15 @@ 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 { - --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 77bed69211c4..5093b14f24bf 100644 --- a/frontend/static/themes/shadow.css +++ b/frontend/static/themes/shadow.css @@ -33,12 +33,34 @@ 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 { - 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/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; } 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); } diff --git a/frontend/static/themes/trance.css b/frontend/static/themes/trance.css index 97776935ceb4..7a9220bce3ce 100644 --- a/frontend/static/themes/trance.css +++ b/frontend/static/themes/trance.css @@ -47,30 +47,57 @@ 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 { - --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; + } -#words.highlight-off .word letter, -#words.highlight-off .word.typed 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; + } -#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 { - 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; + } -#words.flipped .word.typed 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; + } }