Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
da62366
test: test-page harness (TH1)
Miodec Oct 26, 2026
f67067c
test: run any *.page-spec.tsx on the test-page harness
Miodec Oct 26, 2026
2557977
refactor: inline the __mt helpers into expose.ts
Miodec Oct 26, 2026
8ce4a44
feat: data-ui-* attributes on test words, words, letters and typo hin…
Miodec Oct 26, 2026
75c0801
refactor: find letters by data-ui-element, not the letter tag (T1)
Miodec Oct 26, 2026
8799428
feat: data-ui-* attributes on result words history and replay letters…
Miodec Oct 26, 2026
54f940e
refactor: name the result words containers wordsHistory and replayWor…
Miodec Oct 26, 2026
0689bc7
refactor: bundled themes target data-ui-* instead of #words, classes …
Miodec Oct 26, 2026
9768cfb
refactor: funbox css targets data-ui-* instead of #words and letter (T1)
Miodec Oct 26, 2026
969b50d
docs: theme attributes as built in T1, tag swap moves to CL3
Miodec Oct 26, 2026
b91bb9e
refactor: remaining theme and funbox css targets data-ui-*, adds rtl …
Miodec Oct 27, 2026
7973dad
docs: rtl variant, wordsWrapper and leftover legacy selectors (T1, L2)
Miodec Oct 27, 2026
ee5016c
feat: result words carry data-ui-state, taro and modern_ink target it…
Miodec Oct 27, 2026
7757c0d
docs: result word state (T1)
Miodec Oct 27, 2026
f107fe8
Merge remote-tracking branch 'origin/master' into t1-letter-span
Miodec Oct 28, 2026
024dea5
Merge branch 'master' into t1-letter-span
Miodec Oct 28, 2026
ae05d76
fix(themes): stylelint duplicate selector and descending specificity …
Miodec Oct 28, 2026
4d5b4f2
refactor(themes): nest data-ui-* word and letter rules (T1) (@miodec)
Miodec Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 4 additions & 4 deletions docs/specs/solid-monkeytype/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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 | **`<letter>` → `<span data-ui-element="letter" data-ui-state>`**, 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`). `<letter>`/`<hint>` 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 |
Expand Down Expand Up @@ -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; `<letter>`/`<hint>` become `<span>`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)
Expand Down
8 changes: 4 additions & 4 deletions docs/specs/solid-monkeytype/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
- **`<letter>` goes away**: letters render as `<span data-ui-element="letter" data-ui-state="…">` (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.
- **`<letter>` goes away**: letters render as `<span data-ui-element="letter" data-ui-state="…">` (inline by default, so no extra CSS). Any other made-up elements (`<hint>`) 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.

Expand Down Expand Up @@ -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 | `<letter>` becomes `<span data-ui-element="letter">` |
| Q10 | `<letter>` becomes `<span data-ui-element="letter">`, 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 |
Expand Down
59 changes: 22 additions & 37 deletions frontend/__tests__/__harness__/test-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -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[];
Expand All @@ -154,8 +148,9 @@ export type TestPageHarness = {
/** Moves the clock by `ms`, running timers and frames on the way. */
advance: (ms: number) => Promise<void>;
word: (index: number) => {
letters: () => { char: string; state: LetterState }[];
state: WordState;
letters: () => { char: string; state: LetterStatus }[];
state: WordStatus;
isActive: boolean;
};
phase: () => TestPhase;
liveStats: () => {
Expand All @@ -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<void> {
vi.advanceTimersToNextFrame();
// let the frame's promise chains run
Expand Down Expand Up @@ -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;
Expand All @@ -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<HTMLElement>(
`: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: () =>
Expand Down
22 changes: 12 additions & 10 deletions frontend/__tests__/components/pages/test/TestPage.page-spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -71,16 +72,16 @@ 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 () => {
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");
expect(page.word(0).state).toBe("typedIncorrectly");
expect(page.word(1).isActive).toBe(true);
});
});

Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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);
});
});

Expand All @@ -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" },
Expand Down
6 changes: 5 additions & 1 deletion frontend/__tests__/components/pages/test/TypoHints.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ function renderWord(text: string): HTMLElement {
}

function hintsOf(container: HTMLElement): HTMLElement[] {
return [...container.querySelectorAll<HTMLElement>(".word > .hints > hint")];
return [
...container.querySelectorAll<HTMLElement>(
'.word > .hints > [data-ui-element="typoHint"]',
),
];
}

beforeEach(() => {
Expand Down
Loading
Loading