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}

${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:` (T from `landArtifact`), wait (`soonIn(p, 37000)`) until `__asked` holds at least four `/api/threads` (boot's read and three 5 s ticks: over 15 s after both intervals started, so one 10 s tick has passed), and assert `__asked` has exactly one `/api/view?` (boot's) and `#av-reload` has no `stale` (plan-1 P1). +- [ ] **Step 2: run it, see it fail** (no `stale` in the viewer tab). +- [ ] **Step 3: implement** in `viewerBoot`, beside the 5 s threads interval (after `await tlRefresh()`, so the test's threads count is the poll's clock), for an artifact: a `let asking = false` and a 10 s `setInterval` that returns unless visible, `TS.kind === 'artifact'`, `TS.at` not `tree:`, and not `asking`; it fetches `/api/view?kind=artifact&slug=&at=` for the `TS.slug`/`TS.at` taken at the ask, and on a 200 calls `avFpSeen(j.fp, j.main_fp, slug, at)`; anything else changes nothing; the fetch aborts after 15 s (`AbortController`; spec-2 N3); `asking` cleared in `finally`. Rewrite the boot comment to name this read (one `artifact describe` per ask, every 10 s while seen, main or a draft only). +- [ ] **Step 4: run it, see it pass**, then the whole file through the gate, and `/usr/local/bin/node --test test/dashboard.test.mjs` with the existing viewer test's "nothing else" check still green. +- [ ] **Step 5: commit** `kit-206: the viewer tab checks its artifact's fingerprint`. + +After both: verification.md (a row per done-when), then the finish (worker.md §8). The stale `/api/view` comment was told to the supervisor in m-20261115-013705816 and ruled on (below) (plan-1 P4). + +## Rulings + +- m-20261115-013720113-supervisor (re m-20261115-013705816): spec approved; leaving `devteam.mjs`'s stale "never polled" comment on `/api/view` for kit-106 is fine; the supervisor tells fast-threads. + +## From the spec reviews + +spec-2 (APPROVE 9d399ce) left N1-N4; they are taken here, not in spec.md, so its approval holds: the test's waits and no `POLL_FAST` (N1), main's fp for an unlisted draft at open (N2), a 15 s abort on the viewer's ask (N3), the check on every read, held or drawn (N4). diff --git a/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/final-1.md b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/final-1.md new file mode 100644 index 00000000..2baa9c70 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/final-1.md @@ -0,0 +1,25 @@ +# Final review, pass 1 + +- Reviewer: final-reviewer (fresh subagent, claude-opus) +- Reviewed: 2e02abc6542e52b2e1ae3bae370f2a9ee6b26076 (PR #242) + +**Summary.** I read the ticket, spec, plan record, verification, task reviews and PR body, viewed both screenshots, and read the full diff of `docs/devteam/dashboard/index.html` and `test/dashboard.test.mjs` with the code around it: `TS`/`tsSet`, `showArtifact`, `avHint`/`avFpSeen`, `openArtifact`, `closeArtifact`, the `#av-reload` handler, `route`, `loadNow`, `viewerArtifact`/`viewerBoot`, `tArtFpNow` and `tsSubject`. `/usr/local/bin/node --test --test-name-pattern "reload button says" test/dashboard.test.mjs` at this head passed 3/3 (the test and both subtests). + +Done-whens: +1. **The check and the outline: met.** The dashboard takes the fingerprint from the Library rows after each `/api/data` read (the `loadNow` hook). A viewer tab asks `/api/view` every 10 s while visible, for main or a draft only, one ask at a time, aborting after 15 s, dropping a reply when the stage has moved on. The test checks the outline's computed colour equals a probe's `var(--amber)` in light and in emulated dark (the two differ), the 2px width and the title. The screenshots show the outline in both schemes. +2. **Press clears it, nothing reloads by itself: met.** The frame still shows the old `` when the hint appears and again 3 s later; after the press it shows the new one, `stale` is off and the title is back to "Load the page again". The reload handler copies only fingerprints it knows. +3. **The dashboard test: met.** Both hosts, a press before the first check, and a tree never asked about. Per the task reviews, the T1-1 and T1-2 mutations and the removed tree guard each fail it. +4. **CI and no file added: partly evidenced.** No file is added outside the ticket folder; changes are limited to index.html and the test plus records. All 8 test.yml jobs were still pending when I checked; the merge gate covers it. + +Behaviour in context: +- **Thread "changed since" (`tArtFpNow`) and new threads (`tsSubject`).** `TS.fp` stays the fingerprint the frame loaded; before this change it was set only at open (`route()` runs only on the first read and on `hashchange`). "Changed since" works as before, and new threads carry the fingerprint of what is on screen. Reload moves `fp` and `mainFp` forward, matching the frame after the press. +- **Previews, close, switching stages.** Each goes through `tsSet`, which clears both new fields and calls `avHint`. The reload handler leaves previews alone. +- **The owner's framed wrapper.** It shows only trees, so it never polls. In the framed case where a draft is gone (a 404, shown "held"), the tab polls and gets 404s, which change nothing. +- **Code style.** Comments and names match the file. The new poll avoids the names the existing viewer test forbids; that test passed in the whole-file gate run at 7f62d29 and the code is unchanged since. Nothing project-specific. + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| F1 | Minor | spec acceptance check 3 (flake risk) | test/dashboard.test.mjs, viewer-tab subtest, "no check yet when pressed" | The assertion that only boot's `/api/view?` has been asked depends on the frame loading and the press landing within the first 10 s tick after `tlRefresh`. On a very slow runner it could fail: a flake, not a false pass. | accepted: the load and press take about 1 s against a 10 s window, and a failure is loud; if CI shows it, the fix is to press on the first `ready` | +| F2 | Minor | spec Edge cases (ruling m-20261115-013720113) | docs/devteam/scripts/devteam.mjs, the `/api/view` comment | The comment still says `/api/view` is "never polled"; the supervisor ruled to leave it for kit-106, outside this ticket's touches. Noted so it is tracked. | ruled (m-20261115-013720113): left for kit-106; the supervisor tells fast-threads | + +Verdict: APPROVE 2e02abc6542e52b2e1ae3bae370f2a9ee6b26076 diff --git a/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/plan-1.md b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/plan-1.md new file mode 100644 index 00000000..dc2005ac --- /dev/null +++ b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/plan-1.md @@ -0,0 +1,27 @@ +# Plan review, pass 1 + +- Reviewer: plan-reviewer (fresh subagent, claude-opus) +- Reviewed: 34ee7d3a4a92f707d3ba4ae86c8a869537d6bd50 (plan.md) + +**What I read.** At 34ee7d3: the plan, the spec, spec-1 and spec-2, the ticket, and quality.md §3 and §6; in `index.html` the tokens (~30-45), the viewer CSS (~516-540), `#av-reload` (~779, ~3669), `TS`/`tsSet`/`showArtifact` (~3543-3575), `openArtifact`/`route` (~3638, ~3685), the frame `load` and lens `message` handlers (~5171-5200), `drawOrHold`/`load`/`loadNow` (~5253-5330) and `viewerArtifact`/`viewerBoot` (~5644-5695); in `devteam.mjs` `ARTIFACT_LENS` (`ready` only in answer to `hello`, carrying `document.title`), `draftWorktree`/`draftArtifacts` (a plain directory under `.worktrees/` is a draft), `/api/view` (a 12 s describe; `ARTIFACT_AT` takes worktree names), the artifact route (`no-store`) and `dashboardData`/`DATA_WAIT_MS`; in the tests `soonIn`, `POLL_FAST`, `landArtifact`, `COUNT_FETCH`, the existing viewer test, `threadsFixture`, `chrome.mjs` (`send` is generic, so `Emulation.setEmulatedMedia` works) and `test/browser/dev-links.mjs`, which already clicks `#av-reload` and listens to lens messages from the parent page. + +**Coverage.** Every done-when and spec points 1-5; N1-N4 taken in as spec-2 says. + +**Interfaces.** `avHint`, `avFpSeen(fp, mainFp, slug, at)` and `fpNow`/`mainFpNow` are used the same way in both tasks. + +**Feasibility.** The lens `ready` listener on the parent works (messages from an opaque-origin frame reach the parent; `e.source` compares with `#av-frame.contentWindow`); the `#artifact/wt/<wt>/<slug>` route and a draft folder under `F.old/.worktrees/` work; the computed `outline-color` against a probe and the emulated dark scheme work; the real 10 s polls fit in the 180 s timeout. + +**Existing tests.** They keep passing: the viewer test's "nothing else" list allows `/api/view?`; the `viewerBoot` checks (forbidden names, `postMessage`, `location.*`) are not tripped; the fault tests are unaffected. + +**What blocks.** P1: the tree check waits a fixed 11 s from navigation, but the poll's interval only starts after boot's awaits, so the check would usually pass even with the `tree:` guard broken. + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| P1 | Important | spec "The change" 2 (a `tree:` version is never checked); plan Review focus 4; quality.md §3 (missing test with a plausible failure path) | plan Task 2 Step 1; `index.html` `viewerBoot` ~5664-5695 | The 10 s interval is created after `await viewerArtifact()` and, beside the threads interval, after `await tlRefresh()`. `go()` returns at the start of boot, so the interval starts 1-3 s later and its first tick lands after an 11 s assertion: a missing `tree:` guard would still pass. Create the view poll beside the 5 s threads interval and wait for at least four `/api/threads` in `__asked` before asserting one `/api/view?`, or another wait that provably covers one tick. | fixed: the poll sits beside the threads interval; the test waits for four `/api/threads` | +| P2 | Minor | spec "The change" 1 | plan Task 1 Step 3; `index.html` ~3570 `else Object.assign(TS, { fp, mainFp })` | On a re-show of the same stage the frame is not reloaded, but `TS.fp` is overwritten with `DATA`'s newer fp, so the hint clears while the frame still shows the old page. Keep `fp`/`mainFp` there, or accept it. | fixed: a re-show keeps `fp`/`mainFp`; the given values go only to `fpNow`/`mainFpNow` | +| P3 | Minor | quality.md §6 (the TL;DR says what could go wrong) | plan TL;DR | No risk bullet. Swap one bullet for the main risk. | fixed: a risk bullet | +| P4 | Minor | spec Edge cases, last bullet | plan, the "After both" line | No step tells the supervisor about the stale `/api/view` comment. | fixed: told in m-20261115-013705816, ruled in m-20261115-013720113; both in the plan | +| P5 | Suggestion | none (robustness) | plan Task 1 Step 1 | (a) `addScriptToEvaluateOnNewDocument` also runs in the artifact's frame: guard the listener on `#av-frame`. (b) Write the v1 draft before the dashboard starts. (c) The long waits need `soonIn(p, 45000)`/`soonIn(p, 37000)`; the default is 30 s. | fixed: all three in Task 1 Step 1 | +| P6 | Suggestion | none (taste, accessibility) | plan Task 1 Step 3 CSS | `#av-reload.stale` overrides the `:focus-visible` ring. Consider `#av-reload.stale:not(:focus-visible)`. | fixed: `:not(:focus-visible)` | + +Verdict: CHANGES 34ee7d3a4a92f707d3ba4ae86c8a869537d6bd50 diff --git a/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/plan-2.md b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/plan-2.md new file mode 100644 index 00000000..f063fea0 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/plan-2.md @@ -0,0 +1,22 @@ +# Plan review, pass 2 + +- Reviewer: plan-reviewer 2 (fresh subagent, claude-opus) +- Reviewed: dd5aad7f6ba02460be9c8c624223804b7538e93b (plan.md) + +**What I read.** At dd5aad7: the plan, the spec (approved at 9d399ce), plan-1, the ticket, and quality.md §3 and §6. In `index.html`: `TS`, `tsSet` and `showArtifact` (~3543-3575), `openArtifact`, `closeArtifact` and the `#av-reload` handler (~3638-3670), `route` (~3685) and its callers, `drawOrHold` and `loadNow` (~5253-5325), `tlRefresh` and every place that calls it, and `viewerArtifact` and `viewerBoot` (~5644-5695). In the tests, `COUNT_FETCH`. In the log, m-20261115-013720113-supervisor, which answers m-20261115-013705816-kit-206-viewer-reload-hint. + +**Plan-1 dispositions.** All six hold. +- **P1 holds.** Task 2 Step 3 puts the view poll after `await tlRefresh()`, next to the 5 s threads interval, so both timers start together; Step 1 waits for four `/api/threads` asks before asserting a single `/api/view?`. In a viewer tab, `/api/threads` is asked only by boot and the 5 s tick; every other caller of `tlRefresh` (4763, 4831, 4874, 4942, 5065, 5276) runs only when a card is open, an answer is sent or the dashboard draws, none of which happens in this subtest. So the fourth ask (the 15 s tick) comes after the 10 s view tick, and a missing `tree:` guard would put a second `/api/view?` in the list. +- **P2 holds.** On a re-show, `fp`/`mainFp` are kept and the new values go only into `fpNow`/`mainFpNow` (see S1). +- **P3 holds.** There is a risk bullet, and the TL;DR has exactly seven bullets. +- **P4 holds.** The ruling is in the log and quoted in the plan. +- **P5 holds.** (a), (b) and (c) are all in Task 1 Step 1. +- **P6 holds.** The CSS uses `:not(:focus-visible)`. + +**New issues from the fixes.** Only S1. The re-show branch in `openArtifact` can almost never run: `route` calls `openArtifact` only on the first draw (while `DATA` is null) and on `hashchange`, which lands on the same `slug`/`at` only through an equivalent but differently encoded hash; the viewer calls `showArtifact` once. + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| S1 | Suggestion | spec Edge cases ("A draft removed while open: the dashboard finds no row … neither changes the hint") | plan Task 1 Step 3 (P2 re-show and N2 fallback together); `index.html` `openArtifact` ~3647 | On a re-show, `openArtifact`'s N2 fallback gives main's fp for a draft whose row is missing, and P2 writes it into `fpNow`; for a draft removed while open the hint would then come on, though the spec says a missing row changes nothing. Practically unreachable, so acceptable as written; or apply the fallback only when the stage is new. | taken in the build: the fallback applies only when the stage is new (no change to the plan's interfaces) | + +Verdict: APPROVE dd5aad7f6ba02460be9c8c624223804b7538e93b diff --git a/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/spec-1.md b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/spec-1.md new file mode 100644 index 00000000..6bc7fee0 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/spec-1.md @@ -0,0 +1,39 @@ +# Spec review, pass 1 + +- Reviewer: spec-reviewer (fresh subagent, claude-opus) +- Reviewed: 5f3e192f35bef54fd195680ca94187e03c511040 (spec.md) + +Sources read: the spec, `ticket show kit-206-viewer-reload-hint` (four done-whens; touches are `index.html` and `test/dashboard.test.mjs`), and quality.md §3. In `index.html`: the tokens (~30-45), the `#av-reload` markup (~779) and handler (~3669), `TS`/`tsSet`/`showArtifact` (~3543-3575), `openArtifact` (~3638), `tArtFpNow` (~4622) and its "changed since" use (~4699), `tsSubject` (~5090), the frame `load` and lens `ready` handlers (~5171, ~5191), `holding`/`drawOrHold`/`load`/`loadNow` (~5220-5320), and `viewerArtifact`/`viewerBoot` (~5644-5695). In `devteam.mjs`: `/api/data` (~14377), `/api/view` (~14415), `describing` (~13903), `artifact describe` (~7533), `artifactSource`/`artifactAt`, `diskOid`/`fingerprint` (~7184-7196), `describeArtifact`, `draftArtifacts`, the `/artifact/` response headers (`no-store`, ~14580), and ARTIFACT_LENS `hello`→`ready` (~6396). Also the viewer tests in test/dashboard.test.mjs (~1834-2020). + +Every done-when is restated faithfully, and the commit adds only the spec. The approach holds up against the code: +- **`TS.fp` is what the frame loaded.** Nothing updates it after open on either host. `tsSubject` stamps new threads with it, and `tArtFpNow` compares a thread's fp with it, so keeping it fixed until reload is right. Moving it on reload makes older threads read "changed since", which is correct. +- **The dashboard has the draft's fp on every poll.** `/api/data` runs `dashboard-data` fresh on each read, and `draftArtifacts` hashes the working tree from disk (oid cache keyed by path, size and mtime), so an uncommitted edit moves the fp. +- **Reload fetches the new bytes.** `/artifact/` is served `no-store`. +- **Trees, previews and the framed wrapper need no polling.** The wrapper's gateway grants only published trees, so "never check a tree" means the wrapper never polls. Previews are reset by `tsSet('preview')`. +- **Polling `/api/view` every 10 s is acceptable as stated.** It runs only in visible tabs, the server shares concurrent asks per slug|at, and kit-106 supersedes it. The stale comment in `devteam.mjs` is named and handed to the supervisor. + +Values and refusals: +- P1 (harness): not involved. Passes. +- P2 (nothing project-specific): nothing project-specific. Passes. +- P3 (decide and report): the open-time race is decided and accepted. Passes. +- P4 (owner-only asks): none needed. Passes. +- P5 (authority): not touched. N/A. +- P6 (keep it simple): the design is the simple one. Passes. +- R1 (no self-widening of authority): no authority change. Passes. +- R2 (plain owner-facing text): the new title is plain words. Passes. + +The blocking defect is F1: reload copies `fpNow` into `TS.fp` without checking that `fpNow` is known, so a press before the first check erases `TS.fp`; the stage then never shows the hint again, and threads drawn on it lose their fingerprint. + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| F1 | Important | done-when 1 and 2; spec "The change" 1, 4 and 5 | spec "The change" 4; code `tsSet` ~3554, `tsSubject` ~5090, `tArtFpNow` ~4622 | Reload "sets `TS.fp = TS.fpNow`", but `fpNow` starts null (`tsSet` resets it, nothing sets it to `fp` at open) and stays null until the first check (up to 10 s), and after a 404 or error. A reload in that window, likely right after the owner opens a draft they just edited, sets `TS.fp = null`: the hint never shows for this open, `tsSubject` stops putting `fp` on new threads, and `tArtFpNow` returns null. Set `fpNow = fp` at open, or have reload copy only a known `fpNow`. Add a check: reload before the first poll, then change the file, and the hint still comes. | fixed: `fpNow`/`mainFpNow` start equal to `fp`/`mainFp` at open, reload copies only a known one, and the check is added | +| F2 | Minor | spec "The change" 2, viewer tab ("the same ask it boots with") | `viewerArtifact` ~5655-5663 | Boot asks `VIEWER.at` first and, after an unframed 404, shows main (`TS.at = 'main'`). "The same ask" read as `VIEWER.at` 404s every poll and never checks the main page shown. Say the poll asks `TS.slug` and `TS.at`, and drops a reply for a stage it no longer shows. | fixed: the poll asks `TS.slug`/`TS.at`; a reply for another stage is dropped | +| F3 | Minor | done-when 1 ("in light and dark"); quality.md §3 | Acceptance check 1 | The check reads `outline-color` in one scheme only, and `getComputedStyle` gives `rgb(...)` while the token is hex. Compare against a probe element's computed `color: var(--amber)`, and repeat under `Emulation.setEmulatedMedia` with `prefers-color-scheme: dark`. | fixed: probe element comparison, in light and under emulated dark | +| F4 | Minor | done-when 3 ("the frame shows the change") | Acceptance checks 2 and 3; lens `hello` ~6396, stage `ready` handler ~5191 | The lens reports `document.title` (the page's `<title>`), not `artifact.json`'s title; and the stage keeps nothing of `ready.title` for an artifact, so the test needs its own `message` listener. Say both, so no production `TS.title` is added just for the test. | fixed: the draft's `index.html` `<title>`; the test listens for `ready` itself | +| F5 | Minor | done-when 3 ("within one poll"); quality.md §3 (a test that can flake) | Acceptance check 3 | A `dashboard-data` run in flight when the file changes returns the old fp, and `load()` skips a tick while a read is in flight, so the hint can take longer than "10 s plus the read's time". Give the wait a margin (two poll periods plus the read timeout) and say the done-when is met by the first read that starts after the change. | fixed: wait up to 2 × 10 s + 12 s; met by the first read after the change | +| F6 | Minor | spec "The change" 4; Edge cases | spec "The change" 4 | The open-time race also happens at reload: the frame can fetch a file newer than the last `fpNow`, so new threads carry an older fp and the next poll shows a spurious hint. Accept it in Edge cases, or re-check right after a reload. | accepted in Edge cases, beside the open-time race | +| F7 | Minor | spec "The change" 2, viewer tab | `viewerBoot` ~5694; `/api/view` describe timeout 12 s | An ask can take up to 12 s, longer than the 10 s period, so two asks can overlap and an older reply land last. One ask in flight per tab. | fixed: one ask in flight per tab, a tick skipped while one is pending | +| F8 | Suggestion | existing test "it polls the threads and hello" (~1880); `viewerBoot` comment (~5666) | `viewerBoot` | The comment "Nothing else: a dozen viewer tabs cost a dozen cheap reads" becomes false and must be rewritten; and the existing test forbids `route(`, `render(`, `load(`, `loadNow(`, `loadSchemaDocs(` in `viewerBoot`'s text, so the new poll must not reuse those names. | fixed: both are in the spec | +| F9 | Suggestion | none (taste) | spec "The change" 2 and 4 | Both checks also get main's fp; updating `TS.mainFp` on reload would keep a tree thread's "changed since" right on a draft stage. The viewer bar's title also stays the boot-time one after a reload. | taken in part: `mainFp` follows on reload; the bar title refresh is out of scope | + +Verdict: CHANGES 5f3e192f35bef54fd195680ca94187e03c511040 diff --git a/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/spec-2.md b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/spec-2.md new file mode 100644 index 00000000..e210a2ef --- /dev/null +++ b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/spec-2.md @@ -0,0 +1,31 @@ +# Spec review, pass 2 + +- Reviewer: spec-reviewer 2 (fresh subagent, claude-opus) +- Reviewed: 9d399cedb60788ceae2ec9540221f4395f3830c1 (spec.md) + +Sources read: `ticket show kit-206-viewer-reload-hint`, the spec and `reviews/spec-1.md` at 9d399ce, and quality.md §3. In `index.html`: the tokens (~34, ~42), `#av-reload` (~779, ~3669), `TS`/`tsSet`/`showArtifact` (~3543-3575), `openArtifact` (~3638), `route` (~3685), `tArtFpNow` (~4622), `tsSubject` (~5090), the frame `load` and lens `ready` handlers (~5171-5200), `holding`/`drawOrHold`/`load`/`loadNow` (~5232-5320), `viewerArtifact`/`viewerBoot` (~5644-5695) and the boot line (~5711). In `devteam.mjs`: `draftArtifacts` (~7382), `artifact describe` (~7544, which emits `fp` and `main_fp`), the shared `dashboardData` run (~14244), `/api/data` with `DATA_WAIT_MS` (~14377, ~11994) and `/api/view` (~14415). In `test/dashboard.test.mjs`: `POLL_FAST` (~325) and the viewer tests (~1833-2020). + +Every pass-1 fix holds in the spec text: +- **F1 holds.** `fpNow`/`mainFpNow` start equal to `fp`/`mainFp` through `tsSet`, reload copies only a known value, and acceptance check 3 includes the press-before-first-check case. +- **F2 holds.** The tab asks for `TS.slug`/`TS.at`, so after a fallback it asks for main, and a reply for a stage no longer shown is dropped. +- **F3 holds.** The colour check compares against a probe element styled `color: var(--amber)`, in light and again under emulated dark. +- **F4 holds.** The test reads the draft's `index.html` `<title>` through its own `message` listener, so no production state is added. +- **F6 holds.** The reload-time race is accepted in Edge cases. +- **F7 holds.** One ask in flight per tab. +- **F8 holds.** The `viewerBoot` comment is rewritten, and the forbidden names are avoided (`\bload\(` does not match a name like `reload(`). +- **F9 holds.** `mainFp` follows on reload. + +Existing tests the change touches: +- The existing viewer test's "nothing else" assertion already allows `/api/view?`, so the new ask does not break it. +- The 500 and network-error fault tests inject the fault on every `/api/view` call, so the poll leaves them unchanged. + +No Critical or Important issue found: touches are respected; the `tree:` and preview exclusions keep the owner wrapper from polling; the title change is plain words, so V and R checks are unchanged from pass 1. What remains is two Minors (N1, the timing bound F5's fix added uses the wrong constant; N2, one edge case described incompletely) and two robustness suggestions (N3, N4). + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| N1 | Minor | done-when 3 ("within one poll"); quality.md §3 (a test that can flake) | spec, Acceptance check 3; `devteam.mjs` `dashboardData` ~14248 (`dataRun ??=`), `/api/data` ~14380 (`DATA_WAIT_MS`, 20 s) | On the dashboard, "met by the first read that starts after the change" is not true: `/api/data` shares a `dashboard-data` run already in flight, and a run slower than `DATA_WAIT_MS` (20 s) is answered stale with the old fp. "12 s" is `/api/view`'s describe timeout, not anything on the `/api/data` path. Restate as "the first `dashboard-data` run that starts after the change", size the dashboard's wait from `DATA_WAIT_MS` and `READ_GIVE_UP_MS`, and say whether the test uses `POLL_FAST` (which would also make the viewer tab ask `/api/view` every 300 ms). | taken in the plan: the test does not use `POLL_FAST` (real 10 s polls); the dashboard waits up to 2 × 10 s + `DATA_WAIT_MS` (20 s) + 5 s, the viewer tab 2 × 10 s + 12 s + 5 s; met by the first `dashboard-data` run (or `/api/view` ask) that starts after the change | +| N2 | Minor | done-when 1; spec Edge cases, first bullet | spec Edge cases; `openArtifact` ~3647; `draftArtifacts` ~7395 | A draft equal to main opened on the dashboard keeps `TS.fp` null, so after an edit (now listed, `fpNow` known) the "both known" rule never shows the hint for that open. Say it plainly as accepted, or at open take main's fp for a draft whose row is absent while main's is present. The test should open a draft that already differs from main. | taken in the plan: `openArtifact` takes main's row's fp for a draft whose own row is absent while main's is present (absence means equal to main); the test's draft differs from main at open | +| N3 | Suggestion | none (robustness) | spec "The change" 2, viewer tab | The one-in-flight ask has no client-side timeout; a fetch that never settles stops all later checks in that tab. Abort a little past 12 s. | taken in the plan: the ask aborts after 15 s | +| N4 | Suggestion | none (taste) | spec "The change" 2, dashboard; `drawOrHold` ~5253 | "After each `/api/data` read is drawn" skips reads `holding()` holds back; take `fpNow` from each read as it arrives in `loadNow`. | taken in the plan: `loadNow` reads the raw `d`, held or drawn | + +Verdict: APPROVE 9d399cedb60788ceae2ec9540221f4395f3830c1 diff --git a/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/task-1-2.md b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/task-1-2.md new file mode 100644 index 00000000..01f54e3a --- /dev/null +++ b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/task-1-2.md @@ -0,0 +1,19 @@ +# Task 1 review, pass 2 + +- Reviewer: task-reviewer 2 (fresh subagent, claude-opus) +- Reviewed: 7f62d296b5cb8f837d8e49828ec14fd719bd10c5 (task 1 fixes) + +**Summary.** A cold, diff-only check of `git diff 21ee88e 7f62d29` against T1-1..T1-3 in `reviews/task-1.md`. In a `git archive` copy of 7f62d29 the new test passes (3/3, about 46 s). Pass 1's two mutations, re-run in scratch copies: + +- **T1-1 holds.** Mutation 1 (no open-time `fpNow: fp, mainFpNow: mainFp`, and an unguarded copy of `fpNow` into `fp` on reload) now fails the viewer-tab subtest ("timed out: the outline, within a poll"). That subtest presses reload after `'v1'` arrives a second time and asserts only boot's `/api/view?` has been asked, so the press is shown to come before the tab's first check; a check that fired early would fail the test, not let it pass. The dashboard comment now says what that press there really shows, and it is accurate. +- **T1-2 holds.** Mutation 2 (the frame reloads itself 1.5 s after the hint) now fails both subtests on the 3 s wait and second "nothing reloaded by itself" check (`1 !== 0`). +- **T1-3** (240000 timeout) was a Suggestion; keeping it, noted in verification.md, is acceptable. + +Nothing else in the diff touches task 1's code. + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| + +None. + +Verdict: APPROVE 7f62d296b5cb8f837d8e49828ec14fd719bd10c5 diff --git a/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/task-1.md b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/task-1.md new file mode 100644 index 00000000..c59764ba --- /dev/null +++ b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/task-1.md @@ -0,0 +1,26 @@ +# Task 1 review + +- Reviewer: task-reviewer (fresh subagent, claude-opus) +- Reviewed: 21ee88e246c0c2d1b08243cfbc17683adee0ad7d (task 1) + +**Summary.** I read the diff against plan Task 1, the spec, plan-2 S1, and the code around it: `TS` and `tsSet`, `showArtifact`, `showPreview`, `openArtifact`, `closeArtifact`, the `#av-reload` handler, and `drawOrHold`, `load` and `loadNow`. Only `docs/devteam/dashboard/index.html` and `test/dashboard.test.mjs` changed. The new test passes at the head (2/2, under Node 22). + +The production code looks correct: +- **`tsSet` calling `avHint()` is safe.** Its three callers (`showArtifact`, `showPreview`, `closeArtifact`) all run after the DOM exists (the script at line 814 comes after `#av-reload` at line 781), and `avHint` is a hoisted declaration. On a preview or after a close it gives the class off and today's title. +- **The re-show branch** keeps `fp`/`mainFp` and only moves `fpNow`/`mainFpNow` forward when a value is given (P2). +- **`openArtifact`'s fallback** to main's fp applies only on a new stage, as S1's disposition says. Its `tree:` guard can never fire (the hash regex never yields `tree:`), but it does no harm. +- **The reload handler** does nothing for a preview and copies only fingerprints it knows. +- **The `loadNow` hook** uses the raw `d`, so a held draw still feeds the check; it runs only on the dashboard (`load()` returns early in a viewer tab), and on the first read after `route()` → `openArtifact`. +- **Comments** match the file's voice and density. + +Two mutations in a scratch copy (a `git archive` of 21ee88e) passed the test, so two properties it claims are not pinned: +1. **No open-time init, unguarded copy** (the spec-1 F1 failure mode): the press "before any check" in fact comes after the first `/api/data` read's `loadNow` hook has set `fpNow`. +2. **A delayed self-reload** (the frame's `src` reset 1.5 s after the hint): `said('v2') === 0` is checked the instant `stale` appears and never again. + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| +| T1-1 | Important | spec acceptance check 3 (spec-1 F1); plan Review focus 1 | test/dashboard.test.mjs, the dashboard subtest, "a press before any check" | On the dashboard the first read sets `fpNow` before the press, so mutation 1 passes. Pin it where a press can come before any check (Task 2's viewer tab: press right after `'v1'`, then change the file and expect `stale`), and fix or drop the inaccurate comment here. | fixed in Task 2's commit: the viewer-tab subtest presses before its first check (asserting only boot's `/api/view?` so far), then expects the hint; the dashboard comment now says what that press shows. Mutation 1 now fails the test | +| T1-2 | Important | spec The change §4; acceptance check 2 | test/dashboard.test.mjs, `said('v2') === 0` | Checked the instant `stale` appears; mutation 2 passes. Check again right before the press, after a set wait of a few seconds. | fixed: a 3 s wait and a second check before the press, in both subtests | +| T1-3 | Suggestion | plan Task 1 Step 1 (`timeout: 180000`) | the new test's options | 240000, not the plan's 180000: harmless, matches neighbours, leaves room for Task 2's subtest. | kept at 240000: two subtests with real polls (noted in verification.md) | + +Verdict: CHANGES 21ee88e246c0c2d1b08243cfbc17683adee0ad7d diff --git a/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/task-2.md b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/task-2.md new file mode 100644 index 00000000..ef3366f4 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/reviews/task-2.md @@ -0,0 +1,27 @@ +# Task 2 review + +- Reviewer: task-reviewer 2 (fresh subagent, claude-opus) +- Reviewed: 7f62d296b5cb8f837d8e49828ec14fd719bd10c5 (task 2) + +**Summary.** I read the `viewerBoot` change (index.html ~5705-5748), `avFpSeen`/`avHint` (~3586-3599), `showArtifact`, the reload handler, and the new viewer-tab subtest, against plan Task 2, the global constraints and spec-2 N3. + +- **One ask in flight.** `asking` is set before the fetch and cleared in `finally`; a tick returns early while it is set. +- **Abort at 15 s.** An `AbortController` and a 15 s `setTimeout` cover the fetch and `r.json()`; the timer is cleared in `finally`. An abort or error lands in `catch {}` and changes nothing. +- **Stage captured at ask.** `const { slug, at } = TS` is read before the await; `avFpSeen` drops a reply whose `slug`/`at` no longer matches. A viewer tab never re-stages after boot, so the guard is defensive. +- **Tree and preview never polled.** The tick returns for `TS.kind !== 'artifact'` and for `TS.at` starting with `tree:`, before any fetch. +- **Visibility.** The tick returns unless `document.visibilityState === 'visible'`. The poll starts after `await tlRefresh()`, as planned. +- **Forbidden names.** The new code calls only `setInterval(`, `AbortController`, `setTimeout(`, `fetch(`, `r.json(`, `avFpSeen(` and `clearTimeout(`; none match `/\b(route|render|load|loadNow|loadSchemaDocs)\(/`. The "nothing else" allow-list already admits `/api/view?`; the full file passed 155/155 at this head. +- **The comment.** The boot comment names the new read (main or a draft, every 10 s while seen, one describe per ask); the inline comment gives the reason for one-at-a-time and the tree rule. Both match the file's voice and density. + +**Could the subtest pass vacuously?** +- The `stale` wait can only be met by the poll: in the viewer tab nothing else sets `fpNow`, and the frame is shown never to reload by itself. +- The tree check waits for at least four `/api/threads` reads; the threads interval and the poll start in the same task, so a 10 s tick has passed. `__asked` is fresh per document, and `TS.at === 'tree:T'` rules out a fallback to main. Mutation 3 (the poll's `tree:` guard removed) fails with `2 !== 1`. +- One-in-flight, the abort and visibility cannot be driven without a slow describe or a hidden tab; reading the code confirms them, and the plan does not ask for tests of them. +- "No check yet when pressed" depends on frame load plus a press within about 10 s of boot; if it went wrong it would fail (a flake), not pass falsely. It held here under four parallel runs. + +| Id | Severity | Rule | Where | Finding | Disposition | +|---|---|---|---|---|---| + +None. + +Verdict: APPROVE 7f62d296b5cb8f837d8e49828ec14fd719bd10c5 diff --git a/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/screenshots/reload-hint-dark.png b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/screenshots/reload-hint-dark.png new file mode 100644 index 00000000..1e8dad1b Binary files /dev/null and b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/screenshots/reload-hint-dark.png differ diff --git a/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/screenshots/reload-hint-light.png b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/screenshots/reload-hint-light.png new file mode 100644 index 00000000..3839539e Binary files /dev/null and b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/screenshots/reload-hint-light.png differ diff --git a/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/spec.md b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/spec.md new file mode 100644 index 00000000..a40be559 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/spec.md @@ -0,0 +1,58 @@ +# kit-206-viewer-reload-hint: spec + +The artifact viewer's reload button shows an orange outline when the artifact changed since it was loaded. + +## Why + +The owner's ask (supervisor's terminal, 2026-11-15 01:25): until drafts reload live (fast-threads kit-106), the reload button should say when a reload is needed. The owner iterates on a draft by looking at it; today nothing tells them the draft on screen is behind the one on disk. When kit-106 lands, its live reload supersedes the outline (the supervisor told fast-threads so in m-20261115-012648683-supervisor). + +## How it works today + +- The stage (`#artifact-view`) shows an artifact in `#av-frame` through `showArtifact(slug, at, { fp, mainFp })`, which records the fingerprint of what it shows in `TS.fp` (`index.html` ~3559). `#av-reload` reloads the frame by clearing and resetting its `src` (~3669). +- Two hosts: + - **The dashboard** (`#artifact/main/<slug>`, `#artifact/wt/<worktree>/<slug>`): `openArtifact` takes `fp` from `DATA.library.artifacts` (the `/api/data` poll, every 10 s, `load()`). Nothing updates `TS.fp` afterwards. + - **A viewer tab** (`/view/artifact/<slug>?at=…`): `viewerArtifact` takes `fp` from `/api/view` (which runs `artifact describe --json`), once. The tab polls `/api/threads` every 5 s and `/api/hello` every 30 s, never `/api/data`. +- The Library's draft rows (`draftArtifacts`) list a draft only when its fingerprint differs from main's. +- `--amber` is the warning colour token, defined for light and for dark (`prefers-color-scheme: dark`). + +## The change + +1. **What the frame loaded.** `TS.fp` (and `TS.mainFp`) stay the fingerprints of what the open frame loaded. New `TS.fpNow` and `TS.mainFpNow` hold the latest seen for the stage's `slug`/`at`; `showArtifact` starts them equal to `fp`/`mainFp` when it sets the stage up (`tsSet`), so they are never behind what was loaded (spec-1 F1). +2. **Checking, on the normal poll.** + - Dashboard: after each `/api/data` read is drawn, if the stage shows an artifact at `main` or a draft, `fpNow` is the fp of the Library row with that slug and `at`, and `mainFpNow` the fp of its main row. A row that is absent changes nothing. + - Viewer tab: every 10 s while the tab is visible, if the stage shows an artifact at `main` or a draft, it asks `/api/view` for `TS.slug` and `TS.at` (what the stage shows, which after a fallback is main, not the address's `at`) and takes `fp` and `main_fp`. One ask in flight per tab: a tick while one is pending is skipped. A reply for a stage no longer shown (another slug or `at`) is dropped. A 404, an error or no answer changes nothing (spec-1 F2, F7). + - A `tree:` version never changes: never checked (so the owner wrapper, which shows only trees, never polls). A preview: never checked. + - `viewerBoot`'s comment ("Nothing else: a dozen viewer tabs cost a dozen cheap reads") is rewritten to say a tab showing main or a draft also asks `/api/view` every 10 s while seen: one `artifact describe` per ask. The new poll's names avoid `route`, `render`, `load`, `loadNow` and `loadSchemaDocs`, which the existing viewer test forbids in `viewerBoot` (spec-1 F8). +3. **The hint.** When `TS.fp` and `TS.fpNow` are both known and differ, `#av-reload` gets the class `stale`: a 2 px `var(--amber)` outline (light and dark by the token), and its title becomes "A newer version is ready: reload to see it". Otherwise the class is off and the title is "Load the page again". +4. **Reload.** Pressing `#av-reload` on an artifact reloads the frame as today, then sets `TS.fp = TS.fpNow` and `TS.mainFp = TS.mainFpNow`, each only when the newer one is known, and clears the hint. Nothing reloads by itself. +5. **Switching.** Opening another artifact, version or a preview resets both (`tsSet`), so the hint goes. + +## Acceptance checks (the done-whens) + +| # | Done-when | Check | +|---|---|---| +| 1 | While the viewer is open, the dashboard checks the artifact's fingerprint (as /api/view or describe reports it, for main or the draft shown) on its normal poll; on a difference from what the frame loaded, reload gets an orange outline (`--amber`, light and dark) and a title saying a newer version is ready | browser test: viewer tab and dashboard stage on a draft, file changed, `#av-reload` has `stale`, its title says a newer version is ready, and its computed `outline-color` equals the computed `color` of a probe element styled `color: var(--amber)`, checked in light and again under `Emulation.setEmulatedMedia` with `prefers-color-scheme: dark`; before the change it has no `stale` (spec-1 F3) | +| 2 | Pressing reload loads the new version and clears the outline; nothing reloads by itself | browser test: before the press the frame's lens still reports the old title; after it, the new one, and the class is off | +| 3 | A dashboard test: open a draft, change its file, outline within one poll; press it, outline goes, frame shows the change | the test in `test/dashboard.test.mjs`. "Within one poll" is met by the first read that starts after the change; a read already in flight may carry the old fp, so the test waits at most two poll periods plus the read's timeout (2 × 10 s + 12 s) (spec-1 F5). It also presses reload before the first check, then changes the file, and the hint still comes (spec-1 F1) | +| 4 | test.yml passes on every job; no file is added | CI at the head; `git diff --name-only --diff-filter=A origin/main` lists only this ticket's records | + +"The frame shows the change": the frame is opaque-origin, so the test reads what the artifact's lens tells the stage. Its `ready` message carries the page's `document.title`, the `<title>` in the draft's `index.html` (not `artifact.json`'s title), and the stage keeps none of it for an artifact, so the test adds its own `message` listener on the stage page and waits for a `ready` with the new `<title>` after the press. No production state is added for the test (spec-1 F4). + +## Contracts + +None provided or changed. Consumed: `/api/view` (artifact kind) and `/api/data`'s `library.artifacts[].fp`, unchanged. + +## Edge cases + +- A draft whose fingerprint equals main's is absent from the Library rows: the dashboard has no `fpNow` for it and shows no hint (the viewer tab, which asks `/api/view`, does). A draft opened from the Library is listed, so the usual case works. +- A draft removed while open: the dashboard finds no row, the viewer tab gets a 404; neither changes the hint. +- The fingerprint taken at open can be a poll older than the file the frame fetched; the hint then shows once though nothing is newer, and one reload clears it. The same holds at reload: a file changed after the last check and before the press is on screen while `TS.fp` names the version before it, so threads drawn then carry that older fp and the next check shows the hint once more. Accepted for both, until kit-106 (spec-1 F6). +- `/api/view`'s comment in `devteam.mjs` says the ask is "never polled". The viewer tab now asks it every 10 s while visible (one `artifact describe` child per ask, shared by concurrent asks). That file is outside touches; the comment goes stale until kit-106's `/api/artifact-fp` replaces this poll. Told to the supervisor in the review request. + +## Out of scope + +Live reload (kit-106), refreshing the viewer bar's title after a reload, keeping the scroll position across reload, a cheaper fingerprint endpoint, any change to `devteam.mjs`. + +## Rulings + +None yet. diff --git a/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/verification.md b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/verification.md new file mode 100644 index 00000000..34158f88 --- /dev/null +++ b/docs/devteam-dev/tickets/kit-206-viewer-reload-hint/verification.md @@ -0,0 +1,20 @@ +# kit-206 verification + +At 225ebfba7ba9388c901e8f1d5644aaeb7c2c4f40 (code as at 7f62d29; later commits add records only). `origin/main` merged: already up to date. Browser tests run with Node 22 (`/usr/local/bin/node`); the machine's default Node 18 has no WebSocket and skips them, as CI's Node 18 job does. + +| # | Done-when | Command | Result | +|---|---|---|---| +| 1 | While the viewer is open, the dashboard checks the artifact's fingerprint on its normal poll; on a difference from what the frame loaded, reload gets an orange outline (warning token, light and dark) and a title saying a newer version is ready | `/usr/local/bin/node --test --test-name-pattern "reload button says" test/dashboard.test.mjs` | pass, 3/3 (the test, its dashboard subtest and its viewer-tab subtest): `stale` within one poll after the draft changes, title "A newer version is ready: reload to see it", `outline-color` equal to a probe's computed `var(--amber)` in light and under emulated dark (the two differ), 2px solid. Screenshots: `screenshots/reload-hint-light.png`, `screenshots/reload-hint-dark.png` | +| 2 | Pressing reload loads the new version and clears the outline; nothing reloads by itself | same | pass: the frame's lens still says the old `<title>` when the hint appears and again 3 s later; after the press it says the new one, `stale` is off, title "Load the page again". Mutations (a delayed self-reload; no open-time fpNow with an unguarded copy; no poll; no tree guard) each fail the test (task-1.md, task-1-2.md, task-2.md) | +| 3 | A dashboard test: open a draft, change its file, outline within one poll; press it, outline goes, frame shows the change | same; whole file through the gate: `devteam.mjs gate test --junit reports/junit.xml -- /usr/local/bin/node --test … test/dashboard.test.mjs` | pass; gate passed, 155/155 at 7f62d29 (262 s) | +| 4 | test.yml passes on every job; no file is added | CI (test.yml) at the PR head; `git diff --name-only --diff-filter=A origin/main` | no-file-added: pass, only this ticket's records (spec, plan, reviews, verification, two screenshots); modified: `docs/devteam/dashboard/index.html`, `test/dashboard.test.mjs`. CI: deferred to the PR's checks, every job green at the head before the merge request | + +## Notes + +- The new test's timeout is 240000 ms, not the plan's 180000: two subtests on real 10 s polls (task-1 T1-3). +- `reports/junit.xml` (the gate's output) is untracked and not committed. + +## Rulings + +- m-20261115-013720113-supervisor: spec approved; the stale "never polled" comment on `/api/view` in `devteam.mjs` is left for kit-106 (also in plan.md). +- m-20261115-014304247-supervisor: plan approved; the final review is one fresh subagent (risk normal); a screenshot of the outline in light and dark goes to the supervisor before the merge request. diff --git a/docs/devteam/dashboard/index.html b/docs/devteam/dashboard/index.html index 0fc71f1e..9cfb4d8e 100644 --- a/docs/devteam/dashboard/index.html +++ b/docs/devteam/dashboard/index.html @@ -521,6 +521,8 @@ .av-bar button, .av-bar a.av-btn { flex: none; margin: 0; display: inline-flex; align-items: center; gap: 6px; } .av-bar a.av-btn { height: 28px; padding: 0 10px; border-radius: 6px; font-size: 12px; font-weight: 500; background: var(--surface); color: var(--fg); border: 1px solid var(--line-2); } .av-bar a.av-btn:hover { background: var(--hover); } + /* the artifact changed since the frame loaded it; a keyboard's focus ring still shows over it */ + #av-reload.stale:not(:focus-visible) { outline: 2px solid var(--amber); outline-offset: 1px; } #av-frame, #pv-frame { flex: 1 1 auto; min-height: 0; width: 100%; border: 0; background: #fff; display: block; } #av-frame[hidden], #pv-frame[hidden] { display: none; } @media (max-width: 900px) { #artifact-view:not([hidden]) { inset: 8px; } } @@ -3540,7 +3542,7 @@ <h3>Fingerprints</h3><div class="feed">${inc.fingerprints.map((f) => feed.has(f. // pointer events, the last reply, and where the lens says each pinned anchor is now (keyed by a // thread's id). A preview's: its name, the lens's origin, and its app's route, viewport and last // errors as the lens said them; the session that offered it and whether it answers, for the bar. -const TS = { kind: null, slug: null, at: null, fp: null, mainFp: null, path: null, name: null, route: null, viewport: null, errors: [], origin: null, session: null, up: null, target: '*', ready: false, raised: false, seq: 0, minted: new Map(), hit: null, waiters: new Map(), rects: new Map(), keyOf: new Map(), watching: new Set(), watch: '', last: null, rafPending: false, via: null, waitMs: 150, drawPending: false, pinsPending: false }; +const TS = { kind: null, slug: null, at: null, fp: null, mainFp: null, fpNow: null, mainFpNow: null, path: null, name: null, route: null, viewport: null, errors: [], origin: null, session: null, up: null, target: '*', ready: false, raised: false, seq: 0, minted: new Map(), hit: null, waiters: new Map(), rects: new Map(), keyOf: new Map(), watching: new Set(), watch: '', last: null, rafPending: false, via: null, waitMs: 150, drawPending: false, pinsPending: false }; // What the lens was asked and has not answered, let go: a waiting click hears it is gone (the page it // pointed at is no longer there, so it starts nothing), and no reply to a question from before is taken. function tsForget() { @@ -3551,8 +3553,9 @@ <h3>Fingerprints</h3><div class="feed">${inc.fingerprints.map((f) => feed.has(f. } function tsSet(kind, info = {}) { tsForget(); - Object.assign(TS, { kind, slug: null, at: null, fp: null, mainFp: null, path: null, name: null, route: null, viewport: null, errors: [], origin: null, session: null, up: null, target: '*', ready: false, watch: '', last: null }, info); + Object.assign(TS, { kind, slug: null, at: null, fp: null, mainFp: null, fpNow: null, mainFpNow: null, path: null, name: null, route: null, viewport: null, errors: [], origin: null, session: null, up: null, target: '*', ready: false, watch: '', last: null }, info); for (const m of [TS.rects, TS.keyOf, TS.watching]) m.clear(); + avHint(); } // The stage, in the dashboard or as a viewer tab: the page in its frame, the bar, and the lens's // state for it. title: the viewer's (from /api/view); the dashboard's comes from its data. @@ -3566,13 +3569,33 @@ <h3>Fingerprints</h3><div class="feed">${inc.fingerprints.map((f) => feed.has(f. $('av-note').hidden = !note; $('av-note').textContent = note ?? ''; $('av-tab').href = artViewer(slug, at); - if (TS.kind !== 'artifact' || TS.slug !== slug || TS.at !== at) tsSet('artifact', { slug, at, fp, mainFp }); - else Object.assign(TS, { fp, mainFp }); + // fp and mainFp are what the frame loads now; on the stage already shown its frame isn't loaded + // again, so what it loaded stays, and what is given is only the latest seen + if (TS.kind !== 'artifact' || TS.slug !== slug || TS.at !== at) tsSet('artifact', { slug, at, fp, mainFp, fpNow: fp, mainFpNow: mainFp }); + else Object.assign(TS, { fpNow: fp ?? TS.fpNow, mainFpNow: mainFp ?? TS.mainFpNow }); $('pv-frame').hidden = true; $('pv-frame').removeAttribute('src'); $('av-frame').hidden = false; if ($('av-frame').getAttribute('src') !== src) $('av-frame').src = src; $('artifact-view').hidden = false; + avHint(); +} +// The reload button says when the artifact on the stage changed since its frame loaded it (the +// latest fingerprint seen is another), until a press loads it. Never a reload by itself: kit-106's +// live reload of drafts is to take over from this. +function avHint() { + const stale = TS.kind === 'artifact' && !!TS.fp && !!TS.fpNow && TS.fp !== TS.fpNow; + const b = $('av-reload'); + b.classList.toggle('stale', stale); + b.title = stale ? 'A newer version is ready: reload to see it' : 'Load the page again'; +} +// The fingerprints just seen for an artifact there (null: not known), taken only while the stage +// shows it; a published tree never changes, so it is never checked. +function avFpSeen(fp, mainFp, slug, at) { + if (TS.kind !== 'artifact' || TS.slug !== slug || TS.at !== at || String(at).startsWith('tree:')) return; + if (fp) TS.fpNow = fp; + if (mainFp) TS.mainFpNow = mainFp; + avHint(); } // A preview's viewer, in a tab of its own. const pvViewer = (name) => `/view/preview/${encodeURIComponent(name)}`; @@ -3644,7 +3667,12 @@ <h3>Fingerprints</h3><div class="feed">${inc.fingerprints.map((f) => feed.has(f. if (!at || !/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(slug ?? '')) return void (location.hash = '#library'); if (!AV) { AV_FROM = ROUTED; avFocus = document.activeElement; } const arts = DATA?.library?.artifacts ?? []; - showArtifact(slug, at, { note, fp: arts.find((x) => x.slug === slug && x.at === at)?.fp ?? null, mainFp: arts.find((x) => x.slug === slug && x.at === 'main')?.fp ?? null }); + const mainFp = arts.find((x) => x.slug === slug && x.at === 'main')?.fp ?? null; + // a draft the Library doesn't list while main's is listed is the same as main's; only for a new + // stage, as one gone while it was shown is listed nowhere either + const fresh = TS.kind !== 'artifact' || TS.slug !== slug || TS.at !== at; + const fp = arts.find((x) => x.slug === slug && x.at === at)?.fp ?? (fresh && at !== 'main' && !String(at).startsWith('tree:') ? mainFp : null); + showArtifact(slug, at, { note, fp, mainFp }); $('scrim').hidden = false; document.body.style.overflow = 'hidden'; $('av-close').focus(); @@ -3666,7 +3694,14 @@ <h3>Fingerprints</h3><div class="feed">${inc.fingerprints.map((f) => feed.has(f. // a viewer tab has nothing to go back to: it never closes function leaveArtifact() { if (VIEWER) return; if (AV_FROM) history.back(); else location.hash = '#library'; } $('av-close').addEventListener('click', leaveArtifact); -$('av-reload').addEventListener('click', () => { const f = tsFrame(), src = f.getAttribute('src'); if (!src) return; f.removeAttribute('src'); f.src = src; }); +$('av-reload').addEventListener('click', () => { + const f = tsFrame(), src = f.getAttribute('src'); + if (!src) return; + f.removeAttribute('src'); + f.src = src; + // the frame now loads the latest seen + if (TS.kind === 'artifact') { if (TS.fpNow) TS.fp = TS.fpNow; if (TS.mainFpNow) TS.mainFp = TS.mainFpNow; avHint(); } +}); // a question's link to a changed artifact says so in the viewer's header document.addEventListener('click', (e) => { const a = e.target.closest?.('a[data-av-note]'); if (a) AV_NOTE = a.dataset.avNote || null; }); @@ -5323,6 +5358,11 @@ <h4 data-plain>${k.plain ? esc(k.plain) : linkify(k.statement)}</h4> document.querySelector('main').classList.remove('waiting'); drawOrHold(d); staleNote(); + // the stage's artifact as this read lists it, held from the page or drawn (a missing row says nothing) + if (TS.kind === 'artifact') { + const row = (at) => (d.library?.artifacts ?? []).find((a) => a.slug === TS.slug && a.at === at)?.fp ?? null; + avFpSeen(row(TS.at), row('main'), TS.slug, TS.at); + } } // The server answers a slow read with the last data it had, marked stale: say so where it shows on // any screen, with the time of what is drawn (not of a read held while the owner types), until a @@ -5662,8 +5702,10 @@ <h4 data-plain>${k.plain ? esc(k.plain) : linkify(k.statement)}</h4> const d = got.d; showArtifact(VIEWER.name, at, { title: d?.title || VIEWER.name, note: held ?? (d ? note : got.status === 404 ? 'This artifact is not here' : 'This artifact could not be read'), fp: d?.fp ?? null, mainFp: d?.main_fp ?? null }); } -// The viewer's own start: the stage, the layer's threads every 5 s while the tab is seen, and the -// instance's hello (its name) every 30 s. Nothing else: a dozen viewer tabs cost a dozen cheap reads. +// The viewer's own start: the stage, the layer's threads every 5 s while the tab is seen, the +// instance's hello (its name) every 30 s, and for an artifact on main or a draft its fingerprint +// every 10 s while seen (/api/view: one artifact describe per ask, for the reload button's hint). +// Nothing else. async function viewerBoot() { document.documentElement.classList.add('viewer'); if (VIEWER.kind === 'preview') { if (showPreview(VIEWER.name, await previewInfo(VIEWER.name)) === 'reload') return; } @@ -5690,6 +5732,19 @@ <h4 data-plain>${k.plain ? esc(k.plain) : linkify(k.statement)}</h4> viewerTalk(); addEventListener('hashchange', viewerTalk); setInterval(() => { if (document.visibilityState === 'visible') tlRefresh(); }, 5000); + // one ask at a time (a describe can take 12 s), for the stage as it is when asked; a tree never changes + let asking = false; + setInterval(async () => { + if (asking || document.visibilityState !== 'visible' || TS.kind !== 'artifact' || String(TS.at).startsWith('tree:')) return; + const { slug, at } = TS; + asking = true; + const stop = new AbortController(); + const late = setTimeout(() => stop.abort(), 15000); + try { + const r = await fetch(`/api/view?${new URLSearchParams({ kind: 'artifact', slug, at })}`, { signal: stop.signal }); + if (r.ok) { const j = await r.json(); avFpSeen(j.fp ?? null, j.main_fp ?? null, slug, at); } + } catch {} finally { clearTimeout(late); asking = false; } + }, 10000); } // #talk/<id> in a viewer tab, and nothing else of the dashboard's routes. function viewerTalk() { diff --git a/test/dashboard.test.mjs b/test/dashboard.test.mjs index 27878140..02c7ed16 100644 --- a/test/dashboard.test.mjs +++ b/test/dashboard.test.mjs @@ -2021,6 +2021,104 @@ test('the viewer: one artifact on a stage the size of the tab, the threads and h assert.deepEqual(await p.ev(`[document.getElementById('artifact-view').hidden, location.pathname, location.hash, window.__esc]`), [false, '/view/artifact/viewer-mock', '', true]); }); }); +// The reload button says when the artifact on the stage changed since its frame loaded, on the +// page's real polls (never POLL_FAST: "within one poll" is the 10 s one), and only a press reloads. +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: 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; }); + const F = threadsFixture('reload-hint'); + undo.push(() => F.done()); + // the page's <title> is what its lens says when it is ready: how the test sees what the frame shows + const mock = (title) => ({ 'artifact.json': JSON.stringify({ v: 1, title: 'Hint mock', summary: 'A page that changes', by: 'supervisor', for: [] }), 'index.html': `<!doctype html><html><head><title>${title}

${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; });