Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 7 additions & 6 deletions docs/specs/solid-monkeytype/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Each step can start once the previous step's **bold** items are done. Everything
- [ ] 2. **TH1** test-page harness, then **T1** `data-ui-*` on words and letters. Alongside: U2, U3, U4 (after U1), C2, C3.
- [x] 3. **T2** joining-script dots. Alongside: EP3.
- [x] 4. **T3** caret boundary. Then L3 and L12 can start.
- [ ] 5. **T4** word registry private.
- [ ] 5. **T4** word registry private (done) → **T4b** letter refs.
- [ ] 6. **T5** delete test-ui (needs EP1, EP2, L1, L2 from step 1).
- [ ] 7. **T6a** engine state → **T6b** restart via flag and **T6c** finish split (these two in parallel).
- [ ] 8. **T7** drop the page-mount workaround (needs L3, L4, L5). Alongside: C4, U6.
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 words/letters data-ui-* ─► T2 joining dots ─► 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 joining dots ─► T3 caret boundary ─► T4 registry private ─► T4b letter refs ─► 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 @@ -86,8 +86,9 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec
| [x] | T1 | L · PR | **Theme attributes on the test and result words**: words (one attribute per mode: `data-ui-word-highlight`, `data-ui-blind`, `data-ui-flipped`, `data-ui-colorful`, `data-ui-joining-script`, `data-ui-rtl`), word (`WordStatus`, `data-ui-active`), letter (`LetterStatus`, tab/newline variant), typo hint; result `wordsHistory`/`replayWords`, word (`WordStatus`, typed = committed), letter (`ResultLetterStatus`, `data-ui-corrected`, `data-ui-extra-corrected`). `<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 words `data-ui-rtl` 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 |
| [x] | 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. Done in [#8562](https://github.com/monkeytypegame/monkeytype/pull/8562): replaced instead of moved. Joining-script letters stay inline under the dots typed effect and draw their own dot (CSS only, a `::before` copy from `data-char` runs the shrink), so nothing is measured; the break-joining module, its layout calls and config effects are deleted, and Word uses `cn` (no outside class writes left). Deviations in the spec | T1, TH1 | The only outside writes into Word elements |
| [x] | 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). Fix the tape offset for a word whose direction differs from the test's: after EP3 the caret is still up to ~105px off its letter (measured; ~194px seen in word tape), mixed custom text and polyglot only. Done in [#8563](https://github.com/monkeytypegame/monkeytype/pull/8563): Words renders `#caret`/`#paceCaret` (`data-ui-element` `caret`/`paceCaret`) with reactive style class, visibility (`isWordsInputFocused`, set by WordsInput; hidden from `isResultCalculating` on) and blinking (focus mode); movement stays on the `Caret` handle (`components/pages/test/caret.ts`, was `elements/caret.ts`) owned by the words layout. `test/caret.ts` deleted; `test/pace-caret.ts` is DOM-free, exporting `paceCaretTarget`. The tape's letter shift is shared (`getTapeLetterShift`); a locked caret in a word running against the test places itself from the tape anchor. Monkey power still reads `#caret` from the DOM (L3); the dev handle finds the caret by `data-ui-element`. Caret style is also `data-ui-variant`; `caret.scss` and the bundled theme and crt funbox caret CSS target `data-ui-*` (style classes kept for legacy themes). Deviations in the spec | 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 |
| [ ] | T5 | L · PR | **Delete test-ui**: sound/keymap/monkey-power triggers become effects or input-layer calls; autofocus moves to WordsInput; fades move to TestPage, as does the pre-test scroll to the test block (the focus flow keeps requesting Words' active-line recenter during a test). Add the **input-layer lint rule** (no component/UI imports) | T4, EP1, EP2, L1, L2 | The glue module test-logic and input call |
| [x] | T4 | S · commit | **Word registry private** to Words; only the synchronous overflow check stays exported. Done in [#8567](https://github.com/monkeytypegame/monkeytype/pull/8567): `createWordRegistry()` per Words instance (`components/pages/test/word-registry.ts`), Word/line breaks register via a prop, `Caret.goTo` takes the word element; `states/word-elements.ts` deleted, `LETTER_SELECTOR` in `components/pages/test/selectors.ts` | T3 | The public word-elements bridge |
| [ ] | T4b | M · PR | **Letter refs**: each Word keeps its letter elements, filled by WordLetter callback refs and read through the word registry (eg. `registry.getLetters(i)`); caret, words layout and TypoHints use them instead of selector queries; caret debug marking becomes a reactive attribute on WordLetter (drops its document queries); the overflow probe inserts its temporary letters after the last ref. Delete `LETTER_SELECTOR` (`dev/` and tests may keep a local selector). Spec Q31 | T4 | The test words' selector queries |
| [ ] | T5 | L · PR | **Delete test-ui**: sound/keymap/monkey-power triggers become effects or input-layer calls; autofocus moves to WordsInput; fades move to TestPage, as does the pre-test scroll to the test block (the focus flow keeps requesting Words' active-line recenter during a test). Add the **input-layer lint rule** (no component/UI imports) | T4b, EP1, EP2, L1, L2 | The glue module test-logic and input call |
| [ ] | T6 | split | **Engine split**, in three PRs: | T5 | test-logic ↔ UI coupling |
| [ ] | T6a | L · PR | Engine owns its state module (phase, active word index, words, timer, live stats); commands start/restart/finish/fail; the general test state keeps only UI state; drop its `__nonReactive` (layout emulator and keydown read engine/UI state directly). The engine owns the EventLog: event arrays, pressed keys, `logTestEvent` as a command; derived signals for current input, live stats and the frozen log (replaces `lastEventLog` in the test state module); key-code helpers (`getTestEventCode`, `keysToTrack`) move to input. Add the **engine lint rule** (no UI/input/DOM; data layer allowed) | T5 | |
| [ ] | T6b | L · PR | **Restarts via `changeRequiresRestart`**: the engine requests one merged restart on flagged config changes; test settings retaken while faded out; render reads the test settings; callers stop restarting; tags stay live. The EventLog context reads the test settings, not live config. The restart calls `replaceWords` outside effects and batches: it measures the new words synchronously, inside one it would measure the old words | T6a, C1 | Lost settings mid-restart, flashes, double restarts |
Expand Down Expand Up @@ -146,7 +147,7 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec
| Done | ID | Size | Step | Blocked by |
|---|---|---|---|---|
| [ ] | CL1 | M · PR | Delete the DOM-wrapper utility library, the ref-with-utils hook and `onDOMReady` (the app has none since L6; `email-handler.html`, a separate Vite entry, still imports `qs` and `onDOMReady` from `utils/dom`, so migrate that page first) | 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 |
| [ ] | CL2 | M · PR | Element-query / `innerHTML` lint ban: `querySelector*`, `getElementsBy*`, `qs`/`qsa`, scoped ones too (spec Q31); `closest()` allowed, `dev/` and tests exempt; 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; `<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 |

Expand All @@ -158,7 +159,7 @@ Serial; each step needs the previous one. Verify each in the browser (`__mt.chec

## Suggested sequencing for 2–3 people

- **Person 1 (critical path):** T0 → TH1 → T1 → T2 → T3 → T4 → T5 → T6a → T6b → T6c → T7.
- **Person 1 (critical path):** T0 → TH1 → T1 → T2 → T3 → T4 → T4b → T5 → T6a → T6b → T6c → T7.
- **Person 2 (state):** C1 → C2 (×5) → U1 → U2 → U3 → C3 areas → U4 → U5 → then C4, U6 once T6 lands.
- **Person 3 / agents (leaves):** EP1, EP2, L1, L2 first (they gate T5), then L8, L7, L9, L10, L4, L5, L6, C5, L11, EP4; L3 and L12 once T3 lands; EP3 after T1.
- **Converge:** C4, U6, then CL, then TR1.
Expand Down
9 changes: 5 additions & 4 deletions docs/specs/solid-monkeytype/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ Users should see no behaviour change, except where a bug at today's seams disapp
### Typing test: render layer

- The words render store, fed by the shared per-frame flush buffer, is the only input to Words, Word, WordLetter and TypoHints, including the typed input that layout and the caret position from.
- **Words owns all layout**: after each flush it runs one layout pass: line jump, tape scroll, input positioning and caret updates. Joining-script dots (dots typed effect) are CSS only: letters stay inline, so nothing is measured and nothing writes into Word. The word-element registry becomes private to Words; only the overflow check is exported.
- **Words owns all layout**: after each flush it runs one layout pass: line jump, tape scroll, input positioning and caret updates. Joining-script dots (dots typed effect) are CSS only: letters stay inline, so nothing is measured and nothing writes into Word. The word-element registry is private to Words: Words creates one per instance, its Word and line breaks register through a callback prop, and its layout hands the caret the word element. From T4b it also holds each word's letter elements, filled by WordLetter callback refs; caret debug marking is a reactive attribute on WordLetter. Only the overflow check is exported, reading the mounted Words' registry.
- **Words keeps the active line in view** as part of that layout pass: during a test, when the words wrapper is taller than the window, it scrolls the window to the active word (down only while typing, both ways when recentering after a re-layout or on request). The focus flow asks for a recenter through a request signal. Before the test, TestPage keeps the test block in view.
- **Carets: Words renders and drives both.** Words is the only thing that drives the caret and pace caret; nothing outside Words calls them; the line-jump/tape-scroll handoff is internal to Words. Decided in T3, a mix: Words renders the caret elements with their style class, visibility (caret: words input focused, test not ending or finished, window not resizing; pace caret: has a target) and blinking (off in focus mode) reactively; movement (position, line-jump and tape-scroll margins) stays anime.js animations on a private imperative handle the words layout owns, so it looks as before. The pace caret's timing is a DOM-free module whose output is a target signal (word, letter, step end). Tribe carets follow the pace caret: Words renders one per player from the room store, each heading for a target (`stepEnd` = now + the room's update rate), and the layout's caret list (`allCarets`) carries their line-jump and tape-scroll handoff; nothing outside Words moves them.
- **TestPage runs all fades**, driven by the engine's phase: words fade out on finish; when the fade-out completes, the result fades in if the result signal has a value, otherwise a spinner fades in and is swapped for the result when it arrives. Restart fades follow the restart flow above.
Expand All @@ -199,13 +199,13 @@ 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` (one value, eg. a letter's `tab`/`newline`). Facts independent of the state get their own attribute, one per fact, never a space-separated list: `data-ui-active` on the active word; `data-ui-word-highlight` (the highlight mode, eg. `next-two-words`), `data-ui-blind`, `data-ui-flipped`, `data-ui-colorful`, `data-ui-joining-script` and `data-ui-rtl` on words; `data-ui-corrected` and `data-ui-extra-corrected` on result letters; the caret and pace caret carry their caret style as `data-ui-variant`. 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.
- **`<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 through refs (each Word's letter list, from T4b), never by tag or selector.
- **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.

### Leaf UI and utilities

- No module outside a component queries the document for elements. Components use refs (callback ref pattern, act in `onMount`), per the coding standards.
- No app code finds elements by selector: no `querySelector*`, `getElementsBy*` or `qs`/`qsa`, document-wide or scoped to an element. Components and their private helpers reach elements through refs (callback ref pattern, act in `onMount`), per the coding standards, and may measure and write on them imperatively (layout, animations, the overflow probe's temporary letters). Walking up from an element the code was handed (`event.target.closest(...)`) is allowed. `dev/` and tests are exempt: they verify the DOM.
- The DOM-wrapper utility library (`qs`/`qsa`/element-with-utils), the ref-with-utils hook and `onDOMReady` are deleted at the end.
- Server-provided HTML (notifications, simple modal, version history, quote-search highlight) keeps using `innerHTML`, sanitized, as the lint rule's allowlist.
- The activity calendar is a Solid component over plain activity data. The sorted table utility is replaced by the DataTable component. Ad and analytics controllers keep their third-party script handling but find elements through component refs or state.
Expand All @@ -217,7 +217,7 @@ Users should see no behaviour change, except where a bug at today's seams disapp
- Input imports the engine and the render buffer, never components.
- Components import states, the engine's signals/commands and hooks. A component never calls back into input.
- Only components (and their private helper modules) touch the DOM.
- Each rule becomes a lint rule (import restrictions per folder, plus a ban on document queries and `innerHTML` outside the allowlist) **in the PR that makes that layer clean**: the engine rule with the engine split, the input rule with test-ui's deletion, the DOM-query ban at the end.
- Each rule becomes a lint rule (import restrictions per folder, plus a ban on element queries, scoped ones included, and `innerHTML` outside the allowlist) **in the PR that makes that layer clean**: the engine rule with the engine split, the input rule with test-ui's deletion, the DOM-query ban at the end.

### Tribe

Expand Down Expand Up @@ -328,6 +328,7 @@ From commandline metadata (C5): generated config commands use the settings label
| Q28 | Same one-getter pattern for tags, presets and custom themes (replaces their live-query hooks); results stay async. `__nonReactive` goes by rewriting its old-style callers, then deleting it |
| Q29 | Auth bus deleted entirely; the user-data load is state (loading/loaded/failed) plus `reloadUserData()` beside the auth signal, read by the router |
| Q30 | The user is one Solid store in `states/user.ts`, not a collection: no sync source, the collection needed a mirror store anyway, TanStack optimistic transactions hide concurrent direct writes. Same API (`getUser()`, named writes, hand-rolled optimistic actions with field-level rollback) |
| Q31 | (2026-11-11) Elements only through refs: no selector queries in app code, scoped ones included; imperative measuring and writes on refs stay; `closest()` from a handed element allowed; `dev/` and tests exempt. Letters get refs in T4b, `LETTER_SELECTOR` goes |

EventLog (grilling, 2026-10-26):

Expand Down
2 changes: 1 addition & 1 deletion frontend/__tests__/__harness__/test-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ 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 { LETTER_SELECTOR } from "../../src/ts/components/pages/test/selectors";
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 @@ -74,7 +75,6 @@ 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";
import { paceCaretTarget } from "../../src/ts/test/pace-caret";

Expand Down
Loading
Loading