From 367ca8ac6ee476c9624f12deef26de17cf190bab Mon Sep 17 00:00:00 2001 From: shum Date: Wed, 21 Oct 2026 12:01:59 +0000 Subject: [PATCH] plans: names webapp redesign spec and plans --- .../2026-10-17-webapp-in-place-updates.md | 672 +++++++ ...0-17-webapp-in-place-updates.md.tasks.json | 92 + .../2026-10-19-webapp-redesign-plan.md | 1571 +++++++++++++++++ ...6-10-19-webapp-redesign-plan.md.tasks.json | 121 ++ .../names-codes/2026-10-19-webapp-redesign.md | 127 ++ 5 files changed, 2583 insertions(+) create mode 100644 plans/names-codes/2026-10-17-webapp-in-place-updates.md create mode 100644 plans/names-codes/2026-10-17-webapp-in-place-updates.md.tasks.json create mode 100644 plans/names-codes/2026-10-19-webapp-redesign-plan.md create mode 100644 plans/names-codes/2026-10-19-webapp-redesign-plan.md.tasks.json create mode 100644 plans/names-codes/2026-10-19-webapp-redesign.md diff --git a/plans/names-codes/2026-10-17-webapp-in-place-updates.md b/plans/names-codes/2026-10-17-webapp-in-place-updates.md new file mode 100644 index 0000000000..a8f0a9d6b6 --- /dev/null +++ b/plans/names-codes/2026-10-17-webapp-in-place-updates.md @@ -0,0 +1,672 @@ +# Names webapp: pages that change in place — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Every in-page state change of the names webapp updates only the nodes whose state changed; only a move to another screen is a page transition. + +**Architecture:** Today every state change rebuilds the whole page (`page()` with fresh DOM) and `motion.ts` compares old and new pages (signatures, slots, runtime view-transition names, Firefox overlays) to make the swap look partial. That comparison is the source of every flicker reported. The new model: a view computes its state and calls `screen(kind, state, build)`; the screen of the same kind already shown gets `update(state)` and changes its own nodes (class changes animated by CSS transitions, `live()` cross-fades, `rollTo`, keyed list sync with `addTo`/`removeFrom`); another kind is built once and shown by `page()` as a page change. Page changes use view transitions with statically named shared elements (header, footer, step bar, order card, order name) in Chromium and WebKit, and a fade-through in Firefox. The comparison machinery is then deleted. + +**Tech Stack:** Vite + TypeScript (vanilla DOM), Web Animations API, CSS transitions, View Transitions API, Playwright e2e (Chromium, Firefox, WebKit), vitest (node, no DOM). + +**Spec:** This document (the owner approved the approach in conversation on 2026-10-17: "Proceed with your plan please, make the architecture correct and modern"). Context: `plans/names-codes/2026-10-09-names-web-store-plan.md`. + +## Global Constraints + +- Code lives in `/project/git/simplex.domains/webapp`; never commit unless the owner asks. +- Follow CLAUDE.md: comments rare, short, no plan references; named constants; no dead code; fix every warning; English only. +- In-page change = same route token family (a `refresh()` redraw or a poll answer) and same screen kind. It MUST NOT call `page()`, MUST NOT start a view transition, and MUST keep the DOM nodes of everything that did not change (same object identity). +- Screen change (another kind, or another route) = one page transition: view transition with types `forward` / `back` / `screen` / `fill` / `theme` in browsers where `transitions()` is true; elsewhere (Firefox, `motion.ts` `VIEW_TRANSITIONS` false) the fade-through in `swapPage(..., "through")`. +- View transitions name only static shared elements: `page-top`, `page-foot`, `offline-note`, `page` (the main content), `page-title` (`.page-head h1`), `page-lead` (`.page-head .lead`), `crumbs`, `step-ring`, transient `step-line-*` / `step-done-*` from `crumbs()`, `order-card` (`.summary`), `page-panel` (the main column's first `.panel`), `order-name`. No runtime pairing, no `part-N` / `gone-N` / `fresh-N` names. +- Reduced motion: every change applies at once (`moving()` false), as today. +- Timers and listeners that belong to a screen stop when the screen is replaced or the route is left (the screen's `AbortSignal`), not when a redraw of the same screen happens. +- Focus: an update keeps focus on the node that had it (the node survives); a screen change focuses the new heading, as today (`focus.focusHeading`). +- Determinism (owner, 2026-10-17: "implement all three, everything should be deterministic"): each view's state is one object changed only by `dispatch(event)` through a pure `reduce(state, event)`; a pure `screenOf(state)` picks the screen; a per-view transition table (`moves`) gives each screen-to-screen move its motion. Reducers and `screenOf` never read the clock, randomness, storage, the network or the DOM: time and outside data arrive inside events. Effects (API calls, storage writes, timers, focus) run in the view's controller, and their results come back as events. A move missing from the table is logged with `console.warn("[screens] : unexpected move → ")` and still shown as a page change. The pure modules live in `src/screens/` and are covered by vitest: every `screenOf` branch and every table row. +- e2e must pass in Chromium, Firefox and WebKit (`npx playwright test --project=`), with the motion checks of `e2e/cuts.ts` (no flash, cut-in, cut-out, spike, jump, shift, scroll-jump). + +- Every page-change animation that exists today is preserved (owner, 2026-10-17): header, footer and ground stay still and the footer moves to the new height; the order's name travels from the search result into the order card (`order-name`); the step bar's pills stay while the ring moves on (`step-ring`), the line to the reached step fills (`step-line-*`) and the step left turns to a growing tick (`step-done-*`); the order card stays and morphs across checkout → name code → payment → registering; headings and leads change in place; the main panel morphs; content that differs slides out and in the way the reader goes (`forward`/`back`) or fades within a page; the theme is one cross-fade. + +**User decisions (already made):** +- "Proceed with your plan please, make the architecture correct and modern" (2026-10-17): views update in place; page transitions only between screens; delete the redraw/diff machinery. +- Firefox before 158 does not use view transitions (bug 2066829, fixed in 158); it changes pages by the fallback (fade through, shared parts moved by FLIP). Firefox 158 and newer use view transitions like Chromium (owner updated to 158, 2026-10-17: "Yes, proceed"). +- Order/registering page first, then checkout, Your names, search. +- Pure screen functions, a transition table per view, and one reducer per view, all unit-tested; everything deterministic (2026-10-17). + +--- + +## File structure + +| File | Responsibility after the change | +|---|---| +| `src/views/shell.ts` | Router glue, header/footer, popups, `page()` (screen change only), new `screen()` primitive and the shown screen's lifetime | +| `src/motion.ts` | In-place motion (`live`, `crossFade`, `rollTo`, `addTo`, `removeFrom`, height easing, new `syncList`), page change (`transition`, `swapPage`), no comparison code | +| `src/views/order.ts` | Order page: `follow()` loop computes state and calls `screen()` with kinds `pay-crypto`, `pay-card`, `confirming`, `slow`, `registering`, `registered`, `taken`, `code-issued`, `failed`, `not-on-device`, `status:`; each kind has `build` and, where its state changes in place, `update` | +| `src/views/checkout.ts` | Checkout and name-code screens with `update` for code applied/removed, method, waiting order, link previews | +| `src/views/names.ts` | Your names screen with keyed row sync; edit and restore screens | +| `src/views/search.ts` | Search screen with `update` for applied code, retarget banner, typed name kept | +| `src/styles.css` | Page transition rules for the static names; CSS transitions for step states; no part/box/swap/gone/fresh rules | +| `e2e/motion.spec.ts`, `e2e/cuts.ts` | Motion checks for the new model: in-place updates keep nodes and run no view transition | +| `e2e/inplace.spec.ts` (new) | One test per screen: an in-page state change keeps unchanged nodes, runs no transition, shows no cut | +| `src/screens/machine.ts` (new) | Pure core: `Machine`, `Motion`, `step()` (reduce, pick screen, look up the move, warn on an unexpected one) | +| `src/screens/order.ts`, `checkout.ts`, `names.ts`, `search.ts`, `routes.ts` (new) | Pure per-view state, events, `reduce`, `screenOf`, `moves`; `routes.ts` gives moves between views (search → checkout → name code → order, Your names) | +| `test/machine.test.ts`, `test/screens-*.test.ts` (new) | vitest: `step()`, every `screenOf` branch and every table row per view | + +--- + +### Task 1: The screen primitive + +**Goal:** `screen()` in `shell.ts` builds a screen once and routes later states of the same kind to its `update`, with a per-screen `AbortSignal` for its timers. + +**Files:** +- Modify: `src/views/shell.ts` (add `screen`, `Built`, the shown-screen state; reset it in `nextRoute` when the route is not a redraw) +- Modify: `src/views/order.ts:27-38` (`every()` takes a signal instead of pushing to `cleanups`) +- Test: `e2e/inplace.spec.ts` (new; first test drives Task 2, so this task's test is the harness helper) + +**Acceptance Criteria:** +- [ ] `screen(active, kind, state, build)` calls `build(state, signal)` and `page()` when no screen of `kind` is shown on this route family; otherwise calls the shown screen's `update(state)` and nothing else. +- [ ] A screen without `update` is rebuilt (`page()`) when its kind is called again with a state whose `JSON.stringify` differs from the shown one; with an equal state it is left alone. +- [ ] Leaving the route (`nextRoute` without `redrawing`) aborts the shown screen's signal and forgets it; a `refresh()` keeps it. +- [ ] `e2e/inplace.spec.ts` exists with helpers `markNodes(page, selectors)` (sets `__kept = true` on each matched node) and `keptNodes(page, selectors)` (returns which still carry it), and `takeTransitions` from `e2e/cuts.ts`. +- [ ] `npx tsc --noEmit -p .` clean. + +**Verify:** `npx tsc --noEmit -p . && npm test` → no errors, `77 passed` + +**Steps:** + +- [ ] **Step 1: Add the primitive to `src/views/shell.ts`** (after `refresh()`): + +```ts +// A screen of a view is built once and then told each new state of what it shows, changing only what differs; a state +// of another kind is another screen, shown as a page change. What a screen runs, as a countdown, stops with it. +export interface Built { sections: Node[]; update?: (state: S) => void } +interface Shown { kind: string; state: string; update?: (state: unknown) => void; stop: AbortController } +let shown: Shown | null = null + +export function screen(active: Active, kind: string, state: S, build: (state: S, signal: AbortSignal) => Built): void { + const same = shown !== null && shown.kind === kind && isRedraw() && document.querySelector(SHOWN_PAGE) !== null + if (same && shown!.update) return shown!.update(state) + if (same && shown!.state === JSON.stringify(state)) return + shown?.stop.abort() + const stop = new AbortController() + const built = build(state, stop.signal) + shown = { kind, state: JSON.stringify(state), update: built.update as Shown["update"], stop } + page(active, built.sections) +} +``` + +In `nextRoute()`, where `focus.drop()` runs for a new page, also run: + +```ts + shown?.stop.abort() + shown = null +``` + +`state` passed to screens must be JSON-serialisable data (API views, records, flags), never DOM nodes or functions. + +- [ ] **Step 2: Signal-owned timers in `src/views/order.ts`**: replace `cleanups`/`cleanUp`/`every` with + +```ts +function every(ms: number, f: () => void, signal: AbortSignal): void { + const id = setInterval(f, ms) + signal.addEventListener("abort", () => clearInterval(id), { once: true }) +} +``` + +and give `onTheSecond(left, f, signal)` the same signal (clear its timeout on abort). `cleanups.push(card.destroy)` becomes `signal.addEventListener("abort", card.destroy, { once: true })`. + +- [ ] **Step 3: Create `e2e/inplace.spec.ts` helpers** + +```ts +import { expect, test, type Page } from "@playwright/test" +import { recordCuts, takeCuts, takeTransitions } from "./cuts.ts" + +test.use({ reducedMotion: "no-preference" }) +test.beforeEach(async ({ page }) => { await page.addInitScript(recordCuts) }) + +// marks the nodes a state change must keep, to tell afterwards which are still the same nodes +export const markNodes = (page: Page, selectors: string[]) => page.evaluate((list) => { + for (const s of list) for (const n of document.querySelectorAll("body > main " + s)) (n as unknown as { __kept: boolean }).__kept = true +}, selectors) +export const keptNodes = (page: Page, selectors: string[]) => page.evaluate((list) => list.filter((s) => { + const n = document.querySelector("body > main " + s) as unknown as { __kept?: boolean } | null + return n?.__kept === true +}), selectors) +``` + +- [ ] **Step 4: Type-check and unit tests** + +Run: `npx tsc --noEmit -p . && npm test` +Expected: no type errors, `Tests 77 passed` + +```json:metadata +{"files": ["src/views/shell.ts", "src/views/order.ts", "e2e/inplace.spec.ts"], "verifyCommand": "npx tsc --noEmit -p . && npm test", "acceptanceCriteria": ["screen() builds once and routes same-kind states to update", "screen without update rebuilds only on a changed state", "leaving the route aborts the screen signal; refresh keeps it", "inplace.spec.ts helpers exist", "tsc clean"], "modelTier": "frontier"} +``` + +--- + +### Task 2: Order page as screens, registering changes in place + +**Goal:** The order page's `follow()` loop calls `screen()` with a kind per screen; the registering screen's phases (committing → committed → wait → registering), its countdown, transactions and problem note change in place. + +**Files:** +- Modify: `src/views/order.ts` (`draw`, `payment`, `confirming`, `registering`, `track`, `txRows`, `registered`, `taken`, `codeIssued`, `failed`, status screens; remove `carry(note)` uses) +- Modify: `src/styles.css` (`.stage` / `.node` state transitions) +- Test: `e2e/inplace.spec.ts` + +**Acceptance Criteria:** +- [ ] Kinds: `pay-crypto`, `pay-card`, `confirming`, `slow`, `registering`, `registered`, `taken`, `code-issued`, `failed`, `not-on-device`, and `status:` for each status page (`paid-late`, `paid-by-invoice`, `still-open`, `card-failed`, `expired-cancelled`, `expired-received`, `expired`, `price-changed`, `unreadable`, `unknown-link`). +- [ ] `registering` update, for a new `RegistrationView` of the same order: each `.stage` gets its new state class (`done`/`active`/`""`), its node text crosses over (live), its title crosses over (live); the wait line fill starts on entering `committed`; the countdown clock and note keep their live behaviour; each tx row's value and chip cross over (live); the problem slot crosses over; heading/lead/crumbs/card are untouched unless their text changes (then live). +- [ ] `pay-crypto` update: partial-payment alert slot, amount label and amount, copy fields, QR (only when the URI changed), "Waiting for (the rest of) payment" and the cancel link slot cross over in place; the rate countdown restarts only if `expiresAt` changed. +- [ ] `confirming` update: the confirmations line crosses over; the slow timer keeps running from the same `cardSubmittedAt`. +- [ ] Stage colours change by CSS transition (`transition: background-color, border-color, color, box-shadow` on `.stage .node` and the line drawn by `.stage::before`), not by a view transition. +- [ ] e2e `registering changes in place`: on the code-order journey, nodes `.track`, `.stage:nth-child(1)`, `.countdown`, `.txs`, `.summary`, `h1` are marked during committing; after the commit is mined and again after the wait ends, all are still kept, `takeTransitions` is `[]`, `takeCuts` is `[]`, and the stage classes are `done active ""` then `done done active`. +- [ ] e2e `a new screen is a page change`: registering → registered runs exactly one transition (or, in Firefox, `.page-leaving` appears once) and the heading takes focus. + +**Verify:** `npx playwright test e2e/inplace.spec.ts e2e/paid.spec.ts e2e/taken.spec.ts --project=chromium` → all passed + +**Steps:** + +- [ ] **Step 1: Write the failing e2e test** in `e2e/inplace.spec.ts`: + +```ts +import { control, REGISTRATION_MS, uniqueLabel } from "./helpers.ts" +test("registering changes in place: the steps, countdown and transactions keep their nodes through each phase", async ({ page, request }) => { + const { code } = await control(request, "code", { minLength: 6 }) as { code: string } + await page.goto("/") + await page.getByRole("button", { name: "Check a code" }).click() + await page.getByRole("textbox", { name: "Code", exact: true }).fill(code) + await page.getByRole("button", { name: "Use this code" }).click() + await page.getByRole("textbox", { name: "Name", exact: true }).fill(uniqueLabel("inplace")) + await page.getByRole("textbox", { name: "Name", exact: true }).press("Enter") + await page.getByRole("button", { name: "Register with code" }).click() + await page.getByRole("button", { name: "Continue" }).click() + await page.getByText("I have saved my code").click() + await page.getByRole("button", { name: "Register", exact: true }).click() + await page.locator(".stage.active", { hasText: "Committing" }).waitFor({ timeout: REGISTRATION_MS }) + const kept = [".track", ".stage:nth-child(1)", ".countdown", ".txs", ".summary", "h1"] + await markNodes(page, kept) + await takeTransitions(page) + await page.locator(".stage.active", { hasText: "Waiting" }).waitFor({ timeout: REGISTRATION_MS }) + expect(await keptNodes(page, kept)).toEqual(kept) + expect(await page.evaluate(() => [...document.querySelectorAll("body > main .stage")].map((s) => s.className))).toEqual(["stage done", "stage active", "stage"]) + await page.locator(".stage.active", { hasText: "Registering" }).waitFor({ timeout: REGISTRATION_MS }) + expect(await keptNodes(page, kept)).toEqual(kept) + expect(await takeTransitions(page)).toEqual([]) + expect(await takeCuts(page)).toEqual([]) +}) +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `npx playwright test e2e/inplace.spec.ts --project=chromium` +Expected: FAIL at the first `keptNodes` (nodes were rebuilt). + +- [ ] **Step 3: Kinds in `draw()`**: `draw(invoice, view, record)` builds the state `{ invoice, view, record }` and calls `screen(null, kindOf(invoice, view, record), state, buildOf(kind))`, where `kindOf` returns the kind strings above using exactly the branch conditions now in `draw()`, `payment()`, `confirming()` (slow when `Date.now() > slowAt`) and `expired()`. Status pages become `status:` kinds with `build` returning the same `status(...)` sections they return today and no `update`. + +- [ ] **Step 4: Registering screen** — rewrite `registering()` as `buildRegistering(state, signal): Built`: + +```ts +function buildRegistering({ invoice, view, record }: OrderState, signal: AbortSignal): Built { + const steps = track() + const clockNode = live(el("b", {})) + const note = live(el("p", {})) + const problem = live(el("div", {})) + const txs = txRows() + const lead = live(el("span", {})) + let current = view + const show = (next: OrderState) => { + current = next.view + const states = stagesOf(next.view) + steps.set(states) + txs.set(next.view, states, refusedOf(next.view, next.record)) + lead.replaceChildren(leadOf(next.view, next.record)) + problem.replaceChildren(...problemOf(next.view, next.record)) + waitFor(next, signal) + } + show({ invoice, view, record }) + onTheSecond(() => waitLeft(current), () => tick(current, record, clockNode, note), signal) + const sections = [pageHead(headingOf(view, record), lead, crumbs("Registration", { fromApp: openedFromApp(view, record), prepaid: !invoice })), + twoCols(el("div", { class: "panel" }, el("div", { class: "panel-sec" }, steps.node, el("div", { class: "countdown" }, clockNode, note), problem), txs.node), + orderCard(labelOf(view, record), view.tld, paidRows(view, invoice)))] + return { sections, update: show } +} +``` + +where `track()` returns `{ node, set(states) }`: it builds the three `.stage` elements once (each with a live `.node` span and live `b` title) and `set` assigns `stage.className = \`stage ${st}\`.trim()`, `node.replaceChildren(st === "done" ? "✓" : st === "failed" ? "✕" : n)` only when the text differs, and the title (`Committing`/`Committed`, `Registering`/`Registered`) only when it differs. `txRows()` returns `{ node, set(view, states, refused) }` with live value and chip cells per row, replacing children only when their text differs. `waitFor` starts `fillWait` the first time the phase is `committed` (once per screen). The reveal retry (`revealing`, `revealFailures`) moves into `tick` unchanged. + +- [ ] **Step 5: Stage transitions in `src/styles.css`** (after `.stage.failed .node`): + +```css +/* a step turning active or done eases to its colours, its node and line changing in place */ +.stage::before, .stage .node { transition: var(--moves); } +``` + +- [ ] **Step 6: Payment and confirming screens** — `buildPayCrypto` keeps references to: the partial-alert slot (`live(el("div", {}))`), amount label, amount, USD line, the three copy fields (each a live value cell), the QR slot (live; replaced only when `paymentUri(...)` differs from the last), the awaiting text (live) and the cancel slot (live). Its `update` recomputes each from the new invoice and replaces only what differs. `buildConfirming` keeps the confirmations line (live) and the slow timer on `signal`. + +- [ ] **Step 7: Run the tests** + +Run: `npx playwright test e2e/inplace.spec.ts e2e/paid.spec.ts e2e/taken.spec.ts e2e/focus.spec.ts --project=chromium` +Expected: all passed + +```json:metadata +{"files": ["src/views/order.ts", "src/styles.css", "e2e/inplace.spec.ts"], "verifyCommand": "npx playwright test e2e/inplace.spec.ts e2e/paid.spec.ts e2e/taken.spec.ts e2e/focus.spec.ts --project=chromium", "acceptanceCriteria": ["order kinds as listed", "registering update keeps nodes and changes only differing ones", "pay-crypto update in place", "confirming update in place", "stage colours by CSS transition", "e2e registering changes in place passes", "e2e new screen is one page change"], "modelTier": "frontier"} +``` + +--- + +### Task 3: Checkout and name-code screens change in place + +**Goal:** Applying or removing a code, choosing a method, a waiting order and link previews change the checkout's nodes in place; the name-code page's states do too. + +**Files:** +- Modify: `src/views/checkout.ts` (`checkoutPage`, `codeEntry`, `ownerCodePage`, `settleWaiting`, `showOpenOrder`; remove `carry(linksSec)`, `linksDraw`) +- Modify: `src/views/codes.ts:169` (calls `refresh()` instead of `checkoutPage()` for the checkout) +- Test: `e2e/inplace.spec.ts` + +**Acceptance Criteria:** +- [ ] Kind `checkout`: `update(c)` crosses over the rows (`Paying with` ↔ `Code`), the total, the code area, the link slot (`I have a code` ↔ `Remove the code`), the crumbs (`prepaid` changes the steps: the step bar crosses over as one live part) and the Continue button state; the links section (fields being typed, previews loading) is the same node throughout. +- [ ] Kind `owner-code`: the "I have saved my code" check and Register button keep their nodes when the order is refused for its method and drawn again. +- [ ] Error pages (`price-changed`, `too-many`, `failed`) are `status:` kinds. +- [ ] e2e `checkout changes in place`: mark `.summary`, `.panel`, `.link-field`, `h1`; apply a code, remove it; all kept, no transition, no cut, rows read `Code` then `Paying with`. +- [ ] `src/screens/checkout.ts`: state, events (code applied/removed, method chosen, waiting order seen/settled, hold started/ended, order refused, price changed), pure `reduce`, `screenOf` (`checkout`, `owner-code`, `status:`), `moves`; the views dispatch events and render from state; `test/screens-checkout.test.ts` covers every `screenOf` branch and every `moves` row. + +**Verify:** `npx playwright test e2e/inplace.spec.ts e2e/checkout.spec.ts e2e/codes.spec.ts --project=chromium` → all passed + +**Steps:** + +- [ ] **Step 1: Failing e2e test** + +```ts +test("the checkout changes in place: a code applied and removed keeps the card, panel and link fields", async ({ page, request }) => { + const { code } = await control(request, "code", { minLength: 6 }) as { code: string } + await page.goto(`/#/?name=${uniqueLabel("inco")}`) + await page.getByRole("button", { name: "Register" }).click() + await page.getByRole("heading", { name: "Check your order" }).waitFor() + const kept = [".summary", ".panel", ".link-field", "h1"] + await markNodes(page, kept) + await takeTransitions(page) + await page.getByText("I have a code").click() + await page.locator(".code-entry input").fill(code) + await expect(page.getByText("Remove the code")).toBeVisible() + expect(await page.locator(".summary .r").allInnerTexts()).toContainEqual(expect.stringContaining("Code")) + await page.getByText("Remove the code").click() + await expect(page.getByText("I have a code")).toBeVisible() + expect(await keptNodes(page, kept)).toEqual(kept) + expect(await takeTransitions(page)).toEqual([]) + expect(await takeCuts(page)).toEqual([]) +}) +``` + +- [ ] **Step 2: Run, expect FAIL** (`npx playwright test e2e/inplace.spec.ts -g checkout --project=chromium`). + +- [ ] **Step 3: Restructure `checkoutPage()`** into state (`session.checkout()`, waiting record, held flag, applied code) → `screen("search", "checkout", state, buildCheckout)`. `buildCheckout` creates the links section, method radios, card rows (each value a live cell keyed by row label), total (live), code area (live), link slot (live), the crumbs slot (live, `crumbs("Details", ...)` replaced only when `prepaid` changes) and Continue; `update` recomputes rows/total/slots and replaces only differing ones. The code entry's success path calls `refresh()`; removing the code calls `refresh()`. + +- [ ] **Step 4: Owner-code page** as kind `owner-code` with `update` that keeps the check and button and replaces the hold note (live). + +- [ ] **Step 5: Run tests** + +Run: `npx playwright test e2e/inplace.spec.ts e2e/checkout.spec.ts e2e/codes.spec.ts e2e/focus.spec.ts --project=chromium` +Expected: all passed + +```json:metadata +{"files": ["src/views/checkout.ts", "src/views/codes.ts", "e2e/inplace.spec.ts"], "verifyCommand": "npx playwright test e2e/inplace.spec.ts e2e/checkout.spec.ts e2e/codes.spec.ts e2e/focus.spec.ts --project=chromium", "acceptanceCriteria": ["checkout update in place", "owner-code update in place", "error pages are status kinds", "e2e checkout changes in place passes"], "modelTier": "frontier"} +``` + +--- + +### Task 4: Your names, edit and restore screens change in place + +**Goal:** The Your names list syncs its rows by key (kept, changed, added, removed) instead of being rebuilt; the edit and restore pages' states change in place. + +**Files:** +- Modify: `src/motion.ts` (add `syncList`) +- Modify: `src/views/names.ts` (`historyPage`, `confirmForget`, `pendingRow`, `orderRow`, `codeRow`, `editPage`, `restorePage`) +- Test: `e2e/inplace.spec.ts` + +**Acceptance Criteria:** +- [ ] `syncList(part, rows)` in `motion.ts`: for each new row with `data-key`, an existing child with the same key is kept when `isEqualNode` holds, else replaced by `removeFrom(part, old, row)` (fades across); keys only in the old list leave by `removeFrom(part, old)`; keys only in the new list come in by `addTo(part, row, nextSibling)`; order follows `rows`. +- [ ] Kind `names`: `update` calls `syncList` for the Names and Codes lists and crosses over the empty-list notes and the forget area; rows that did not change keep their nodes; the entering rise runs only when the screen is built. +- [ ] Kinds `edit`, `edit-unreadable`, `restore`: retry and refusal states cross over in place; `editPage`'s `setTimeout` retry uses the screen signal. +- [ ] e2e `Your names changes in place`: with two orders, mark both rows; add a code in another tab (`storage` event); both rows kept, the code row added, no transition, no cut. +- [ ] `src/screens/names.ts`: state (records, codes, pending, forget question, edit and restore states), events (storage changed, forget asked/kept/confirmed, edit answer, restore answer), pure `reduce`, `screenOf`, `moves`; `test/screens-names.test.ts` covers every branch and row. + +**Verify:** `npx playwright test e2e/inplace.spec.ts e2e/names.spec.ts e2e/history.spec.ts --project=chromium` → all passed + +**Steps:** + +- [ ] **Step 1: Failing e2e test** + +```ts +test("Your names changes in place: rows another tab leaves alone keep their nodes", async ({ page, context, request }) => { + const codeOf = async () => (await control(request, "code", { minLength: 6 }) as { code: string }).code + const [first, second] = [await codeOf(), await codeOf()] + const store = (code: string) => localStorage.setItem(`simplex-names:code:${code}`, JSON.stringify({ code, minLength: 6, years: 2, addedAt: new Date().toISOString() })) + await page.goto("/#/names") + await page.evaluate(store, first) + await page.reload() + await page.locator(".row-item").first().waitFor() + await expect(page.locator(".row-item.entering")).toHaveCount(0) + await markNodes(page, [".row-item", "h1"]) + await takeTransitions(page) + const other = await context.newPage() + await other.goto("/#/names") + await other.evaluate(store, second) + await expect(page.locator(".row-item")).toHaveCount(2) + expect(await keptNodes(page, [".row-item", "h1"])).toEqual([".row-item", "h1"]) + expect(await takeTransitions(page)).toEqual([]) + expect(await takeCuts(page)).toEqual([]) +}) +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: `syncList` in `src/motion.ts`** (after `addTo`): + +```ts +// A list drawn again from its rows, each keyed by data-key: a row alike stays as it is, a changed one crosses over in +// its place, one gone fades as what follows slides up, and a new one fades in as what follows slides aside. +export function syncList(part: HTMLElement, rows: HTMLElement[]): void { + const byKey = new Map([...part.children].filter((n) => !n.classList.contains("outgoing")).map((n) => [(n as HTMLElement).dataset.key, n as HTMLElement])) + const wanted = new Set(rows.map((r) => r.dataset.key)) + for (const [key, node] of byKey) if (!wanted.has(key)) removeFrom(part, node) + let before: Element | null = part.firstElementChild + for (const row of rows) { + while (before?.classList.contains("outgoing")) before = before.nextElementSibling + const old = byKey.get(row.dataset.key) + if (!old) addTo(part, row, before) + else if (old.isEqualNode(row)) { + if (old !== before) part.insertBefore(old, before) + before = old.nextElementSibling + continue + } else removeFrom(part, old, row) + before = row.nextElementSibling + } +} +``` + +- [ ] **Step 4: Your names screen** — `historyPage()` computes `{ orders, codes, pending, canPersist }` and calls `screen("names", "names", state, buildNames)`; `buildNames` keeps the two list parts (live) and the forget area; `update` builds rows (`pendingRow`, `orderRow`, `codeRow`, each with `data-key`) and calls `syncList`. The storage listener calls `refresh()` (batched in one frame as now). + +- [ ] **Step 5: Edit and restore** — `editPage` state `{ registrationId, view | error }` → kinds; `update` crosses over the result line and fields' notes; the retry `setTimeout` is cleared on `signal` abort. + +- [ ] **Step 6: Run tests** + +Run: `npx playwright test e2e/inplace.spec.ts e2e/names.spec.ts e2e/history.spec.ts e2e/focus.spec.ts --project=chromium` +Expected: all passed + +```json:metadata +{"files": ["src/motion.ts", "src/views/names.ts", "e2e/inplace.spec.ts"], "verifyCommand": "npx playwright test e2e/inplace.spec.ts e2e/names.spec.ts e2e/history.spec.ts e2e/focus.spec.ts --project=chromium", "acceptanceCriteria": ["syncList keeps, crosses, adds and removes rows by key", "names update syncs lists in place", "edit and restore states in place", "e2e Your names changes in place passes"], "modelTier": "frontier"} +``` + +--- + +### Task 5: Search screen changes in place + +**Goal:** A code applied or removed, a retarget banner corrected and a redraw keep the search box, typed name and results nodes. + +**Files:** +- Modify: `src/views/search.ts` (`searchPage`, `removeCode`, `checkRetarget`, the applied-code note; remove `shownInput`, `shownResults`, `carry(results)`) +- Modify: `src/views/codes.ts:109,179` (keep `refresh()`) +- Test: `e2e/inplace.spec.ts` + +**Acceptance Criteria:** +- [ ] Kind `search:`: `update` crosses over the banner slot and the applied-code note; the input, its typed value, the search button and the results part keep their nodes; a new `name` parameter that differs from the searched one runs the search in place. +- [ ] Kind `status:bad-app-link` for the damaged wallet link. +- [ ] e2e `search changes in place`: type a name, apply a code from the popup; `.search`, `.search input`, `.results`, `h1` kept, input value unchanged, no transition, no cut. +- [ ] `src/screens/search.ts`: state (name, typed, answer, applied code, retarget, app wallet, pause), events (typed, searched, answered, code applied/removed, retarget read), pure `reduce`, `screenOf`, `moves`; `test/screens-search.test.ts` covers every branch and row. + +**Verify:** `npx playwright test e2e/inplace.spec.ts e2e/search.spec.ts e2e/codes.spec.ts --project=chromium` → all passed + +**Steps:** + +- [ ] **Step 1: Failing e2e test** + +```ts +test("the search changes in place: a code applied from the popup keeps the box, the typed name and the results", async ({ page, request }) => { + const { code } = await control(request, "code", { minLength: 6 }) as { code: string } + await page.goto("/") + await page.getByRole("textbox", { name: "Name", exact: true }).fill("typedname") + const kept = [".search", ".search input", ".results", "h1"] + await markNodes(page, kept) + await takeTransitions(page) + await page.getByRole("button", { name: "Check a code" }).click() + await page.getByRole("textbox", { name: "Code", exact: true }).fill(code) + await page.getByRole("button", { name: "Use this code" }).click() + await expect(page.getByText(/^Code applied/)).toBeVisible() + expect(await keptNodes(page, kept)).toEqual(kept) + await expect(page.getByRole("textbox", { name: "Name", exact: true })).toHaveValue("typedname") + expect(await takeTransitions(page)).toEqual([]) + expect(await takeCuts(page)).toEqual([]) +}) +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Restructure `searchPage()`** into state `{ name, fromApp, retargetId, applied }` → `screen("search", kind, state, buildSearch)`; `buildSearch` creates hero, input, box, results (live), banner slot (live), applied-note slot (live), bento; `update` updates the banner and applied note, and runs `submit()` when `state.name` differs from the last searched name. `removeCode` calls `refresh()` then focuses the same input (it survives). + +- [ ] **Step 4: Run tests** + +Run: `npx playwright test e2e/inplace.spec.ts e2e/search.spec.ts e2e/codes.spec.ts e2e/focus.spec.ts --project=chromium` +Expected: all passed + +```json:metadata +{"files": ["src/views/search.ts", "src/views/codes.ts", "e2e/inplace.spec.ts"], "verifyCommand": "npx playwright test e2e/inplace.spec.ts e2e/search.spec.ts e2e/codes.spec.ts e2e/focus.spec.ts --project=chromium", "acceptanceCriteria": ["search update in place keeps box, input, results", "bad app link is a status kind", "e2e search changes in place passes"], "modelTier": "frontier"} +``` + +--- + +### Task 6: Page changes with static names; delete the comparison machinery + +**Goal:** `page()` shows a new screen with one transition and no comparison; every runtime pairing path and its CSS is deleted. + +**Files:** +- Modify: `src/views/shell.ts` (`page()`: no `compare`, no identical-redraw path, no `carry`; change type `forward`/`back`/`screen`/`fill`) +- Modify: `src/motion.ts` (delete `compare`, `Match`, `Comparison`, `signatures`, `keysOf`, `slotsOf`, `pairsOf`, `freshOf`, `paints`, `leaf`, `empty`, `carry`, `placeCarried`, stand-ins, `MOST_MORPHED`, `kindOf`, `anchorOf`, `WHOLE`, `MOVING`, `changeOnly`, `leaveOver`'s "over" mode, the pair-naming loop in `begin`, `unname`'s pair names; keep `transition`, `transitionsIdle`, `dropWaitingPage`, `finishMotion`, `settled`, transient names, `swapPage` with `"at-once" | "through"`) +- Modify: `src/styles.css` (delete `.part`, `.box`, `.swap`, `.gone`, `.fresh`, `.step`, `.tick`, `.name` pair rules and `vt-moving`; add page rules; static names `order-card`) +- Modify: `src/views/order.ts`, `src/views/checkout.ts` (`.summary` gets `view-transition-name: order-card` via CSS only), remove `data-whole` +- Test: `e2e/motion.spec.ts`, `e2e/cuts.ts` + +**Acceptance Criteria:** +- [ ] `rg -n "compare\(|pairsOf|freshOf|carry\(|data-whole|part-\$\{|gone-|fresh-|vt-moving" src` returns nothing. +- [ ] A page change in Chromium/WebKit runs one view transition whose groups are a subset of `root, page, page-top, page-foot, offline-note, step-ring, step-line-*, step-done-*, order-card, order-name`. +- [ ] `page` group: old fades out in `--axis-out-ms`, new fades in over `--axis-in-ms` after it, both sliding `--axis` the way the reader goes for `forward`/`back`; `screen` and `fill` without the slide. +- [ ] Firefox: page changes use `swapPage(old, main, ease, "through")`; no `.page-leaving` remains after `PAGE_OUT` time. +- [ ] `e2e/motion.spec.ts` updated: tests asserting `part-`/`gone-`/`fresh-` groups now assert the static groups above; in-place assertions moved to `e2e/inplace.spec.ts`. +- [ ] Each preserved animation has an e2e check in `e2e/motion.spec.ts` (groups and pseudo animations from `takeTransitions` in Chromium/WebKit): search → checkout has `order-name`; checkout → name code has `order-card`, `page-title`, `crumbs`, `step-ring`, `step-line-name-code` (`fill-across`), `step-done-details` (`tick-in`); forward/back page changes slide `page` (`axis-out`/`axis-in`); the theme change is one root cross-fade. +- [ ] `motion.ts` `VIEW_TRANSITIONS` admits Gecko from major version 158 (`FIREFOX_FIXED = 158`, read from the user agent's `Firefox/`), with the bug cited in one short comment; older Gecko and any browser without transition types take the fallback. +- [ ] Firefox before 158 (no view transitions) keeps the shared parts' motion by FLIP: on a page change, each element of `order-name`, `order-card`, `crumbs`, `page-title`, `page-panel` present on both pages is hidden in the leaving page and the new one moves (translate, and height for `order-card`/`page-panel`) from the old one's place over `--axis-ms`; the rest fades through. e2e in Firefox: the name's new element starts at the result's place (its first animation's `translate` keyframe equals the old offset) and the card does not fade. +- [ ] `src/screens/routes.ts`: moves between views (search, checkout, name code, order, Your names, restore, edit, trademarks) with their motion; `page()` takes the motion from `step()`/`routes.ts` instead of `wayTo()`'s crumb and nav heuristics (browser Back/Forward still give `back`/`forward`); `test/screens-routes.test.ts` covers every row. +- [ ] `npx tsc --noEmit -p .` clean; `npm test` passes (77 plus the new screen tests). + +**Verify:** `npx tsc --noEmit -p . && npm test && npx playwright test e2e/motion.spec.ts e2e/inplace.spec.ts --project=chromium` → all passed + +**Steps:** + +- [ ] **Step 1: Update `e2e/motion.spec.ts` expectations first** — in "a page opened slides the way the reader goes…", replace the group assertion with + +```ts + expect(opened.groups.filter((g) => !/^step-(line|done)-/.test(g)).sort()).toEqual(["page", "page-foot", "page-top", "root"]) +``` + +and delete the `redraws` part (in-place redraws no longer transition; covered in `inplace.spec.ts`). In "the next step of an order…", keep the `step-ring` / `step-line-name-code` / `step-done-details` assertions, replace the root and `gone-` assertions with + +```ts + expect(next.groups).toContain("page") + expect(next.groups).toContain("order-card") +``` + +- [ ] **Step 2: Run, expect FAIL** (`npx playwright test e2e/motion.spec.ts --project=chromium`). + +- [ ] **Step 3: CSS** — replace the view-transition section between `/* Every change of the page is compared…` and the theme rules with: + +```css +/* A page change moves only the shared parts, each by its name: the header and footer stay, the step bar's ring goes over, + the order's card and name go to their new places; the rest of the page fades through, the way the reader goes. */ +.summary { view-transition-name: order-card; } +body > main:not(.pending) { view-transition-name: page; } +::view-transition-group(page), ::view-transition-group(order-card), ::view-transition-group(.name), ::view-transition-group(step-ring), ::view-transition-group(page-foot) { animation-duration: var(--axis-ms); animation-timing-function: var(--emphasized); } +::view-transition-old(page) { animation: var(--axis-out-ms) linear both fade-out; } +::view-transition-new(page) { animation: var(--axis-in-ms) var(--emphasized) var(--axis-out-ms) both appear; } +html:active-view-transition-type(forward, back)::view-transition-old(page) { animation: var(--axis-ms) var(--emphasized) both axis-out, var(--axis-out-ms) linear both fade-out; } +html:active-view-transition-type(forward, back)::view-transition-new(page) { animation: var(--axis-ms) var(--emphasized) both axis-in, var(--axis-in-ms) var(--emphasized) var(--axis-out-ms) both appear; } +``` + +keeping the existing `page-top`/`page-foot`/`offline-note` rules (types now `forward, back, screen, fill`), the `step-*` rules, `.order-name` rules and keyframes. + +- [ ] **Step 4: `page()`** — delete `compare`, `filled`/`arrived`/`unseen` comparison branches that depend on it, the `!comparison.differs` path; `change` is `samePage ? (filled ? "fill" : "screen") : wayTo(...)`; `transition(change, swap, () => finishMotion(old))`; `swap` calls `swapPage(old, main, !scrollTo, transitions() ? "at-once" : "through")`. + +- [ ] **Step 5: `motion.ts`** — delete the listed functions and types; `transition(change, update, prepare)` drops the `comparison` parameter; `begin` becomes: `prepare(); unname(); start the view transition with the update; on finish unname and run the queue`. + +- [ ] **Step 6: Run tests** + +Run: `npx tsc --noEmit -p . && npm test && npx playwright test e2e/motion.spec.ts e2e/inplace.spec.ts --project=chromium` +Expected: no type errors, 77 passed, all passed + +```json:metadata +{"files": ["src/views/shell.ts", "src/motion.ts", "src/styles.css", "src/views/order.ts", "src/views/checkout.ts", "e2e/motion.spec.ts", "e2e/cuts.ts"], "verifyCommand": "npx tsc --noEmit -p . && npm test && npx playwright test e2e/motion.spec.ts e2e/inplace.spec.ts --project=chromium", "acceptanceCriteria": ["no comparison code remains (rg empty)", "page change groups are static names only", "page group fade-through with axis slide", "Firefox fades through", "motion.spec updated", "tsc clean, unit tests pass"], "modelTier": "frontier"} +``` + +--- + +### Task 7: Verify across browsers, recordings and review + +**Goal:** The whole suite passes in three browsers, frame recordings show no flicker, and review subagents report no issues. + +> **USER-ORDERED GATE — NON-SKIPPABLE.** This task was requested by the user in the current conversation. It MUST NOT be closed by walking around it, by declaring it "verified inline", or by substituting a cheaper check. Close only after every item in `acceptanceCriteria` has been re-validated independently, with output captured. + +**Files:** +- Test: whole `e2e/`, scratchpad recordings (`tools/journey/plain-journey.mjs`, `tools/static-spikes.mjs`) + +**Acceptance Criteria:** +- [ ] `npx playwright test --project=chromium`, `--project=firefox`, `--project=webkit` each report 0 failed (2 offline tests skipped without `BASE_URL`). +- [ ] Firefox and Chromium recordings of the `code-order` and `checkout-edits` journeys: `static-spikes.mjs` reports 0 one-frame spikes in header and ground; frame sheets of each in-page change show only the changed parts moving. +- [ ] A review round of subagents (code, motion with frame recordings in Chromium and Firefox, design desktop and phone) reports no issues; findings are fixed and the round repeated until it does. + +**Verify:** `for b in chromium firefox webkit; do npx playwright test --project=$b; done` → `0 failed` each + +**Steps:** + +- [ ] **Step 1: Full e2e in each browser** (one at a time; Firefox and WebKit with the scratchpad env scripts). +- [ ] **Step 2: Recordings** — `node plain-journey.mjs firefox code-order ` and `chromium`, then `node static-spikes.mjs `; cut and read frame sheets of each change. +- [ ] **Step 3: Review round** — dispatch reviewers (no edits to the repos, scratchpad only, no polling waits), fix findings, repeat until none. + +```json:metadata +{"files": [], "verifyCommand": "for b in chromium firefox webkit; do npx playwright test --project=$b; done", "acceptanceCriteria": ["0 failed in chromium, firefox, webkit", "0 one-frame spikes in recordings; only changed parts move", "review subagents report no issues"], "modelTier": "frontier", "userGate": true, "tags": ["user-gate"]} +``` + +--- + +### Task 8: The state machine core + +**Goal:** A pure `src/screens/machine.ts` with `Machine`, `Motion` and `step()`, unit-tested, for every view to run its state through. + +**Files:** +- Create: `src/screens/machine.ts` +- Test: `test/machine.test.ts` + +**Acceptance Criteria:** +- [ ] `step(machine, state, shown, event, warn)` returns `{ state, screen, motion, expected }`: `state = machine.reduce(state, event)`, `screen = machine.screenOf(state)`; `shown === null` → motion `"screen"`, expected; `screen === shown` → `"in-place"`, expected; a move listed in `machine.moves[shown][screen]` → that motion, expected; otherwise `warn("[screens] : unexpected move → ")`, motion `"screen"`, `expected: false`. +- [ ] No DOM, clock, storage or network use in `src/screens/` (`rg -n "document|window|Date\.now|localStorage|fetch" src/screens` empty). +- [ ] vitest covers the four branches with a small test machine (counter-like states and two screens). + +**Verify:** `npx tsc --noEmit -p . && npx vitest run test/machine.test.ts` → passed + +**Steps:** + +- [ ] **Step 1: Failing test** `test/machine.test.ts`: + +```ts +import { describe, expect, it, vi } from "vitest" +import { type Machine, step } from "../src/screens/machine.ts" + +type S = { n: number } +type E = { kind: "add"; by: number } +type K = "low" | "high" | "odd" +const machine: Machine = { + name: "test", + reduce: (s, e) => ({ n: s.n + e.by }), + screenOf: (s) => (s.n % 2 ? "odd" : s.n < 10 ? "low" : "high"), + moves: { low: { high: "forward" }, high: { low: "back" }, odd: {} }, +} + +describe("step", () => { + it("draws the first screen as a page change", () => { + expect(step(machine, { n: 0 }, null, { kind: "add", by: 2 }, vi.fn())).toEqual({ state: { n: 2 }, screen: "low", motion: "screen", expected: true }) + }) + it("keeps a screen of the same kind in place", () => { + expect(step(machine, { n: 2 }, "low", { kind: "add", by: 2 }, vi.fn()).motion).toBe("in-place") + }) + it("takes a listed move's motion", () => { + expect(step(machine, { n: 8 }, "low", { kind: "add", by: 2 }, vi.fn())).toMatchObject({ screen: "high", motion: "forward", expected: true }) + }) + it("logs a move missing from the table and still shows it as a page change", () => { + const warn = vi.fn() + expect(step(machine, { n: 2 }, "low", { kind: "add", by: 1 }, warn)).toMatchObject({ screen: "odd", motion: "screen", expected: false }) + expect(warn).toHaveBeenCalledWith("[screens] test: unexpected move low → odd") + }) +}) +``` + +- [ ] **Step 2: Run, expect FAIL** (`npx vitest run test/machine.test.ts`: module not found). + +- [ ] **Step 3: Implement** `src/screens/machine.ts`: + +```ts +// A view's behaviour as data: its state changes only by events through reduce, the screen shown follows from the state +// alone, and each move from one screen to another has its motion; a move the table lacks is logged, and still shown. +export type Motion = "in-place" | "forward" | "back" | "screen" +export interface Machine { + name: string + reduce: (state: S, event: E) => S + screenOf: (state: S) => K + moves: { readonly [F in K]: Partial>> } +} +export interface Step { state: S; screen: K; motion: Motion; expected: boolean } + +export function step(machine: Machine, state: S, shown: K | null, event: E, warn: (message: string) => void = console.warn): Step { + const next = machine.reduce(state, event) + const screen = machine.screenOf(next) + if (shown === null) return { state: next, screen, motion: "screen", expected: true } + if (screen === shown) return { state: next, screen, motion: "in-place", expected: true } + const motion = machine.moves[shown][screen] + if (motion) return { state: next, screen, motion, expected: true } + warn(`[screens] ${machine.name}: unexpected move ${shown} → ${screen}`) + return { state: next, screen, motion: "screen", expected: false } +} +``` + +- [ ] **Step 4: Run, expect PASS**; `npx tsc --noEmit -p .` clean. + +```json:metadata +{"files": ["src/screens/machine.ts", "test/machine.test.ts"], "verifyCommand": "npx tsc --noEmit -p . && npx vitest run test/machine.test.ts", "acceptanceCriteria": ["step() covers first draw, in place, listed move, unexpected move with warn", "src/screens has no DOM/clock/storage/network use", "vitest covers the four branches"], "modelTier": "mechanical"} +``` + +--- + +### Task 9: The order page as a state machine + +**Goal:** The order page's state lives in one object changed by events through a pure reducer in `src/screens/order.ts`; its screen comes from a pure `screenOf`, its moves from a table, all unit-tested; `order.ts` becomes the controller (polls, timers, API calls, storage) that dispatches events and renders. + +**Files:** +- Create: `src/screens/order.ts` +- Modify: `src/views/order.ts` (follow loop, timers and actions dispatch events; `kindOf` replaced by `screenOf`; screens render from state) +- Test: `test/screens-order.test.ts` + +**Acceptance Criteria:** +- [ ] `OrderState` holds everything the screens read: invoice, registration view, record, card submitted time, reveal attempts and refusals, action notes, hold, `now` (service time of the last event). +- [ ] Events: `answered` (invoice and/or view with service time), `record-changed`, `tick` (service time), `card-submitted`, `card-failed`, `reveal-sent`, `reveal-refused`, `reveal-failed`, `cancel-started`, `cancel-answered`, `cancel-failed`, `reorder-started`, `reorder-failed`, `hold-started`, `hold-ended`; reducer pure (no clock, storage, network, DOM). +- [ ] `screenOf` returns every kind of Task 2 and nothing else; `moves` lists the order lifecycle (pay-crypto → confirming → registering → registered | taken | failed; pay-crypto → expired | still-open | expired-cancelled; pay-card → confirming | card-failed | slow; confirming → slow | registering; registering → registered | taken | failed | not-on-device; etc.) with motion `screen`. +- [ ] `test/screens-order.test.ts`: one test per `screenOf` branch (state → kind), one per `moves` row (a state of the from-screen and an event leading to the to-screen: `step()` returns the row's motion, `expected: true`), the reducer's handling of each event, and a race test: two `answered` events applied in either order (older service time second) end in the same state (an older answer never replaces a newer one). +- [ ] The e2e of Task 2 still pass. + +**Verify:** `npx tsc --noEmit -p . && npm test && npx playwright test e2e/inplace.spec.ts e2e/paid.spec.ts e2e/taken.spec.ts --project=chromium` → all passed + +```json:metadata +{"files": ["src/screens/order.ts", "src/views/order.ts", "test/screens-order.test.ts"], "verifyCommand": "npx tsc --noEmit -p . && npm test && npx playwright test e2e/inplace.spec.ts e2e/paid.spec.ts e2e/taken.spec.ts --project=chromium", "acceptanceCriteria": ["OrderState holds all screen inputs", "events as listed, reducer pure", "screenOf returns Task 2 kinds; moves list the lifecycle", "vitest covers every branch, row, event and the answer race", "Task 2 e2e still pass"], "modelTier": "frontier"} +``` + +--- + +## Self-review + +- Spec coverage: in-place updates per view (Tasks 2–5), page transitions with static names and machinery removal (Task 6), verification (Task 7), primitive (Task 1). +- Types: `Built`, `screen()`, `syncList()`, `every(ms, f, signal)`, `onTheSecond(left, f, signal)` are defined in Tasks 1 and 4 and used with the same signatures later. +- Order: Task 6 depends on Tasks 2–5 (no view may still rely on `compare`). diff --git a/plans/names-codes/2026-10-17-webapp-in-place-updates.md.tasks.json b/plans/names-codes/2026-10-17-webapp-in-place-updates.md.tasks.json new file mode 100644 index 0000000000..c59ed5102d --- /dev/null +++ b/plans/names-codes/2026-10-17-webapp-in-place-updates.md.tasks.json @@ -0,0 +1,92 @@ +{ + "planPath": "plans/names-codes/2026-10-17-webapp-in-place-updates.md", + "tasks": [ + { + "id": 0, + "subject": "Task 1: The screen primitive", + "status": "completed", + "description": "**Goal:** `screen()` in `shell.ts` builds a screen once and routes later states of the same kind to its `update`, with a per-screen `AbortSignal` for its timers.\n\n**Files:**\n- Modify: `src/views/shell.ts` (add `screen`, `Built`, the shown-screen state; reset it in `nextRoute` when the route is not a redraw)\n- Modify: `src/views/order.ts:27-38` (`every()` takes a signal instead of pushing to `cleanups`)\n- Test: `e2e/inplace.spec.ts` (new; first test drives Task 2, so this task's test is the harness helper)\n\n**Acceptance Criteria:**\n- [ ] `screen(active, kind, state, build)` calls `build(state, signal)` and `page()` when no screen of `kind` is shown on this route family; otherwise calls the shown screen's `update(state)` and nothing else.\n- [ ] A screen without `update` is rebuilt (`page()`) when its kind is called again with a state whose `JSON.stringify` differs from the shown one; with an equal state it is left alone.\n- [ ] Leaving the route (`nextRoute` without `redrawing`) aborts the shown screen's signal and forgets it; a `refresh()` keeps it.\n- [ ] `e2e/inplace.spec.ts` exists with helpers `markNodes(page, selectors)` (sets `__kept = true` on each matched node) and `keptNodes(page, selectors)` (returns which still carry it), and `takeTransitions` from `e2e/cuts.ts`.\n- [ ] `npx tsc --noEmit -p .` clean.\n\n**Verify:** `npx tsc --noEmit -p . && npm test` \u2192 no errors, `77 passed`\n\n**Steps:**\n\n- [ ] **Step 1: Add the primitive to `src/views/shell.ts`** (after `refresh()`):\n\n```ts\n// A screen of a view is built once and then told each new state of what it shows, changing only what differs; a state\n// of another kind is another screen, shown as a page change. What a screen runs, as a countdown, stops with it.\nexport interface Built { sections: Node[]; update?: (state: S) => void }\ninterface Shown { kind: string; state: string; update?: (state: unknown) => void; stop: AbortController }\nlet shown: Shown | null = null\n\nexport function screen(active: Active, kind: string, state: S, build: (state: S, signal: AbortSignal) => Built): void {\n const same = shown !== null && shown.kind === kind && isRedraw() && document.querySelector(SHOWN_PAGE) !== null\n if (same && shown!.update) return shown!.update(state)\n if (same && shown!.state === JSON.stringify(state)) return\n shown?.stop.abort()\n const stop = new AbortController()\n const built = build(state, stop.signal)\n shown = { kind, state: JSON.stringify(state), update: built.update as Shown[\"update\"], stop }\n page(active, built.sections)\n}\n```\n\nIn `nextRoute()`, where `focus.drop()` runs for a new page, also run:\n\n```ts\n shown?.stop.abort()\n shown = null\n```\n\n`state` passed to screens must be JSON-serialisable data (API views, records, flags), never DOM nodes or functions.\n\n- [ ] **Step 2: Signal-owned timers in `src/views/order.ts`**: replace `cleanups`/`cleanUp`/`every` with\n\n```ts\nfunction every(ms: number, f: () => void, signal: AbortSignal): void {\n const id = setInterval(f, ms)\n signal.addEventListener(\"abort\", () => clearInterval(id), { once: true })\n}\n```\n\nand give `onTheSecond(left, f, signal)` the same signal (clear its timeout on abort). `cleanups.push(card.destroy)` becomes `signal.addEventListener(\"abort\", card.destroy, { once: true })`.\n\n- [ ] **Step 3: Create `e2e/inplace.spec.ts` helpers**\n\n```ts\nimport { expect, test, type Page } from \"@playwright/test\"\nimport { recordCuts, takeCuts, takeTransitions } from \"./cuts.ts\"\n\ntest.use({ reducedMotion: \"no-preference\" })\ntest.beforeEach(async ({ page }) => { await page.addInitScript(recordCuts) })\n\n// marks the nodes a state change must keep, to tell afterwards which are still the same nodes\nexport const markNodes = (page: Page, selectors: string[]) => page.evaluate((list) => {\n for (const s of list) for (const n of document.querySelectorAll(\"body > main \" + s)) (n as unknown as { __kept: boolean }).__kept = true\n}, selectors)\nexport const keptNodes = (page: Page, selectors: string[]) => page.evaluate((list) => list.filter((s) => {\n const n = document.querySelector(\"body > main \" + s) as unknown as { __kept?: boolean } | null\n return n?.__kept === true\n}), selectors)\n```\n\n- [ ] **Step 4: Type-check and unit tests**\n\nRun: `npx tsc --noEmit -p . && npm test`\nExpected: no type errors, `Tests 77 passed`\n\n```json:metadata\n{\"files\": [\"src/views/shell.ts\", \"src/views/order.ts\", \"e2e/inplace.spec.ts\"], \"verifyCommand\": \"npx tsc --noEmit -p . && npm test\", \"acceptanceCriteria\": [\"screen() builds once and routes same-kind states to update\", \"screen without update rebuilds only on a changed state\", \"leaving the route aborts the screen signal; refresh keeps it\", \"inplace.spec.ts helpers exist\", \"tsc clean\"], \"modelTier\": \"frontier\"}\n```" + }, + { + "id": 1, + "subject": "Task 2: Order page as screens, registering changes in place", + "status": "in_progress", + "description": "**Goal:** The order page's `follow()` loop calls `screen()` with a kind per screen; the registering screen's phases (committing \u2192 committed \u2192 wait \u2192 registering), its countdown, transactions and problem note change in place.\n\n**Files:**\n- Modify: `src/views/order.ts` (`draw`, `payment`, `confirming`, `registering`, `track`, `txRows`, `registered`, `taken`, `codeIssued`, `failed`, status screens; remove `carry(note)` uses)\n- Modify: `src/styles.css` (`.stage` / `.node` state transitions)\n- Test: `e2e/inplace.spec.ts`\n\n**Acceptance Criteria:**\n- [ ] Kinds: `pay-crypto`, `pay-card`, `confirming`, `slow`, `registering`, `registered`, `taken`, `code-issued`, `failed`, `not-on-device`, and `status:` for each status page (`paid-late`, `paid-by-invoice`, `still-open`, `card-failed`, `expired-cancelled`, `expired-received`, `expired`, `price-changed`, `unreadable`, `unknown-link`).\n- [ ] `registering` update, for a new `RegistrationView` of the same order: each `.stage` gets its new state class (`done`/`active`/`\"\"`), its node text crosses over (live), its title crosses over (live); the wait line fill starts on entering `committed`; the countdown clock and note keep their live behaviour; each tx row's value and chip cross over (live); the problem slot crosses over; heading/lead/crumbs/card are untouched unless their text changes (then live).\n- [ ] `pay-crypto` update: partial-payment alert slot, amount label and amount, copy fields, QR (only when the URI changed), \"Waiting for (the rest of) payment\" and the cancel link slot cross over in place; the rate countdown restarts only if `expiresAt` changed.\n- [ ] `confirming` update: the confirmations line crosses over; the slow timer keeps running from the same `cardSubmittedAt`.\n- [ ] Stage colours change by CSS transition (`transition: background-color, border-color, color, box-shadow` on `.stage .node` and the line drawn by `.stage::before`), not by a view transition.\n- [ ] e2e `registering changes in place`: on the code-order journey, nodes `.track`, `.stage:nth-child(1)`, `.countdown`, `.txs`, `.summary`, `h1` are marked during committing; after the commit is mined and again after the wait ends, all are still kept, `takeTransitions` is `[]`, `takeCuts` is `[]`, and the stage classes are `done active \"\"` then `done done active`.\n- [ ] e2e `a new screen is a page change`: registering \u2192 registered runs exactly one transition (or, in Firefox, `.page-leaving` appears once) and the heading takes focus.\n\n**Verify:** `npx playwright test e2e/inplace.spec.ts e2e/paid.spec.ts e2e/taken.spec.ts --project=chromium` \u2192 all passed\n\n**Steps:**\n\n- [ ] **Step 1: Write the failing e2e test** in `e2e/inplace.spec.ts`:\n\n```ts\nimport { control, REGISTRATION_MS, uniqueLabel } from \"./helpers.ts\"\ntest(\"registering changes in place: the steps, countdown and transactions keep their nodes through each phase\", async ({ page, request }) => {\n const { code } = await control(request, \"code\", { minLength: 6 }) as { code: string }\n await page.goto(\"/\")\n await page.getByRole(\"button\", { name: \"Check a code\" }).click()\n await page.getByRole(\"textbox\", { name: \"Code\", exact: true }).fill(code)\n await page.getByRole(\"button\", { name: \"Use this code\" }).click()\n await page.getByRole(\"textbox\", { name: \"Name\", exact: true }).fill(uniqueLabel(\"inplace\"))\n await page.getByRole(\"textbox\", { name: \"Name\", exact: true }).press(\"Enter\")\n await page.getByRole(\"button\", { name: \"Register with code\" }).click()\n await page.getByRole(\"button\", { name: \"Continue\" }).click()\n await page.getByText(\"I have saved my code\").click()\n await page.getByRole(\"button\", { name: \"Register\", exact: true }).click()\n await page.locator(\".stage.active\", { hasText: \"Committing\" }).waitFor({ timeout: REGISTRATION_MS })\n const kept = [\".track\", \".stage:nth-child(1)\", \".countdown\", \".txs\", \".summary\", \"h1\"]\n await markNodes(page, kept)\n await takeTransitions(page)\n await page.locator(\".stage.active\", { hasText: \"Waiting\" }).waitFor({ timeout: REGISTRATION_MS })\n expect(await keptNodes(page, kept)).toEqual(kept)\n expect(await page.evaluate(() => [...document.querySelectorAll(\"body > main .stage\")].map((s) => s.className))).toEqual([\"stage done\", \"stage active\", \"stage\"])\n await page.locator(\".stage.active\", { hasText: \"Registering\" }).waitFor({ timeout: REGISTRATION_MS })\n expect(await keptNodes(page, kept)).toEqual(kept)\n expect(await takeTransitions(page)).toEqual([])\n expect(await takeCuts(page)).toEqual([])\n})\n```\n\n- [ ] **Step 2: Run it to verify it fails**\n\nRun: `npx playwright test e2e/inplace.spec.ts --project=chromium`\nExpected: FAIL at the first `keptNodes` (nodes were rebuilt).\n\n- [ ] **Step 3: Kinds in `draw()`**: `draw(invoice, view, record)` builds the state `{ invoice, view, record }` and calls `screen(null, kindOf(invoice, view, record), state, buildOf(kind))`, where `kindOf` returns the kind strings above using exactly the branch conditions now in `draw()`, `payment()`, `confirming()` (slow when `Date.now() > slowAt`) and `expired()`. Status pages become `status:` kinds with `build` returning the same `status(...)` sections they return today and no `update`.\n\n- [ ] **Step 4: Registering screen** \u2014 rewrite `registering()` as `buildRegistering(state, signal): Built`:\n\n```ts\nfunction buildRegistering({ invoice, view, record }: OrderState, signal: AbortSignal): Built {\n const steps = track()\n const clockNode = live(el(\"b\", {}))\n const note = live(el(\"p\", {}))\n const problem = live(el(\"div\", {}))\n const txs = txRows()\n const lead = live(el(\"span\", {}))\n let current = view\n const show = (next: OrderState) => {\n current = next.view\n const states = stagesOf(next.view)\n steps.set(states)\n txs.set(next.view, states, refusedOf(next.view, next.record))\n lead.replaceChildren(leadOf(next.view, next.record))\n problem.replaceChildren(...problemOf(next.view, next.record))\n waitFor(next, signal)\n }\n show({ invoice, view, record })\n onTheSecond(() => waitLeft(current), () => tick(current, record, clockNode, note), signal)\n const sections = [pageHead(headingOf(view, record), lead, crumbs(\"Registration\", { fromApp: openedFromApp(view, record), prepaid: !invoice })),\n twoCols(el(\"div\", { class: \"panel\" }, el(\"div\", { class: \"panel-sec\" }, steps.node, el(\"div\", { class: \"countdown\" }, clockNode, note), problem), txs.node),\n orderCard(labelOf(view, record), view.tld, paidRows(view, invoice)))]\n return { sections, update: show }\n}\n```\n\nwhere `track()` returns `{ node, set(states) }`: it builds the three `.stage` elements once (each with a live `.node` span and live `b` title) and `set` assigns `stage.className = \\`stage ${st}\\`.trim()`, `node.replaceChildren(st === \"done\" ? \"\u2713\" : st === \"failed\" ? \"\u2715\" : n)` only when the text differs, and the title (`Committing`/`Committed`, `Registering`/`Registered`) only when it differs. `txRows()` returns `{ node, set(view, states, refused) }` with live value and chip cells per row, replacing children only when their text differs. `waitFor` starts `fillWait` the first time the phase is `committed` (once per screen). The reveal retry (`revealing`, `revealFailures`) moves into `tick` unchanged.\n\n- [ ] **Step 5: Stage transitions in `src/styles.css`** (after `.stage.failed .node`):\n\n```css\n/* a step turning active or done eases to its colours, its node and line changing in place */\n.stage::before, .stage .node { transition: var(--moves); }\n```\n\n- [ ] **Step 6: Payment and confirming screens** \u2014 `buildPayCrypto` keeps references to: the partial-alert slot (`live(el(\"div\", {}))`), amount label, amount, USD line, the three copy fields (each a live value cell), the QR slot (live; replaced only when `paymentUri(...)` differs from the last), the awaiting text (live) and the cancel slot (live). Its `update` recomputes each from the new invoice and replaces only what differs. `buildConfirming` keeps the confirmations line (live) and the slow timer on `signal`.\n\n- [ ] **Step 7: Run the tests**\n\nRun: `npx playwright test e2e/inplace.spec.ts e2e/paid.spec.ts e2e/taken.spec.ts e2e/focus.spec.ts --project=chromium`\nExpected: all passed\n\n```json:metadata\n{\"files\": [\"src/views/order.ts\", \"src/styles.css\", \"e2e/inplace.spec.ts\"], \"verifyCommand\": \"npx playwright test e2e/inplace.spec.ts e2e/paid.spec.ts e2e/taken.spec.ts e2e/focus.spec.ts --project=chromium\", \"acceptanceCriteria\": [\"order kinds as listed\", \"registering update keeps nodes and changes only differing ones\", \"pay-crypto update in place\", \"confirming update in place\", \"stage colours by CSS transition\", \"e2e registering changes in place passes\", \"e2e new screen is one page change\"], \"modelTier\": \"frontier\"}\n```", + "blockedBy": [ + 0 + ] + }, + { + "id": 2, + "subject": "Task 3: Checkout and name-code screens change in place", + "status": "pending", + "description": "**Goal:** Applying or removing a code, choosing a method, a waiting order and link previews change the checkout's nodes in place; the name-code page's states do too.\n\n**Files:**\n- Modify: `src/views/checkout.ts` (`checkoutPage`, `codeEntry`, `ownerCodePage`, `settleWaiting`, `showOpenOrder`; remove `carry(linksSec)`, `linksDraw`)\n- Modify: `src/views/codes.ts:169` (calls `refresh()` instead of `checkoutPage()` for the checkout)\n- Test: `e2e/inplace.spec.ts`\n\n**Acceptance Criteria:**\n- [ ] Kind `checkout`: `update(c)` crosses over the rows (`Paying with` \u2194 `Code`), the total, the code area, the link slot (`I have a code` \u2194 `Remove the code`), the crumbs (`prepaid` changes the steps: the step bar crosses over as one live part) and the Continue button state; the links section (fields being typed, previews loading) is the same node throughout.\n- [ ] Kind `owner-code`: the \"I have saved my code\" check and Register button keep their nodes when the order is refused for its method and drawn again.\n- [ ] Error pages (`price-changed`, `too-many`, `failed`) are `status:` kinds.\n- [ ] e2e `checkout changes in place`: mark `.summary`, `.panel`, `.link-field`, `h1`; apply a code, remove it; all kept, no transition, no cut, rows read `Code` then `Paying with`.\n- [ ] `src/screens/checkout.ts`: state, events (code applied/removed, method chosen, waiting order seen/settled, hold started/ended, order refused, price changed), pure `reduce`, `screenOf` (`checkout`, `owner-code`, `status:`), `moves`; the views dispatch events and render from state; `test/screens-checkout.test.ts` covers every `screenOf` branch and every `moves` row.\n\n**Verify:** `npx playwright test e2e/inplace.spec.ts e2e/checkout.spec.ts e2e/codes.spec.ts --project=chromium` \u2192 all passed\n\n**Steps:**\n\n- [ ] **Step 1: Failing e2e test**\n\n```ts\ntest(\"the checkout changes in place: a code applied and removed keeps the card, panel and link fields\", async ({ page, request }) => {\n const { code } = await control(request, \"code\", { minLength: 6 }) as { code: string }\n await page.goto(`/#/?name=${uniqueLabel(\"inco\")}`)\n await page.getByRole(\"button\", { name: \"Register\" }).click()\n await page.getByRole(\"heading\", { name: \"Check your order\" }).waitFor()\n const kept = [\".summary\", \".panel\", \".link-field\", \"h1\"]\n await markNodes(page, kept)\n await takeTransitions(page)\n await page.getByText(\"I have a code\").click()\n await page.locator(\".code-entry input\").fill(code)\n await expect(page.getByText(\"Remove the code\")).toBeVisible()\n expect(await page.locator(\".summary .r\").allInnerTexts()).toContainEqual(expect.stringContaining(\"Code\"))\n await page.getByText(\"Remove the code\").click()\n await expect(page.getByText(\"I have a code\")).toBeVisible()\n expect(await keptNodes(page, kept)).toEqual(kept)\n expect(await takeTransitions(page)).toEqual([])\n expect(await takeCuts(page)).toEqual([])\n})\n```\n\n- [ ] **Step 2: Run, expect FAIL** (`npx playwright test e2e/inplace.spec.ts -g checkout --project=chromium`).\n\n- [ ] **Step 3: Restructure `checkoutPage()`** into state (`session.checkout()`, waiting record, held flag, applied code) \u2192 `screen(\"search\", \"checkout\", state, buildCheckout)`. `buildCheckout` creates the links section, method radios, card rows (each value a live cell keyed by row label), total (live), code area (live), link slot (live), the crumbs slot (live, `crumbs(\"Details\", ...)` replaced only when `prepaid` changes) and Continue; `update` recomputes rows/total/slots and replaces only differing ones. The code entry's success path calls `refresh()`; removing the code calls `refresh()`.\n\n- [ ] **Step 4: Owner-code page** as kind `owner-code` with `update` that keeps the check and button and replaces the hold note (live).\n\n- [ ] **Step 5: Run tests**\n\nRun: `npx playwright test e2e/inplace.spec.ts e2e/checkout.spec.ts e2e/codes.spec.ts e2e/focus.spec.ts --project=chromium`\nExpected: all passed\n\n```json:metadata\n{\"files\": [\"src/views/checkout.ts\", \"src/views/codes.ts\", \"e2e/inplace.spec.ts\"], \"verifyCommand\": \"npx playwright test e2e/inplace.spec.ts e2e/checkout.spec.ts e2e/codes.spec.ts e2e/focus.spec.ts --project=chromium\", \"acceptanceCriteria\": [\"checkout update in place\", \"owner-code update in place\", \"error pages are status kinds\", \"e2e checkout changes in place passes\"], \"modelTier\": \"frontier\"}\n```", + "blockedBy": [ + 0, + 7, + 8 + ] + }, + { + "id": 3, + "subject": "Task 4: Your names, edit and restore screens change in place", + "status": "pending", + "description": "**Goal:** The Your names list syncs its rows by key (kept, changed, added, removed) instead of being rebuilt; the edit and restore pages' states change in place.\n\n**Files:**\n- Modify: `src/motion.ts` (add `syncList`)\n- Modify: `src/views/names.ts` (`historyPage`, `confirmForget`, `pendingRow`, `orderRow`, `codeRow`, `editPage`, `restorePage`)\n- Test: `e2e/inplace.spec.ts`\n\n**Acceptance Criteria:**\n- [ ] `syncList(part, rows)` in `motion.ts`: for each new row with `data-key`, an existing child with the same key is kept when `isEqualNode` holds, else replaced by `removeFrom(part, old, row)` (fades across); keys only in the old list leave by `removeFrom(part, old)`; keys only in the new list come in by `addTo(part, row, nextSibling)`; order follows `rows`.\n- [ ] Kind `names`: `update` calls `syncList` for the Names and Codes lists and crosses over the empty-list notes and the forget area; rows that did not change keep their nodes; the entering rise runs only when the screen is built.\n- [ ] Kinds `edit`, `edit-unreadable`, `restore`: retry and refusal states cross over in place; `editPage`'s `setTimeout` retry uses the screen signal.\n- [ ] e2e `Your names changes in place`: with two orders, mark both rows; add a code in another tab (`storage` event); both rows kept, the code row added, no transition, no cut.\n- [ ] `src/screens/names.ts`: state (records, codes, pending, forget question, edit and restore states), events (storage changed, forget asked/kept/confirmed, edit answer, restore answer), pure `reduce`, `screenOf`, `moves`; `test/screens-names.test.ts` covers every branch and row.\n\n**Verify:** `npx playwright test e2e/inplace.spec.ts e2e/names.spec.ts e2e/history.spec.ts --project=chromium` \u2192 all passed\n\n**Steps:**\n\n- [ ] **Step 1: Failing e2e test**\n\n```ts\ntest(\"Your names changes in place: rows another tab leaves alone keep their nodes\", async ({ page, context, request }) => {\n const codeOf = async () => (await control(request, \"code\", { minLength: 6 }) as { code: string }).code\n const [first, second] = [await codeOf(), await codeOf()]\n const store = (code: string) => localStorage.setItem(`simplex-names:code:${code}`, JSON.stringify({ code, minLength: 6, years: 2, addedAt: new Date().toISOString() }))\n await page.goto(\"/#/names\")\n await page.evaluate(store, first)\n await page.reload()\n await page.locator(\".row-item\").first().waitFor()\n await expect(page.locator(\".row-item.entering\")).toHaveCount(0)\n await markNodes(page, [\".row-item\", \"h1\"])\n await takeTransitions(page)\n const other = await context.newPage()\n await other.goto(\"/#/names\")\n await other.evaluate(store, second)\n await expect(page.locator(\".row-item\")).toHaveCount(2)\n expect(await keptNodes(page, [\".row-item\", \"h1\"])).toEqual([\".row-item\", \"h1\"])\n expect(await takeTransitions(page)).toEqual([])\n expect(await takeCuts(page)).toEqual([])\n})\n```\n\n- [ ] **Step 2: Run, expect FAIL.**\n\n- [ ] **Step 3: `syncList` in `src/motion.ts`** (after `addTo`):\n\n```ts\n// A list drawn again from its rows, each keyed by data-key: a row alike stays as it is, a changed one crosses over in\n// its place, one gone fades as what follows slides up, and a new one fades in as what follows slides aside.\nexport function syncList(part: HTMLElement, rows: HTMLElement[]): void {\n const byKey = new Map([...part.children].filter((n) => !n.classList.contains(\"outgoing\")).map((n) => [(n as HTMLElement).dataset.key, n as HTMLElement]))\n const wanted = new Set(rows.map((r) => r.dataset.key))\n for (const [key, node] of byKey) if (!wanted.has(key)) removeFrom(part, node)\n let before: Element | null = part.firstElementChild\n for (const row of rows) {\n while (before?.classList.contains(\"outgoing\")) before = before.nextElementSibling\n const old = byKey.get(row.dataset.key)\n if (!old) addTo(part, row, before)\n else if (old.isEqualNode(row)) {\n if (old !== before) part.insertBefore(old, before)\n before = old.nextElementSibling\n continue\n } else removeFrom(part, old, row)\n before = row.nextElementSibling\n }\n}\n```\n\n- [ ] **Step 4: Your names screen** \u2014 `historyPage()` computes `{ orders, codes, pending, canPersist }` and calls `screen(\"names\", \"names\", state, buildNames)`; `buildNames` keeps the two list parts (live) and the forget area; `update` builds rows (`pendingRow`, `orderRow`, `codeRow`, each with `data-key`) and calls `syncList`. The storage listener calls `refresh()` (batched in one frame as now).\n\n- [ ] **Step 5: Edit and restore** \u2014 `editPage` state `{ registrationId, view | error }` \u2192 kinds; `update` crosses over the result line and fields' notes; the retry `setTimeout` is cleared on `signal` abort.\n\n- [ ] **Step 6: Run tests**\n\nRun: `npx playwright test e2e/inplace.spec.ts e2e/names.spec.ts e2e/history.spec.ts e2e/focus.spec.ts --project=chromium`\nExpected: all passed\n\n```json:metadata\n{\"files\": [\"src/motion.ts\", \"src/views/names.ts\", \"e2e/inplace.spec.ts\"], \"verifyCommand\": \"npx playwright test e2e/inplace.spec.ts e2e/names.spec.ts e2e/history.spec.ts e2e/focus.spec.ts --project=chromium\", \"acceptanceCriteria\": [\"syncList keeps, crosses, adds and removes rows by key\", \"names update syncs lists in place\", \"edit and restore states in place\", \"e2e Your names changes in place passes\"], \"modelTier\": \"frontier\"}\n```", + "blockedBy": [ + 0, + 7, + 8 + ] + }, + { + "id": 4, + "subject": "Task 5: Search screen changes in place", + "status": "pending", + "description": "**Goal:** A code applied or removed, a retarget banner corrected and a redraw keep the search box, typed name and results nodes.\n\n**Files:**\n- Modify: `src/views/search.ts` (`searchPage`, `removeCode`, `checkRetarget`, the applied-code note; remove `shownInput`, `shownResults`, `carry(results)`)\n- Modify: `src/views/codes.ts:109,179` (keep `refresh()`)\n- Test: `e2e/inplace.spec.ts`\n\n**Acceptance Criteria:**\n- [ ] Kind `search:`: `update` crosses over the banner slot and the applied-code note; the input, its typed value, the search button and the results part keep their nodes; a new `name` parameter that differs from the searched one runs the search in place.\n- [ ] Kind `status:bad-app-link` for the damaged wallet link.\n- [ ] e2e `search changes in place`: type a name, apply a code from the popup; `.search`, `.search input`, `.results`, `h1` kept, input value unchanged, no transition, no cut.\n- [ ] `src/screens/search.ts`: state (name, typed, answer, applied code, retarget, app wallet, pause), events (typed, searched, answered, code applied/removed, retarget read), pure `reduce`, `screenOf`, `moves`; `test/screens-search.test.ts` covers every branch and row.\n\n**Verify:** `npx playwright test e2e/inplace.spec.ts e2e/search.spec.ts e2e/codes.spec.ts --project=chromium` \u2192 all passed\n\n**Steps:**\n\n- [ ] **Step 1: Failing e2e test**\n\n```ts\ntest(\"the search changes in place: a code applied from the popup keeps the box, the typed name and the results\", async ({ page, request }) => {\n const { code } = await control(request, \"code\", { minLength: 6 }) as { code: string }\n await page.goto(\"/\")\n await page.getByRole(\"textbox\", { name: \"Name\", exact: true }).fill(\"typedname\")\n const kept = [\".search\", \".search input\", \".results\", \"h1\"]\n await markNodes(page, kept)\n await takeTransitions(page)\n await page.getByRole(\"button\", { name: \"Check a code\" }).click()\n await page.getByRole(\"textbox\", { name: \"Code\", exact: true }).fill(code)\n await page.getByRole(\"button\", { name: \"Use this code\" }).click()\n await expect(page.getByText(/^Code applied/)).toBeVisible()\n expect(await keptNodes(page, kept)).toEqual(kept)\n await expect(page.getByRole(\"textbox\", { name: \"Name\", exact: true })).toHaveValue(\"typedname\")\n expect(await takeTransitions(page)).toEqual([])\n expect(await takeCuts(page)).toEqual([])\n})\n```\n\n- [ ] **Step 2: Run, expect FAIL.**\n\n- [ ] **Step 3: Restructure `searchPage()`** into state `{ name, fromApp, retargetId, applied }` \u2192 `screen(\"search\", kind, state, buildSearch)`; `buildSearch` creates hero, input, box, results (live), banner slot (live), applied-note slot (live), bento; `update` updates the banner and applied note, and runs `submit()` when `state.name` differs from the last searched name. `removeCode` calls `refresh()` then focuses the same input (it survives).\n\n- [ ] **Step 4: Run tests**\n\nRun: `npx playwright test e2e/inplace.spec.ts e2e/search.spec.ts e2e/codes.spec.ts e2e/focus.spec.ts --project=chromium`\nExpected: all passed\n\n```json:metadata\n{\"files\": [\"src/views/search.ts\", \"src/views/codes.ts\", \"e2e/inplace.spec.ts\"], \"verifyCommand\": \"npx playwright test e2e/inplace.spec.ts e2e/search.spec.ts e2e/codes.spec.ts e2e/focus.spec.ts --project=chromium\", \"acceptanceCriteria\": [\"search update in place keeps box, input, results\", \"bad app link is a status kind\", \"e2e search changes in place passes\"], \"modelTier\": \"frontier\"}\n```", + "blockedBy": [ + 0, + 7, + 8 + ] + }, + { + "id": 5, + "subject": "Task 6: Page changes with static names; delete the comparison machinery", + "status": "pending", + "description": "**Goal:** `page()` shows a new screen with one transition and no comparison; every runtime pairing path and its CSS is deleted.\n\n**Files:**\n- Modify: `src/views/shell.ts` (`page()`: no `compare`, no identical-redraw path, no `carry`; change type `forward`/`back`/`screen`/`fill`)\n- Modify: `src/motion.ts` (delete `compare`, `Match`, `Comparison`, `signatures`, `keysOf`, `slotsOf`, `pairsOf`, `freshOf`, `paints`, `leaf`, `empty`, `carry`, `placeCarried`, stand-ins, `MOST_MORPHED`, `kindOf`, `anchorOf`, `WHOLE`, `MOVING`, `changeOnly`, `leaveOver`'s \"over\" mode, the pair-naming loop in `begin`, `unname`'s pair names; keep `transition`, `transitionsIdle`, `dropWaitingPage`, `finishMotion`, `settled`, transient names, `swapPage` with `\"at-once\" | \"through\"`)\n- Modify: `src/styles.css` (delete `.part`, `.box`, `.swap`, `.gone`, `.fresh`, `.step`, `.tick`, `.name` pair rules and `vt-moving`; add page rules; static names `order-card`)\n- Modify: `src/views/order.ts`, `src/views/checkout.ts` (`.summary` gets `view-transition-name: order-card` via CSS only), remove `data-whole`\n- Test: `e2e/motion.spec.ts`, `e2e/cuts.ts`\n\n**Acceptance Criteria:**\n- [ ] `rg -n \"compare\\(|pairsOf|freshOf|carry\\(|data-whole|part-\\$\\{|gone-|fresh-|vt-moving\" src` returns nothing.\n- [ ] A page change in Chromium/WebKit runs one view transition whose groups are a subset of `root, page, page-top, page-foot, offline-note, step-ring, step-line-*, step-done-*, order-card, order-name`.\n- [ ] `page` group: old fades out in `--axis-out-ms`, new fades in over `--axis-in-ms` after it, both sliding `--axis` the way the reader goes for `forward`/`back`; `screen` and `fill` without the slide.\n- [ ] Firefox: page changes use `swapPage(old, main, ease, \"through\")`; no `.page-leaving` remains after `PAGE_OUT` time.\n- [ ] `e2e/motion.spec.ts` updated: tests asserting `part-`/`gone-`/`fresh-` groups now assert the static groups above; in-place assertions moved to `e2e/inplace.spec.ts`.\n- [ ] `src/screens/routes.ts`: moves between views (search, checkout, name code, order, Your names, restore, edit, trademarks) with their motion; `page()` takes the motion from `step()`/`routes.ts` instead of `wayTo()`'s crumb and nav heuristics (browser Back/Forward still give `back`/`forward`); `test/screens-routes.test.ts` covers every row.\n- [ ] `npx tsc --noEmit -p .` clean; `npm test` passes (77 plus the new screen tests).\n\n**Verify:** `npx tsc --noEmit -p . && npm test && npx playwright test e2e/motion.spec.ts e2e/inplace.spec.ts --project=chromium` \u2192 all passed\n\n**Steps:**\n\n- [ ] **Step 1: Update `e2e/motion.spec.ts` expectations first** \u2014 in \"a page opened slides the way the reader goes\u2026\", replace the group assertion with\n\n```ts\n expect(opened.groups.filter((g) => !/^step-(line|done)-/.test(g)).sort()).toEqual([\"page\", \"page-foot\", \"page-top\", \"root\"])\n```\n\nand delete the `redraws` part (in-place redraws no longer transition; covered in `inplace.spec.ts`). In \"the next step of an order\u2026\", keep the `step-ring` / `step-line-name-code` / `step-done-details` assertions, replace the root and `gone-` assertions with\n\n```ts\n expect(next.groups).toContain(\"page\")\n expect(next.groups).toContain(\"order-card\")\n```\n\n- [ ] **Step 2: Run, expect FAIL** (`npx playwright test e2e/motion.spec.ts --project=chromium`).\n\n- [ ] **Step 3: CSS** \u2014 replace the view-transition section between `/* Every change of the page is compared\u2026` and the theme rules with:\n\n```css\n/* A page change moves only the shared parts, each by its name: the header and footer stay, the step bar's ring goes over,\n the order's card and name go to their new places; the rest of the page fades through, the way the reader goes. */\n.summary { view-transition-name: order-card; }\nbody > main:not(.pending) { view-transition-name: page; }\n::view-transition-group(page), ::view-transition-group(order-card), ::view-transition-group(.name), ::view-transition-group(step-ring), ::view-transition-group(page-foot) { animation-duration: var(--axis-ms); animation-timing-function: var(--emphasized); }\n::view-transition-old(page) { animation: var(--axis-out-ms) linear both fade-out; }\n::view-transition-new(page) { animation: var(--axis-in-ms) var(--emphasized) var(--axis-out-ms) both appear; }\nhtml:active-view-transition-type(forward, back)::view-transition-old(page) { animation: var(--axis-ms) var(--emphasized) both axis-out, var(--axis-out-ms) linear both fade-out; }\nhtml:active-view-transition-type(forward, back)::view-transition-new(page) { animation: var(--axis-ms) var(--emphasized) both axis-in, var(--axis-in-ms) var(--emphasized) var(--axis-out-ms) both appear; }\n```\n\nkeeping the existing `page-top`/`page-foot`/`offline-note` rules (types now `forward, back, screen, fill`), the `step-*` rules, `.order-name` rules and keyframes.\n\n- [ ] **Step 4: `page()`** \u2014 delete `compare`, `filled`/`arrived`/`unseen` comparison branches that depend on it, the `!comparison.differs` path; `change` is `samePage ? (filled ? \"fill\" : \"screen\") : wayTo(...)`; `transition(change, swap, () => finishMotion(old))`; `swap` calls `swapPage(old, main, !scrollTo, transitions() ? \"at-once\" : \"through\")`.\n\n- [ ] **Step 5: `motion.ts`** \u2014 delete the listed functions and types; `transition(change, update, prepare)` drops the `comparison` parameter; `begin` becomes: `prepare(); unname(); start the view transition with the update; on finish unname and run the queue`.\n\n- [ ] **Step 6: Run tests**\n\nRun: `npx tsc --noEmit -p . && npm test && npx playwright test e2e/motion.spec.ts e2e/inplace.spec.ts --project=chromium`\nExpected: no type errors, 77 passed, all passed\n\n```json:metadata\n{\"files\": [\"src/views/shell.ts\", \"src/motion.ts\", \"src/styles.css\", \"src/views/order.ts\", \"src/views/checkout.ts\", \"e2e/motion.spec.ts\", \"e2e/cuts.ts\"], \"verifyCommand\": \"npx tsc --noEmit -p . && npm test && npx playwright test e2e/motion.spec.ts e2e/inplace.spec.ts --project=chromium\", \"acceptanceCriteria\": [\"no comparison code remains (rg empty)\", \"page change groups are static names only\", \"page group fade-through with axis slide\", \"Firefox fades through\", \"motion.spec updated\", \"tsc clean, unit tests pass\"], \"modelTier\": \"frontier\"}\n```", + "blockedBy": [ + 1, + 2, + 3, + 4, + 8 + ] + }, + { + "id": 6, + "subject": "Task 7: Verify across browsers, recordings and review", + "status": "pending", + "description": "**Goal:** The whole suite passes in three browsers, frame recordings show no flicker, and review subagents report no issues.\n\n> **USER-ORDERED GATE \u2014 NON-SKIPPABLE.** This task was requested by the user in the current conversation. It MUST NOT be closed by walking around it, by declaring it \"verified inline\", or by substituting a cheaper check. Close only after every item in `acceptanceCriteria` has been re-validated independently, with output captured.\n\n**Files:**\n- Test: whole `e2e/`, scratchpad recordings (`tools/journey/plain-journey.mjs`, `tools/static-spikes.mjs`)\n\n**Acceptance Criteria:**\n- [ ] `npx playwright test --project=chromium`, `--project=firefox`, `--project=webkit` each report 0 failed (2 offline tests skipped without `BASE_URL`).\n- [ ] Firefox and Chromium recordings of the `code-order` and `checkout-edits` journeys: `static-spikes.mjs` reports 0 one-frame spikes in header and ground; frame sheets of each in-page change show only the changed parts moving.\n- [ ] A review round of subagents (code, motion with frame recordings in Chromium and Firefox, design desktop and phone) reports no issues; findings are fixed and the round repeated until it does.\n\n**Verify:** `for b in chromium firefox webkit; do npx playwright test --project=$b; done` \u2192 `0 failed` each\n\n**Steps:**\n\n- [ ] **Step 1: Full e2e in each browser** (one at a time; Firefox and WebKit with the scratchpad env scripts).\n- [ ] **Step 2: Recordings** \u2014 `node plain-journey.mjs firefox code-order ` and `chromium`, then `node static-spikes.mjs `; cut and read frame sheets of each change.\n- [ ] **Step 3: Review round** \u2014 dispatch reviewers (no edits to the repos, scratchpad only, no polling waits), fix findings, repeat until none.\n\n```json:metadata\n{\"files\": [], \"verifyCommand\": \"for b in chromium firefox webkit; do npx playwright test --project=$b; done\", \"acceptanceCriteria\": [\"0 failed in chromium, firefox, webkit\", \"0 one-frame spikes in recordings; only changed parts move\", \"review subagents report no issues\"], \"modelTier\": \"frontier\", \"userGate\": true, \"tags\": [\"user-gate\"]}\n```", + "blockedBy": [ + 5 + ] + }, + { + "id": 7, + "subject": "Task 8: The state machine core", + "status": "in_progress", + "description": "**Goal:** A pure `src/screens/machine.ts` with `Machine`, `Motion` and `step()`, unit-tested, for every view to run its state through.\n\n**Files:**\n- Create: `src/screens/machine.ts`\n- Test: `test/machine.test.ts`\n\n**Acceptance Criteria:**\n- [ ] `step(machine, state, shown, event, warn)` returns `{ state, screen, motion, expected }`: `state = machine.reduce(state, event)`, `screen = machine.screenOf(state)`; `shown === null` \u2192 motion `\"screen\"`, expected; `screen === shown` \u2192 `\"in-place\"`, expected; a move listed in `machine.moves[shown][screen]` \u2192 that motion, expected; otherwise `warn(\"[screens] : unexpected move \u2192 \")`, motion `\"screen\"`, `expected: false`.\n- [ ] No DOM, clock, storage or network use in `src/screens/` (`rg -n \"document|window|Date\\.now|localStorage|fetch\" src/screens` empty).\n- [ ] vitest covers the four branches with a small test machine (counter-like states and two screens).\n\n**Verify:** `npx tsc --noEmit -p . && npx vitest run test/machine.test.ts` \u2192 passed\n\n**Steps:**\n\n- [ ] **Step 1: Failing test** `test/machine.test.ts`:\n\n```ts\nimport { describe, expect, it, vi } from \"vitest\"\nimport { type Machine, step } from \"../src/screens/machine.ts\"\n\ntype S = { n: number }\ntype E = { kind: \"add\"; by: number }\ntype K = \"low\" | \"high\" | \"odd\"\nconst machine: Machine = {\n name: \"test\",\n reduce: (s, e) => ({ n: s.n + e.by }),\n screenOf: (s) => (s.n % 2 ? \"odd\" : s.n < 10 ? \"low\" : \"high\"),\n moves: { low: { high: \"forward\" }, high: { low: \"back\" }, odd: {} },\n}\n\ndescribe(\"step\", () => {\n it(\"draws the first screen as a page change\", () => {\n expect(step(machine, { n: 0 }, null, { kind: \"add\", by: 2 }, vi.fn())).toEqual({ state: { n: 2 }, screen: \"low\", motion: \"screen\", expected: true })\n })\n it(\"keeps a screen of the same kind in place\", () => {\n expect(step(machine, { n: 2 }, \"low\", { kind: \"add\", by: 2 }, vi.fn()).motion).toBe(\"in-place\")\n })\n it(\"takes a listed move's motion\", () => {\n expect(step(machine, { n: 8 }, \"low\", { kind: \"add\", by: 2 }, vi.fn())).toMatchObject({ screen: \"high\", motion: \"forward\", expected: true })\n })\n it(\"logs a move missing from the table and still shows it as a page change\", () => {\n const warn = vi.fn()\n expect(step(machine, { n: 2 }, \"low\", { kind: \"add\", by: 1 }, warn)).toMatchObject({ screen: \"odd\", motion: \"screen\", expected: false })\n expect(warn).toHaveBeenCalledWith(\"[screens] test: unexpected move low \u2192 odd\")\n })\n})\n```\n\n- [ ] **Step 2: Run, expect FAIL** (`npx vitest run test/machine.test.ts`: module not found).\n\n- [ ] **Step 3: Implement** `src/screens/machine.ts`:\n\n```ts\n// A view's behaviour as data: its state changes only by events through reduce, the screen shown follows from the state\n// alone, and each move from one screen to another has its motion; a move the table lacks is logged, and still shown.\nexport type Motion = \"in-place\" | \"forward\" | \"back\" | \"screen\"\nexport interface Machine {\n name: string\n reduce: (state: S, event: E) => S\n screenOf: (state: S) => K\n moves: { readonly [F in K]: Partial>> }\n}\nexport interface Step { state: S; screen: K; motion: Motion; expected: boolean }\n\nexport function step(machine: Machine, state: S, shown: K | null, event: E, warn: (message: string) => void = console.warn): Step {\n const next = machine.reduce(state, event)\n const screen = machine.screenOf(next)\n if (shown === null) return { state: next, screen, motion: \"screen\", expected: true }\n if (screen === shown) return { state: next, screen, motion: \"in-place\", expected: true }\n const motion = machine.moves[shown][screen]\n if (motion) return { state: next, screen, motion, expected: true }\n warn(`[screens] ${machine.name}: unexpected move ${shown} \u2192 ${screen}`)\n return { state: next, screen, motion: \"screen\", expected: false }\n}\n```\n\n- [ ] **Step 4: Run, expect PASS**; `npx tsc --noEmit -p .` clean.\n\n```json:metadata\n{\"files\": [\"src/screens/machine.ts\", \"test/machine.test.ts\"], \"verifyCommand\": \"npx tsc --noEmit -p . && npx vitest run test/machine.test.ts\", \"acceptanceCriteria\": [\"step() covers first draw, in place, listed move, unexpected move with warn\", \"src/screens has no DOM/clock/storage/network use\", \"vitest covers the four branches\"], \"modelTier\": \"mechanical\"}\n```" + }, + { + "id": 8, + "subject": "Task 9: The order page as a state machine", + "status": "pending", + "description": "**Goal:** The order page's state lives in one object changed by events through a pure reducer in `src/screens/order.ts`; its screen comes from a pure `screenOf`, its moves from a table, all unit-tested; `order.ts` becomes the controller (polls, timers, API calls, storage) that dispatches events and renders.\n\n**Files:**\n- Create: `src/screens/order.ts`\n- Modify: `src/views/order.ts` (follow loop, timers and actions dispatch events; `kindOf` replaced by `screenOf`; screens render from state)\n- Test: `test/screens-order.test.ts`\n\n**Acceptance Criteria:**\n- [ ] `OrderState` holds everything the screens read: invoice, registration view, record, card submitted time, reveal attempts and refusals, action notes, hold, `now` (service time of the last event).\n- [ ] Events: `answered` (invoice and/or view with service time), `record-changed`, `tick` (service time), `card-submitted`, `card-failed`, `reveal-sent`, `reveal-refused`, `reveal-failed`, `cancel-started`, `cancel-answered`, `cancel-failed`, `reorder-started`, `reorder-failed`, `hold-started`, `hold-ended`; reducer pure (no clock, storage, network, DOM).\n- [ ] `screenOf` returns every kind of Task 2 and nothing else; `moves` lists the order lifecycle (pay-crypto \u2192 confirming \u2192 registering \u2192 registered | taken | failed; pay-crypto \u2192 expired | still-open | expired-cancelled; pay-card \u2192 confirming | card-failed | slow; confirming \u2192 slow | registering; registering \u2192 registered | taken | failed | not-on-device; etc.) with motion `screen`.\n- [ ] `test/screens-order.test.ts`: one test per `screenOf` branch (state \u2192 kind), one per `moves` row (a state of the from-screen and an event leading to the to-screen: `step()` returns the row's motion, `expected: true`), the reducer's handling of each event, and a race test: two `answered` events applied in either order (older service time second) end in the same state (an older answer never replaces a newer one).\n- [ ] The e2e of Task 2 still pass.\n\n**Verify:** `npx tsc --noEmit -p . && npm test && npx playwright test e2e/inplace.spec.ts e2e/paid.spec.ts e2e/taken.spec.ts --project=chromium` \u2192 all passed\n\n```json:metadata\n{\"files\": [\"src/screens/order.ts\", \"src/views/order.ts\", \"test/screens-order.test.ts\"], \"verifyCommand\": \"npx tsc --noEmit -p . && npm test && npx playwright test e2e/inplace.spec.ts e2e/paid.spec.ts e2e/taken.spec.ts --project=chromium\", \"acceptanceCriteria\": [\"OrderState holds all screen inputs\", \"events as listed, reducer pure\", \"screenOf returns Task 2 kinds; moves list the lifecycle\", \"vitest covers every branch, row, event and the answer race\", \"Task 2 e2e still pass\"], \"modelTier\": \"frontier\"}\n```", + "blockedBy": [ + 1, + 7 + ] + } + ], + "lastUpdated": "2026-10-17T15:40:00Z" +} \ No newline at end of file diff --git a/plans/names-codes/2026-10-19-webapp-redesign-plan.md b/plans/names-codes/2026-10-19-webapp-redesign-plan.md new file mode 100644 index 0000000000..d3bb3977d3 --- /dev/null +++ b/plans/names-codes/2026-10-19-webapp-redesign-plan.md @@ -0,0 +1,1571 @@ +# Names webapp redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebuild the names webapp (`simplex.domains/webapp`) in the simplex.domains look with the badges webapp's working model: one column, one action per screen, whole-screen builders, a sliding purchase track, and about 5.5k lines of source instead of 8.8k. + +**Architecture:** Pure reducers and `screenOf` functions in `src/screens/*` stay the source of truth. Every view becomes a set of builders `(state, signal) => HTMLElement` that `shell.draw()` swaps in whole with `replaceChildren`. Search, checkout and payment sit on one CSS track (`src/views/track.ts`, adapted from badges `main.ts:265-466`) that slides by `transform`; every other screen swaps whole. Inputs being typed in are kept nodes (`shell.kept()`) that move into each new screen. No view transitions, no in-place diffing, no focus restoration machinery. + +**Tech Stack:** Vite 6, TypeScript 5.9 (strict, `noUnusedLocals`), plain DOM, Vitest 3, Playwright 1.64 (Chromium, Firefox 157, WebKit, Firefox 158 via `moz-firefox`), dev backend `dev/start.sh` (anvil + PR #36 contracts + mock service). + +**Spec:** plans/names-codes/2026-10-19-webapp-redesign.md + +## Global Constraints + +- **Repository.** All work is in `/project/git/simplex.domains/webapp` (git root `/project/git/simplex.domains`), branch `sh/names-webapp`, starting at `7ed55cf`. Paths below are relative to `webapp/` unless absolute. Visual source: `/project/git/simplex.domains/index.html` (cited as `landing:LINE`). Working-model source: `/project/git/simplex-chat-4/apps/simplex-badge-service/web` (cited as `badges main.ts:LINE`, `badges styles.css:LINE`, `badges screens.ts:LINE`). +- **Code rules (CLAUDE.md).** Named constants, no magic values; no dead code (an export nothing imports is deleted in the same task); no duplication; no shims kept past the task that replaces them; comments only for traps, ordering, sources of values and invariants, one or two lines, no em-dash asides; fix every warning (`npx tsc --noEmit` prints nothing). +- **Determinism.** `src/screens/*` stay pure: no `Date.now`, `Math.random`, DOM, storage or network. Time enters as event fields (`now`). Every reducer change gets Vitest cases. +- **No new dependencies.** `package.json` `dependencies` and `devDependencies` stay as at `7ed55cf`. +- **Builders.** A builder returns one `
` (or `class="home"`) with exactly one `h1`. Callbacks inside a kept node read the view's module-level `state`, never the state the builder was called with. +- **Copy.** User-facing sentences stay as at `7ed55cf` unless a task gives the new words. The refund wording, "Do not send anything more to the address", "Nothing was charged" and the reference lines are kept wherever the invoice saw a payment. +- **Motion.** Only the track's `transform`/`height` transition (320 ms, `cubic-bezier(.22, .61, .36, 1)`, badges `styles.css:434-443`), 0 ms under `prefers-reduced-motion: reduce`. No `@keyframes`, no `view-transition-*`, no `@property`, no Web Animations calls, no theme easing. +- **Dev backend.** Start it once per session in the background (it runs until stopped), then wait for readiness with curl's own retry, never a sleep loop: + ```bash + S=/tmp/claude-1000/-project-git-simplex-chat-4/5b3db9f9-78f1-4006-b413-b087b96f64ef/scratchpad + cd /project/git/simplex.domains/webapp + SLOTS=3 MIN_COMMITMENT_AGE=5 MOCK_RESEND_MS=1000 MOCK_RETRY_AFTER_SECONDS=2 ENS_CONTRACTS_DIR=$S/nsc/ens-contracts ANVIL=$S/foundry/anvil dev/start.sh # run_in_background + curl -sf --retry 90 --retry-connrefused --retry-delay 2 -o /dev/null http://127.0.0.1:8101/control/state # foreground, exits 0 once slot 2's mock answers + ``` + E2E then runs as `SLOTS=3 npx playwright test …`. A mock change needs a backend restart. +- **Browsers.** Before any Playwright run: `ulimit -c 0` and `source $S/tools/webkit-env.sh` (it also sources `$S/firefox-env/env.sh`). Firefox 158 is `/nix/store/fzqp8k7z4y915fsdssb6x16b7iiqg2dm-firefox-158.0/bin/firefox`, passed as `FIREFOX_158=…`, launched with Playwright channel `"moz-firefox"`. +- **Agents.** No polling waits anywhere (`sleep N; tail`, `while …; do sleep`): run long commands in the foreground with a timeout, or with `run_in_background` and wait for the exit notification. Every agent prompt dispatched for this plan states this rule. Review agents are read-only: they never edit `src/`, `e2e/`, `test/` and never run gradle. +- **Commits.** One commit per task, message `webapp: ` (≤ 50 chars, imperative, no period, no body, no co-author), staging explicit paths with `git -C /project/git/simplex.domains add `. Every commit passes `npx tsc --noEmit` and `npx vitest run`. +- **E2E between tasks.** Each screen task updates the existing e2e tests whose selectors or flows it changes (named in its steps) and runs them; Task 10 sweeps the rest; Task 11 is the full gate. +- **Unused exports check** (used in several tasks; expected output: nothing): + ```bash + cd /project/git/simplex.domains/webapp && for n in $(rg -o --no-filename '^export (?:async )?(?:const|function|type|interface|class) (\w+)' -r '$1' src); do [ "$(rg -lw "$n" src test e2e dev | wc -l)" -lt 2 ] && echo "unused export: $n"; done + ``` + +**User decisions (already made):** +- "Visual design: simplex.domains tokens: colours, gradients, fonts, type scale, pill buttons, light and dark." +- "Home: the current simplex.domains page minus the countdown, plus the search." +- "Motion: the badges model: the purchase steps slide on one CSS track, other screens swap whole; no view transitions, no in-place diffing." +- "Checkout: one page: summary, optional links, payment method, name code, Pay." +- "Prepaid codes: entered at checkout and in Your names only; no header popup, no applied-code banner." +- "Links: stay optional at checkout." +- "Taken after paying: the retarget flow stays." +- "The 'Your names' header link and the checkout block order are defaults the owner may change." + +--- + +### Task 1: Tokens and frame + +**Goal:** The page wears the landing's tokens, fonts, type scale, pill buttons, cards and fields in light and dark, inside the landing's header (logo, "Your names", theme icon) and footer ("Pay with" marks, links row), with the grid background, sticky bar, phone menu and `@property` theme easing gone. + +**Files:** +- Modify: `src/styles.css` (lines 1-230 rewritten: fonts, tokens, base, frame, shared components; lines 650-660 motion variables kept for Task 2; lines 807-840 theme easing and phone menu deleted; remaining rules re-pointed to the new token names) +- Modify: `src/views/shell.ts:140-323` (header, theme, phone menu, footer, frame) +- Modify: `src/dom.ts:111-113` (`alertBox` emits `.notice` with tones `ok | warn | danger | info`) and its callers in `src/views/search.ts`, `src/views/checkout.ts`, `src/views/codes.ts`, `src/views/names.ts`, `src/views/order.ts`, `src/views/slots.ts` +- Modify: `src/icons.ts` (delete `menu`, `moon`, `sun` line icons) +- Modify: `index.html` (static header, theme-color, font preloads) +- Modify: `README.md` (CSP script hashes) +- Delete: `public/fonts/Manrope/Manrope-ExtraBold.woff2`, `public/fonts/Manrope/Manrope-ExtraBold-latin.woff2` +- Modify: `e2e/search.spec.ts` (delete the phone-menu tests at lines 118, 142, 202), `e2e/focus.spec.ts` (delete tests at lines 212, 264, 281) + +**Acceptance Criteria:** +- [ ] `getComputedStyle(document.documentElement).getPropertyValue("--page-color")` is `#F3FAFF` in light and `#000832` with `html.dark`; `--primary` is `#0053D0` / `#70F0F9`. +- [ ] `rg -n "@property|grid-line|menu-btn|menu-open|theme-easing|--glow|--button-glow|box-shadow: var\(--shadow" src/styles.css` prints nothing. +- [ ] On every route the header shows the logo image, a "Your names" link to `/#/names` and one theme button; no element in the page has `position: sticky`. +- [ ] The footer shows "Pay with" and the Card, Bitcoin and Monero marks, and the links Wefunder, Trademark owners, Contact. +- [ ] `.btn` computes `border-radius: 999px`, `padding: 16px 32px`, `font-size: 18px`, `font-weight: 600`, no `box-shadow`. +- [ ] The README CSP hashes equal the output of the hash command in Step 6. +- [ ] `npx tsc --noEmit` prints nothing; `npx vitest run` passes. + +**Verify:** +```bash +cd /project/git/simplex.domains/webapp && npx tsc --noEmit && npx vitest run # → no tsc output; "Test Files … passed" +rg -n "@property|grid-line|menu-btn|theme-easing" src/styles.css # → no output +``` + +**Steps:** + +- [ ] **Step 1: Write the font faces and the token block** at the top of `src/styles.css`, replacing lines 1-163. Keep the existing latin/rest subset pairs and fallback faces for GT-Walsheim 700 and Manrope 500, 600, 700 (lines 5-8, 10-13, 16-19 of the old file); drop the 800 pair (lines 9, 15). Body text is Manrope 500: every visible text on the landing sets 500 or 600 (`landing:153,166,221-222`), so no Regular face is added. + +```css +/* simplex.domains' tokens (landing index.html:30-83); status colours from the badges webapp (public/styles.css:33-45, 97-105) */ +:root { + color-scheme: light; + --display: "GT-Walsheim", "GT-Walsheim fallback", Gilroy, Helvetica, sans-serif; + --body: "Manrope", "Manrope fallback", sans-serif; + --mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", "DejaVu Sans Mono", monospace; + + --fs-h1: clamp(28px, 5vw, 48px); + --fs-h2: clamp(20px, 2.3vw, 24px); + --fs-lead: clamp(19px, 2.3vw, 24px); + --fs-body: 18px; + --fs-small: clamp(14px, 1.5vw, 16px); + --fs-label: clamp(12px, 1.6vw, 16px); + --frame-pad: clamp(18px, 3.5vw, 44px); + --column: 640px; + + --page-color: #F3FAFF; + --page-image: + radial-gradient(95% 62% at 85% 4%, oklch(0.90 0.07 255 / 0.45), transparent 62%), + radial-gradient(135% 108% at 50% 122%, oklch(0.92 0.075 255) 0%, oklch(0.965 0.035 253) 46%, oklch(0.995 0.008 250) 100%); + --heading: linear-gradient(to bottom, #53C1FF -50%, #0053D0 160%); + --ink: #0D1B3A; + --ink-soft: rgba(13, 27, 58, .651); + --primary: #0053D0; + --accent: #0197FF; + --toggle: #001796; + --sym-back: #023789; + --line-strong: rgba(0, 83, 208, .4); + --chip: rgba(0, 83, 208, .06); + --chip-line: rgba(0, 83, 208, .14); + --primary-ink: #ffffff; + --surface: #ffffff; + --surface-line: rgba(0, 83, 208, .14); + --backdrop: rgba(6, 20, 56, .38); + --disabled: rgba(13, 27, 58, .07); + --on-disabled: rgba(13, 27, 58, .50); + + --ok-fg: #1c7c3f; --ok-bg: #e8f5ec; --ok-line: #bfe3cc; + --warn-fg: #8a6100; --warn-bg: #fff8e6; --warn-line: #f0dcae; + --danger-fg: #b3261e; --danger-bg: #fdecec; --danger-line: #f3c9c6; +} +html.dark { + color-scheme: dark; + --page-color: #000832; + --page-image: + radial-gradient(92% 62% at 82% -2%, oklch(0.44 0.135 256 / 0.38), transparent 60%), + radial-gradient(140% 118% at 50% -12%, oklch(0.30 0.105 258) 0%, oklch(0.19 0.088 260) 46%, oklch(0.135 0.075 263) 100%); + --heading: linear-gradient(to bottom, #70F0F9 0%, #70F0F9 33%, #50D8F1 100%); + --ink: #EAF2FF; + --ink-soft: rgba(234, 242, 255, .735); + --primary: #70F0F9; + --accent: #02C0FF; + --toggle: #ffffff; + --sym-back: #ffffff; + --line-strong: rgba(112, 240, 249, .42); + --chip: rgba(112, 240, 249, .08); + --chip-line: rgba(112, 240, 249, .2); + --primary-ink: #041038; + --surface: #0A1C44; + --surface-line: rgba(112, 240, 249, .18); + --backdrop: rgba(2, 8, 26, .58); + --disabled: rgba(255, 255, 255, .09); + --on-disabled: rgba(255, 255, 255, .45); + + --ok-fg: #7FE3A6; --ok-bg: rgba(127, 227, 166, .12); --ok-line: rgba(127, 227, 166, .28); + --warn-fg: #F5C86A; --warn-bg: rgba(245, 200, 106, .12); --warn-line: rgba(245, 200, 106, .28); + --danger-fg: #FF9F98; --danger-bg: rgba(255, 159, 152, .12); --danger-line: rgba(255, 159, 152, .28); +} +``` + +- [ ] **Step 2: Write the base, frame and shared components** directly after the tokens (replacing old lines 165-260: base, grid `body::before`, glow `body::after`, sticky `.top`, buttons, chips, labels). + +```css +* { box-sizing: border-box; margin: 0; padding: 0; } +[hidden] { display: none !important; } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +html { background: var(--page-color); } +body { min-height: 100dvh; display: flex; flex-direction: column; font: 500 var(--fs-body)/1.5 var(--body); color: var(--ink); + background: var(--page-image), var(--page-color); -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; text-wrap: pretty; } +a { color: var(--primary); text-decoration: none; font-weight: 600; } +a:hover { text-decoration: underline; text-underline-offset: 3px; } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; } +svg.i { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; } +.mono { font-family: var(--mono); } +.nowrap { white-space: nowrap; } +h1 { font: 700 var(--fs-h1)/1.05 var(--display); letter-spacing: -.025em; background: var(--heading); -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; color: transparent; padding-bottom: .04em; text-wrap: balance; } +h2 { font: 700 var(--fs-h2)/1.15 var(--display); letter-spacing: -.015em; } +.lead { font-size: var(--fs-lead); font-weight: 500; color: var(--ink-soft); } +.note { font-size: var(--fs-small); font-weight: 500; color: var(--ink-soft); } +.label { font-size: var(--fs-label); font-weight: 600; letter-spacing: .18em; text-transform: uppercase; color: var(--ink-soft); } + +/* the landing's header (landing:117-129): not sticky, no bar */ +.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: var(--frame-pad); } +.brand { display: inline-flex; } +.brand img { height: clamp(36px, 4.4vw, 52px); width: auto; display: block; } +.brand .logo-dark, html.dark .brand .logo-light { display: none; } +html.dark .brand .logo-dark { display: block; } +.top-links { display: flex; align-items: center; gap: clamp(14px, 2vw, 24px); font-size: var(--fs-small); } +.theme-switch { display: inline-flex; background: none; border: 0; cursor: pointer; padding: 6px; margin: -6px; } +.theme-switch svg { width: 22px; height: auto; display: block; overflow: visible; } +.theme-switch svg path { fill: var(--toggle); } +.theme-switch .sun, html.dark .theme-switch .moon { display: none; } +html.dark .theme-switch .sun { display: block; } +body > main { flex: 1 0 auto; } + +/* the landing's footer (landing:170-194) */ +.footer { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: var(--frame-pad); text-align: center; } +.pay { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px 14px; font-size: var(--fs-small); color: var(--ink-soft); } +.pay span { display: inline-flex; align-items: center; gap: 7px; } +.pay svg.mark { width: 21px; height: 21px; } +.footer-links { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px 14px; font-size: var(--fs-small); } +.footer-links a { font-weight: 500; } +.footer-links .sep { color: var(--line-strong); font-weight: 400; } + +/* flat pill buttons (landing:133-141) */ +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 16px 32px; border-radius: 999px; + border: 1px solid transparent; background: var(--primary); color: var(--primary-ink); font: 600 18px/1.2 var(--body); + letter-spacing: .01em; white-space: nowrap; cursor: pointer; transition: filter .15s ease; } +.btn:not([disabled]):hover { filter: brightness(1.06); text-decoration: none; } +.btn:not([disabled]):active { transform: translateY(1px); } +.btn.block { display: flex; width: 100%; } +.btn.quiet { background: none; color: var(--primary); border-color: var(--surface-line); } +.btn.danger { background: var(--danger-fg); color: var(--page-color); } +.btn[disabled] { background: var(--disabled); color: var(--on-disabled); border-color: transparent; cursor: default; } +.btn[aria-busy="true"] { cursor: progress; } + +/* cards: 1px line, radius 24, no shadow */ +.card { background: var(--surface); border: 1px solid var(--surface-line); border-radius: 24px; padding: 24px; } +/* pill fields, as the landing's email field (landing:231-233) */ +.field { display: flex; align-items: center; gap: 10px; padding: 16px 24px; border: 1px solid var(--surface-line); border-radius: 999px; + background: var(--chip); transition: border-color .15s ease; } +.field:focus-within { border-color: var(--primary); } +.field input { flex: 1; min-width: 0; border: 0; background: none; outline: none; font: 500 var(--fs-body) var(--body); color: var(--ink); } +.field input::placeholder { color: var(--ink-soft); } +.field.bad { border-color: var(--danger-fg); } +.field.ok { border-color: var(--ok-fg); } +.notice { display: flex; gap: 12px; padding: 14px 18px; border-radius: 16px; border: 1px solid; font-size: var(--fs-small); text-align: left; } +.notice.ok { color: var(--ok-fg); background: var(--ok-bg); border-color: var(--ok-line); } +.notice.warn { color: var(--warn-fg); background: var(--warn-bg); border-color: var(--warn-line); } +.notice.danger { color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-line); } +.notice.info { color: var(--ink); background: var(--chip); border-color: var(--chip-line); } +.chip { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 14px; font-weight: 600; + background: var(--chip); color: var(--primary); } +.chip.ok { background: var(--ok-bg); color: var(--ok-fg); } +.chip.warn { background: var(--warn-bg); color: var(--warn-fg); } +.chip.bad { background: var(--danger-bg); color: var(--danger-fg); } +``` + +- [ ] **Step 3: Re-point the remaining old rules** (old lines 260-940) with this mapping, then delete every declaration whose token has no row (shadows, glows, rings, sheens): `--bg`→`--page-color`, `--ink-2`/`--ink-3`→`--ink-soft`, `--line`/`--line-2`→`--surface-line`, `--surface-2`/`--brand-soft`/`--glass`→`--chip`, `--brand`/`--heading-1`→`--primary`, `--brand-2`→`--accent`, `--brand-line`→`--chip-line`, `--on-brand`→`--primary-ink`, `--ok`→`--ok-fg`, `--ok-soft`→`--ok-bg`, `--bad`→`--danger-fg`, `--bad-soft`→`--danger-bg`, `--warn`→`--warn-fg`, `--warn-soft`→`--warn-bg`, `--scrim`→`--backdrop`. Rename `.alert` rules to `.notice` and `alertBox()` in `src/dom.ts:111-113` to emit `class: "notice "` with tones `ok | warn | danger | info` (callers passing `"bad"` pass `"danger"`; `rg -n 'alertBox\("bad"' src` → none after the edit). `.panel` keeps working until Task 8 by sharing the `.card` rule: change its selector line 335 to `.panel { background: var(--surface); border: 1px solid var(--surface-line); border-radius: 24px; }`. Delete old lines 807-840 (theme easing, phone menu, `:root::after` scrim), `.top*`, `.icon-btn`, `.ghost-btn`, `.foot*`. + +- [ ] **Step 4: Static header in `index.html`.** Replace the body's header with the landing's, plus the link; update theme colours and the preload face list (drop ExtraBold): + +```html + + +…faces=[["GT-Walsheim/GT-Walsheim-LC-Bold","GT-Walsheim",700],["Manrope/Manrope-Medium","Manrope",500],["Manrope/Manrope-SemiBold","Manrope",600],["Manrope/Manrope-Bold","Manrope",700]]… + +
+ SimpleXSimpleX + +
+
+ +``` + +- [ ] **Step 5: Frame in `src/views/shell.ts`.** Delete `brand()`, `themeIcons()`, `header()`, `fadeMenu()`, `turnIn()`, `closeMenu()` and its two document listeners, `MENU_MS`, `PHONE_MENU`, `MENU_SHUT`, `MENU_WHOLE`, `THEME_EASE_MS`, `themeEased` (lines 140-267). Keep `isDark()`. Replace `paintBars()`, `toggleTheme()`, `footer()` and `frame()` with: + +```ts +const PAY_MARKS: Method[] = ["card", "btc", "xmr"] + +function paintBars(): void { + const ground = getComputedStyle(document.documentElement).getPropertyValue("--page-color").trim() + for (const meta of document.querySelectorAll('meta[name="theme-color"]')) meta.content = ground +} + +function toggleTheme(): void { + const dark = !isDark() + store.setTheme(dark ? "dark" : "light") + document.documentElement.classList.toggle("dark", dark) + paintBars() +} + +function footer(): HTMLElement { + const sep = () => el("span", { class: "sep", "aria-hidden": "true" }, "·") + return el("footer", { class: "footer" }, + el("div", { class: "pay" }, el("span", {}, "Pay with"), ...PAY_MARKS.map((m) => el("span", {}, methodMark(m), methodName(m)))), + el("div", { class: "footer-links" }, + el("a", { href: WEFUNDER_URL, target: "_blank", rel: "noopener" }, "SimpleX crowdfunding (Wefunder)"), sep(), + el("a", { href: "/#/trademarks" }, "Trademark owners"), sep(), + el("a", { href: `mailto:${CONTACT_EMAIL}` }, "Contact"))) +} + +// index.html draws the header; the script adds its theme switch and the footer once +function frame(): HTMLElement { + const main = document.querySelector("body > main")! + if (document.body.dataset.framed) return main + document.body.dataset.framed = "1" + document.querySelector(".theme-switch")!.addEventListener("click", toggleTheme) + document.body.append(footer()) + paintBars() + return main +} +``` +`page()` calls `frame()` where it called the old one (it uses only `main`; the `top` nav highlighting at line 366 and `closeMenu` calls at 350 go). Remove the "Check a code" header entries; the popup stays reachable from the home page's investors tile until Task 4. Delete `menu`, `moon`, `sun` from `LINE_ICONS` in `src/icons.ts`. + +- [ ] **Step 6: Update the README's CSP hashes** (README "Hosting", `script-src`) with the output of: +```bash +node -e 'const fs=require("fs"),c=require("crypto");for(const m of fs.readFileSync("index.html","utf8").matchAll(/