diff --git a/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/plan.md b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/plan.md new file mode 100644 index 00000000..49006f20 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/plan.md @@ -0,0 +1,87 @@ +# kit-206 viewer reload hint: implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (inline, with test-driven-development). Steps use checkbox (`- [ ]`) syntax. + +## TL;DR + +- `#av-reload` gets the class `stale` (2 px `var(--amber)` outline, title "A newer version is ready: reload to see it") when the stage's artifact has a newer fingerprint than the one its frame loaded. +- `TS` gains `fpNow` and `mainFpNow`, started equal to `fp`/`mainFp` in `showArtifact`; `tsSet` resets them. +- Dashboard: after each `/api/data` read (`loadNow`, the raw `d`, not the possibly held `DATA`), the Library rows update them. +- Viewer tab: every 10 s while visible, one `/api/view` ask in flight for `TS.slug`/`TS.at` (main or a draft only). +- Reload copies the known newer fingerprints into `fp`/`mainFp` and clears the hint; nothing reloads by itself. +- One new browser test in `test/dashboard.test.mjs`, two subtests (dashboard stage, viewer tab), light and dark. +- Risk: one-reload races at open and at reload are accepted (a spurious hint once); each visible viewer tab on main or a draft costs one `artifact describe` per 10 s; the test waits on real polls (up to 45 s). Two tasks, only `index.html` and the test change (plan-1 P3). + +**Goal:** the reload button says when the artifact on the stage is behind the one on disk. + +**Architecture:** one hint function (`avHint()`) reads `TS` and sets the button; two feeders (the dashboard's data read, the viewer tab's poll) update `TS.fpNow`/`TS.mainFpNow` and call it; the reload handler moves the loaded fingerprints forward and calls it. + +**Tech stack:** the dashboard's single-file vanilla JS; `node:test` with the kit's CDP Chrome driver (`test/lib/chrome.mjs`). + +**Spec:** `docs/devteam-dev/tickets/kit-206-viewer-reload-hint/spec.md` (read it; this plan argues from it). + +## Global constraints + +- Files: `docs/devteam/dashboard/index.html`, `test/dashboard.test.mjs`, and this ticket's folder. No file added outside the ticket folder. +- Colour: `var(--amber)`, the warning token (light `#f5a623`, dark `#ffb224`). No new token. +- Titles, exact: stale "A newer version is ready: reload to see it"; otherwise "Load the page again" (today's). +- Viewer poll: 10 s, only while `document.visibilityState === 'visible'`, only `TS.kind === 'artifact'` with `TS.at` not `tree:`, one ask in flight. Its code in `viewerBoot` uses none of the names `route(`, `render(`, `load(`, `loadNow(`, `loadSchemaDocs(`. +- Never reload the frame except on the button's press. +- The new test never uses `POLL_FAST`: it waits on the real 10 s polls (spec-2 N1). Waits: dashboard up to 45 000 ms (2 × 10 s + `DATA_WAIT_MS` 20 s + 5 s), viewer tab up to 37 000 ms (2 × 10 s + describe's 12 s + 5 s); met by the first `dashboard-data` run, or `/api/view` ask, that starts after the change. +- Browser tests run here with Node 22: `/usr/local/bin/node` (the default Node 18 has no WebSocket and skips them). + +## Review focus + +1. A reload before any check (fpNow still the open-time fp): the hint still comes on a later change. Pinned in Task 1's test. +2. A held dashboard draw (the owner typing): the check must use the read, not `DATA`. Covered by using `d` in `loadNow`; reviewer reads it. +3. A switch of stage (another artifact, a preview, close) while a viewer ask is in flight: its reply is dropped. Pinned by the slug/at guard; reviewer reads it. +4. A tree stage and a preview stage never poll and never show the hint. Pinned in Task 2's test (the existing "nothing else" asks list stays clean for a tree). +5. Dark mode: outline colour is the dark token. Pinned in Task 1's test. + +--- + +### Task 1: the hint, the reload, and the dashboard's check + +**Files:** +- Modify: `docs/devteam/dashboard/index.html`: CSS near `.av-bar` (~516-535); `TS` (~3543) and `tsSet` (~3554); `showArtifact` (~3569-3570); `openArtifact` (~3647); the `#av-reload` handler (~3669); `loadNow` after `drawOrHold(d)` (~5325). +- Test: `test/dashboard.test.mjs`, a new top-level test after the viewer test (~2021). + +**Interfaces (produced):** +- `TS.fpNow`, `TS.mainFpNow`: string | null. +- `avHint(): void`: sets `#av-reload`'s `stale` class and title from `TS` (stale iff `TS.kind === 'artifact' && TS.fp && TS.fpNow && TS.fp !== TS.fpNow`). +- `avFpSeen(fp: string|null, mainFp: string|null, slug: string, at: string): void`: if the stage shows that `slug`/`at` (and `at` isn't `tree:`), sets each given non-null value on `TS.fpNow`/`TS.mainFpNow`, then `avHint()`. + +- [ ] **Step 1: write the failing test** `the viewer's reload button says when a newer version is ready, and the press loads it`, `{ skip: canDrive ? false : 'needs Chrome and a Node with WebSocket', timeout: 180000 }`, using `threadsFixture('reload-hint')`, `landArtifact(F, 'hint-mock', mock('main'))` and the draft written at `F.old/.worktrees/demo-01-draft/docs/devteam/project/artifacts/hint-mock` as `mock('v1')`, where `mock(t)` is `{ 'artifact.json': …title 'Hint mock'…, 'index.html': `
${t}
` }`. The draft `mock('v1')` is written before the dashboard starts, so its first read already lists it. A page with `COUNT_FETCH`, and on each new document a listener, installed only where `document.getElementById('av-frame')` exists (the script also runs in the artifact's own frame), that pushes `e.data.title` of `{ lens: 1, type: 'ready' }` messages whose `e.source` is `#av-frame`'s `contentWindow` into `window.__titles`. The long waits use `soonIn(p, 45000)` and `soonIn(p, 37000)` (the default is 30 s) (plan-1 P5). Helper `amber()` = computed `color` of a probe `` appended to `#artifact-view`. Subtest **dashboard**: + - open `${B}/#artifact/wt/demo-01-draft/hint-mock`; wait for `__titles` to include `'v1'`; assert `#av-reload` has no `stale` and its title is `Load the page again`. + - press `#av-reload` (before any change); wait for a second `'v1'`. + - write `index.html` as `mock('v2')`; wait up to 45 000 ms for `stale`; assert title `A newer version is ready: reload to see it`, `getComputedStyle(#av-reload).outlineColor === amber()`, outline width `2px`, and `__titles` has no `'v2'` (nothing reloaded by itself). + - `Emulation.setEmulatedMedia` `{ features: [{ name: 'prefers-color-scheme', value: 'dark' }] }`: outline colour still equals `amber()`, and `amber()` differs from its light value; set it back to light. + - press `#av-reload`: wait for `'v2'` in `__titles`; assert no `stale`, title `Load the page again`, and `TS.fp === TS.fpNow`. +- [ ] **Step 2: run it, see it fail** — `/usr/local/bin/node --test --test-name-pattern "reload button says" test/dashboard.test.mjs`; expected: times out waiting for `stale`. +- [ ] **Step 3: implement.** CSS `#av-reload.stale:not(:focus-visible) { outline: 2px solid var(--amber); outline-offset: 1px; }`, so a keyboard focus ring still shows (plan-1 P6). Add `fpNow: null, mainFpNow: null` to `TS` and to `tsSet`'s reset. In `showArtifact`, pass `fpNow: fp, mainFpNow: mainFp` to `tsSet` (new stage), and on the same stage (a re-show, frame not reloaded) keep `fp`/`mainFp` as they are and take the given values only into `fpNow`/`mainFpNow` when non-null (plan-1 P2); call `avHint()` at its end. `avHint()` and `avFpSeen()` beside `showArtifact`. The reload handler: after resetting `src`, if `TS.kind === 'artifact'` copy `fpNow`→`fp` and `mainFpNow`→`mainFp` where non-null, then `avHint()`. `tsSet` also calls `avHint()` so a preview or close clears it. In `openArtifact`, a draft whose own Library row is absent while main's is present opens with main's fp as its `fp` (absence means equal to main; spec-2 N2). In `loadNow` after `drawOrHold(d)` (the raw `d`, so a held draw still feeds the check; spec-2 N4): if `TS.kind === 'artifact'`, `avFpSeen(row(TS.at)?.fp ?? null, row('main')?.fp ?? null, TS.slug, TS.at)` over `d.library?.artifacts`. Comments in the file's voice. +- [ ] **Step 4: run it, see it pass**, then the whole file: `/usr/local/bin/node --test test/dashboard.test.mjs` through `devteam.mjs gate test`. +- [ ] **Step 5: commit** `kit-206: the reload button's hint, and the dashboard's check`. + +### Task 2: the viewer tab's poll + +**Files:** +- Modify: `docs/devteam/dashboard/index.html`: `viewerBoot` (~5664-5695) and its comment. +- Test: `test/dashboard.test.mjs`, a second subtest in Task 1's test. + +**Interfaces (consumed):** `avFpSeen`, `TS.fpNow`, from Task 1. + +- [ ] **Step 1: write the failing subtest** **viewer tab**: write the draft back to `mock('v1')`; go to `${B}/view/artifact/hint-mock?at=demo-01-draft`; wait for `'v1'` in a fresh `__titles`; no `stale`. Write `mock('v3')`; wait up to 37 000 ms for `stale` and the stale title; `__titles` has no `'v3'`. Press: `'v3'` arrives, `stale` off. Then go to `${B}/view/artifact/hint-mock?at=tree:${title}
` }); + const T = landArtifact(F, 'hint-mock', mock('main')); + const draft = path.join(F.old, '.worktrees/demo-01-draft/docs/devteam/project/artifacts/hint-mock'); + const edit = (title) => { for (const [rel, text] of Object.entries(mock(title))) write(path.join(draft, rel), text); }; + // written before the dashboard starts: its first read already lists the draft + edit('v1'); + const { dash, port } = await dashboardOnFreePort(F.old, F.env({})); + const B = `http://127.0.0.1:${port}`; + undo.push(() => dash.stop()); + const chrome = await openChrome(); + undo.push(() => chrome.close()); + const p = await chrome.newPage(); + undo.push(() => p.close()); + await p.send('Page.enable'); + await p.send('Runtime.enable'); + await p.send('Emulation.setDeviceMetricsOverride', { width: 1200, height: 800, deviceScaleFactor: 1, mobile: false }); + await p.send('Page.addScriptToEvaluateOnNewDocument', { source: COUNT_FETCH }); + // the titles the stage's frame says when ready; the script runs in the artifact's own frame too, which has no stage + await p.send('Page.addScriptToEvaluateOnNewDocument', { source: `(() => { window.__titles = []; addEventListener('message', (e) => { const f = document.getElementById('av-frame'); if (f && e.source === f.contentWindow && e.data?.lens === 1 && e.data.type === 'ready') window.__titles.push(e.data.title); }); })();` }); + const soon = soonIn(p); + // two polls, and a read that may have started before the change: the dashboard's can wait DATA_WAIT_MS (20 s), the viewer's describe 12 s + const soonData = soonIn(p, 45000); + const soonView = soonIn(p, 37000); + const said = (title) => p.ev(`window.__titles.filter((x) => x === ${JSON.stringify(title)}).length`); + const button = () => p.ev(`(() => { const b = document.getElementById('av-reload'), s = getComputedStyle(b); return { stale: b.classList.contains('stale'), title: b.title, color: s.outlineColor, width: s.outlineWidth, style: s.outlineStyle }; })()`); + // the warning token as this scheme resolves it + const amber = () => p.ev(`(() => { const e = document.createElement('span'); e.style.color = 'var(--amber)'; document.getElementById('artifact-view').append(e); const c = getComputedStyle(e).color; e.remove(); return c; })()`); + const scheme = (value) => p.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value }] }); + const FRESH = 'Load the page again'; + const NEWER = 'A newer version is ready: reload to see it'; + + await t.test('on the dashboard: a draft changed on disk outlines reload in the warning colour, light and dark; the press loads it', async () => { + await p.send('Page.navigate', { url: `${B}/#artifact/wt/demo-01-draft/hint-mock` }); + await soon(`typeof __titles === 'object' && __titles.includes('v1')`, 'the draft in the frame'); + assert.deepEqual(await p.ev(`[TS.kind, TS.at, /^[0-9a-f]{64}$/.test(TS.fp ?? '')]`), ['artifact', 'demo-01-draft', true], 'opened with its fingerprint'); + let b = await button(); + assert.deepEqual([b.stale, b.title], [false, FRESH], 'nothing newer at open'); + // a press on the version shown changes nothing (a press before any check is the viewer tab's, below) + await p.click('#av-reload'); + await soon(`__titles.filter((x) => x === 'v1').length === 2`, 'the draft again'); + assert.equal((await button()).stale, false); + edit('v2'); + await soonData(`document.getElementById('av-reload').classList.contains('stale')`, 'the outline, within a poll'); + b = await button(); + const light = await amber(); + assert.deepEqual([b.title, b.color, b.width, b.style], [NEWER, light, '2px', 'solid'], 'the warning colour, light'); + assert.equal(await said('v2'), 0, 'nothing reloaded by itself'); + await scheme('dark'); + const dark = await amber(); + assert.notEqual(dark, light, 'the dark token is another colour'); + assert.equal((await button()).color, dark, 'the warning colour, dark'); + await scheme('light'); + // and some seconds on, still the old version: only the press loads it + await new Promise((r) => setTimeout(r, 3000)); + assert.equal(await said('v2'), 0, 'still nothing reloaded by itself'); + await p.click('#av-reload'); + await soon(`__titles.includes('v2')`, 'the new version in the frame'); + b = await button(); + assert.deepEqual([b.stale, b.title], [false, FRESH], 'the outline goes'); + assert.equal(await p.ev('TS.fp === TS.fpNow'), true); + }); + + await t.test('in a viewer tab: its own check of the draft, every 10 s; a press before any check still leaves the hint to come; a tree is never checked', async () => { + edit('v1'); + await p.send('Page.navigate', { url: `${B}/view/artifact/hint-mock?at=demo-01-draft` }); + await soon(`document.documentElement.classList.contains('viewer') && typeof __titles === 'object' && __titles.includes('v1')`, 'the draft in the viewer'); + // pressed before the tab's first check: only boot's ask so far + await p.click('#av-reload'); + await soon(`__titles.filter((x) => x === 'v1').length === 2`, 'the draft again'); + assert.equal(await p.ev(`__asked.filter((u) => u.startsWith('/api/view?')).length`), 1, 'no check yet when pressed'); + let b = await button(); + assert.deepEqual([b.stale, b.title], [false, FRESH]); + edit('v3'); + await soonView(`document.getElementById('av-reload').classList.contains('stale')`, 'the outline, within a poll'); + b = await button(); + assert.deepEqual([b.title, b.color, b.width], [NEWER, await amber(), '2px']); + await new Promise((r) => setTimeout(r, 3000)); + assert.equal(await said('v3'), 0, 'nothing reloaded by itself'); + await p.click('#av-reload'); + await soon(`__titles.includes('v3')`, 'the new version in the frame'); + assert.deepEqual([(await button()).stale, (await button()).title], [false, FRESH], 'the outline goes'); + // a published tree: after one 10 s tick has passed (four reads of the threads: boot's and three + // 5 s ticks, started beside it), still only boot's ask + await p.send('Page.navigate', { url: `${B}/view/artifact/hint-mock?at=tree:${T}` }); + await soon(`document.documentElement.classList.contains('viewer') && typeof TS === 'object' && TS.at === 'tree:${T}'`, 'the tree in the viewer'); + await soonView(`__asked.filter((u) => u === '/api/threads').length >= 4`, 'a 10 s tick gone by'); + assert.equal(await p.ev(`__asked.filter((u) => u.startsWith('/api/view?')).length`), 1, 'a tree is never checked'); + assert.equal((await button()).stale, false); + }); +}); test('Shift over an artifact: the overlay, what the lens says is under the pointer, a thread on that spot', { skip: canDrive ? false : 'needs Chrome and a Node with WebSocket', timeout: 240000 }, async (t) => { const undo = []; t.after(async () => { let err = null; for (const f of undo.reverse()) { try { await f(); } catch (e) { err ??= e; } } if (err) throw err; });