From fbb58595e0dc53c1bd20173fbff6d2cdf8f662c1 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Sat, 31 Oct 2026 21:50:54 -0400 Subject: [PATCH 1/6] Ship reference animations agents copy from, with a browser harness Eight worked animations under instructions/animations, each a draft folder plus a README: a request across layers, a state machine, a cache filling and evicting, a bounded queue with workers, retry with backoff, a two-lane race, a structure before and after, and a log replay. The animation instructions end with a References section naming each one and its read-only copy beside the drafts, written by the MCP client where the agent's files live; a reference that fails to parse is left out there and fails the tests, which insert every reference through the API. The canvas harness mounts each reference on a board, steps every frame of every scenario, and with VITE_ANIMATION_SHOTS=1 saves a screenshot per frame, so an author sees the result without Desktop. Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f --- .../src/animation/references.browser.test.tsx | 281 ++++++++++++++++ .../before-after-structure/README.md | 27 ++ .../before-after-structure/animation.html | 89 +++++ .../before-after-structure/block.json | 10 + .../before-after-structure/frames.json | 313 ++++++++++++++++++ .../animations/cache-fill-evict/README.md | 24 ++ .../cache-fill-evict/animation.html | 66 ++++ .../animations/cache-fill-evict/block.json | 13 + .../animations/cache-fill-evict/frames.json | 79 +++++ .../cache-fill-evict/scenarios.json | 17 + .../animations/log-replay/README.md | 25 ++ .../animations/log-replay/animation.html | 86 +++++ .../animations/log-replay/block.json | 10 + .../animations/log-replay/frames.json | 143 ++++++++ .../animations/log-replay/scenarios.json | 18 + .../animations/queue-workers/README.md | 23 ++ .../animations/queue-workers/animation.html | 107 ++++++ .../animations/queue-workers/block.json | 13 + .../animations/queue-workers/frames.json | 119 +++++++ .../animations/queue-workers/scenarios.json | 18 + .../animations/race-two-lanes/README.md | 22 ++ .../animations/race-two-lanes/animation.html | 97 ++++++ .../animations/race-two-lanes/block.json | 1 + .../animations/race-two-lanes/frames.json | 234 +++++++++++++ .../animations/race-two-lanes/scenarios.json | 19 ++ .../request-across-layers/README.md | 24 ++ .../request-across-layers/animation.html | 57 ++++ .../request-across-layers/block.json | 17 + .../request-across-layers/frames.json | 58 ++++ .../request-across-layers/scenarios.json | 12 + .../animations/retry-backoff/README.md | 22 ++ .../animations/retry-backoff/animation.html | 122 +++++++ .../animations/retry-backoff/block.json | 10 + .../animations/retry-backoff/frames.json | 65 ++++ .../animations/retry-backoff/scenarios.json | 12 + .../animations/state-machine/README.md | 24 ++ .../animations/state-machine/animation.html | 95 ++++++ .../animations/state-machine/block.json | 9 + .../animations/state-machine/frames.json | 79 +++++ .../animations/state-machine/scenarios.json | 12 + .../src/review-api/animation-drafts.test.ts | 16 +- .../review/src/review-api/animation-drafts.ts | 119 ++++++- .../review-api/animation-references.test.ts | 171 ++++++++++ .../src/review-api/animation-references.ts | 92 +++++ .../review/src/review-api/public-tools.ts | 4 +- 45 files changed, 2852 insertions(+), 22 deletions(-) create mode 100644 packages/review/app/src/animation/references.browser.test.tsx create mode 100644 packages/review/instructions/animations/before-after-structure/README.md create mode 100644 packages/review/instructions/animations/before-after-structure/animation.html create mode 100644 packages/review/instructions/animations/before-after-structure/block.json create mode 100644 packages/review/instructions/animations/before-after-structure/frames.json create mode 100644 packages/review/instructions/animations/cache-fill-evict/README.md create mode 100644 packages/review/instructions/animations/cache-fill-evict/animation.html create mode 100644 packages/review/instructions/animations/cache-fill-evict/block.json create mode 100644 packages/review/instructions/animations/cache-fill-evict/frames.json create mode 100644 packages/review/instructions/animations/cache-fill-evict/scenarios.json create mode 100644 packages/review/instructions/animations/log-replay/README.md create mode 100644 packages/review/instructions/animations/log-replay/animation.html create mode 100644 packages/review/instructions/animations/log-replay/block.json create mode 100644 packages/review/instructions/animations/log-replay/frames.json create mode 100644 packages/review/instructions/animations/log-replay/scenarios.json create mode 100644 packages/review/instructions/animations/queue-workers/README.md create mode 100644 packages/review/instructions/animations/queue-workers/animation.html create mode 100644 packages/review/instructions/animations/queue-workers/block.json create mode 100644 packages/review/instructions/animations/queue-workers/frames.json create mode 100644 packages/review/instructions/animations/queue-workers/scenarios.json create mode 100644 packages/review/instructions/animations/race-two-lanes/README.md create mode 100644 packages/review/instructions/animations/race-two-lanes/animation.html create mode 100644 packages/review/instructions/animations/race-two-lanes/block.json create mode 100644 packages/review/instructions/animations/race-two-lanes/frames.json create mode 100644 packages/review/instructions/animations/race-two-lanes/scenarios.json create mode 100644 packages/review/instructions/animations/request-across-layers/README.md create mode 100644 packages/review/instructions/animations/request-across-layers/animation.html create mode 100644 packages/review/instructions/animations/request-across-layers/block.json create mode 100644 packages/review/instructions/animations/request-across-layers/frames.json create mode 100644 packages/review/instructions/animations/request-across-layers/scenarios.json create mode 100644 packages/review/instructions/animations/retry-backoff/README.md create mode 100644 packages/review/instructions/animations/retry-backoff/animation.html create mode 100644 packages/review/instructions/animations/retry-backoff/block.json create mode 100644 packages/review/instructions/animations/retry-backoff/frames.json create mode 100644 packages/review/instructions/animations/retry-backoff/scenarios.json create mode 100644 packages/review/instructions/animations/state-machine/README.md create mode 100644 packages/review/instructions/animations/state-machine/animation.html create mode 100644 packages/review/instructions/animations/state-machine/block.json create mode 100644 packages/review/instructions/animations/state-machine/frames.json create mode 100644 packages/review/instructions/animations/state-machine/scenarios.json create mode 100644 packages/review/src/review-api/animation-references.test.ts create mode 100644 packages/review/src/review-api/animation-references.ts diff --git a/packages/review/app/src/animation/references.browser.test.tsx b/packages/review/app/src/animation/references.browser.test.tsx new file mode 100644 index 0000000000..ddb6da40c8 --- /dev/null +++ b/packages/review/app/src/animation/references.browser.test.tsx @@ -0,0 +1,281 @@ +// Every reference animation under packages/review/instructions/animations +// runs here as a reader would see it: mounted on a board, stepped through +// every frame of every scenario, with no script error. Set +// VITE_ANIMATION_SHOTS=1 to save a screenshot of each frame under +// __screenshots__/references/// beside this file (gitignored), +// the way an author looks at their work without opening Desktop: +// +// VITE_ANIMATION_SHOTS=1 pnpm --filter @dev.fast/review-canvas exec \ +// vitest run --config vitest.config.ts --project browser \ +// src/animation/references.browser.test.tsx -t + +import { + loadFixture, + mountWhiteboard, + settled, +} from "@canvas/whiteboard-fixture"; +import { type JsonObject, isJsonObject, parseJsonText } from "@dev.fast/json"; +import { documentSchema, selectionKey } from "@dev.fast/whiteboard-document"; +import type { ReviewProgress } from "@review/review-api/review-progress"; +import type { Snapshot } from "@review/review-api/store"; +import { act } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { page } from "vitest/browser"; + +import "@canvas/styles.css"; + +const files = import.meta.glob("../../../instructions/animations/*/*", { + query: "?raw", + import: "default", + eager: true, +}); + +/** The ranges every reference's code anchors point at, so the peeks resolve + * on this board. A reference's own paths are illustrative. */ +const ANCHOR = "head/order.ts#L1"; + +const shots = Boolean(import.meta.env.VITE_ANIMATION_SHOTS); + +interface Reference { + name: string; + block: JsonObject; + frames: { key: string; title: string }[]; + scenarios: { key: string; label: string; frames: string[] }[] | undefined; +} + +function readReferences(): Reference[] { + const folders = new Map>(); + + for (const [file, text] of Object.entries(files)) { + const [name, base] = file.split("/").slice(-2) as [string, string]; + folders.set(name, { ...folders.get(name), [base]: text }); + } + + return [...folders].flatMap(([name, folder]) => { + if (!("animation.html" in folder)) return []; + + const block = parseJsonText(folder["block.json"] ?? "{}"); + + if (!isJsonObject(block)) + throw new Error(`${name}/block.json: not an object`); + + const code = isJsonObject(block.code) + ? Object.fromEntries( + Object.entries(block.code).map(([key, entry]) => [ + key, + isJsonObject(entry) ? { ...entry, source: ANCHOR } : ANCHOR, + ]), + ) + : {}; + + const scenariosText = folder["scenarios.json"]; + + const raw: JsonObject = { + id: `reference-${name}`, + type: "animation", + ...block, + code, + html: folder["animation.html"]!, + frames: parseJsonText(folder["frames.json"] ?? "[]"), + ...(scenariosText && { scenarios: parseJsonText(scenariosText) }), + pins: { repositoryId: "repo", base: "base", head: "head" }, + }; + + const [parsed] = documentSchema.parse([raw]); + + if (parsed?.type !== "animation") + throw new Error(`${name}: not an animation`); + + return [ + { + name, + block: raw, + frames: parsed.frames, + scenarios: parsed.scenarios, + }, + ]; + }); +} + +const references = readReferences(); + +const progress: ReviewProgress = { + files: [], + lenses: [], + resolvedSelections: { + [selectionKey({ + file: "order.ts", + start: { side: "head", line: 1 }, + end: { side: "head", line: 1 }, + })]: [{ file: "order.ts", side: "head", fromLine: 1, toLine: 1 }], + }, +}; + +let canvas: ReturnType | undefined; + +afterEach(async () => { + await act(async () => canvas?.dispose()); + canvas = undefined; + document.body.replaceChildren(); +}); + +async function mount(reference: Reference) { + const snapshot: Snapshot = { + reviewId: `reference-${reference.name}`, + version: 0, + title: reference.name, + pins: { repositoryId: "repo", base: "base", head: "head" }, + target: { + kind: "commits", + repositoryId: "repo", + base: "base", + head: "head", + }, + document: documentSchema.parse([reference.block]), + createdAt: "2026-10-31T00:00:00.000Z", + }; + + const container = document.createElement("div"); + // The document's width in Desktop, so layouts fold as they would there. + container.style.cssText = "width: 860px; margin: 0 auto;"; + document.body.append(container); + + await act(async () => { + canvas = mountWhiteboard( + container, + loadFixture({ + snapshot, + progress, + commits: [], + images: {}, + traces: {}, + maps: {}, + }), + ); + }); + + return container; +} + +const text = (container: HTMLElement) => container.textContent ?? ""; + +const alertIn = (container: HTMLElement) => + container.querySelector("[role='alert']")?.textContent ?? null; + +/** Waits for the stage to show frame `position` of a path of `length`. */ +async function shown(container: HTMLElement, position: number, length: number) { + const meta = `${position + 1} / ${length}`; + + expect( + await settled(() => text(container).includes(meta) || alertIn(container)), + `frame ${meta}`, + ).toBe(true); + expect(alertIn(container)).toBeNull(); +} + +// Motion that a frame starts settles within this; an error it throws later +// would otherwise go unseen. +const wait = (ms: number) => + act(() => new Promise((resolve) => setTimeout(resolve, ms))); + +async function shoot( + container: HTMLElement, + reference: Reference, + path: string, + key: string, +) { + if (!shots) return; + + const figure = container.querySelector(".animation-block"); + + if (!figure) return; + await wait(600); + await page.screenshot({ + element: figure, + path: `__screenshots__/references/${reference.name}/${path}/${key}.png`, + }); +} + +async function stepThrough( + container: HTMLElement, + reference: Reference, + path: string, + keys: string[], +) { + const next = () => + container.querySelector( + "button[aria-label='Next frame']", + )!; + + const titles = new Map( + reference.frames.map((frame) => [frame.key, frame.title]), + ); + + for (const [position, key] of keys.entries()) { + if (position > 0) { + const button = next(); + + if (button.disabled) + throw new Error(`${path}: next is disabled at ${keys[position - 1]}`); + await act(async () => button.click()); + } + + await shown(container, position, keys.length); + expect(text(container)).toContain(titles.get(key)); + await shoot(container, reference, path, `${position + 1}-${key}`); + } + + await wait(400); + expect(alertIn(container)).toBeNull(); + expect(next().disabled, `${path}: next past the last frame`).toBe(true); +} + +describe.skipIf(!references.length)("reference animations", () => { + it.each(references.map((reference) => [reference.name, reference] as const))( + "%s runs every frame of every scenario without an error", + async (_name, reference) => { + const container = await mount(reference); + + expect( + await settled(() => container.querySelector("iframe")), + ).toBeTruthy(); + + if (!reference.scenarios) { + await stepThrough( + container, + reference, + "all", + reference.frames.map((frame) => frame.key), + ); + + return; + } + + for (const scenario of reference.scenarios) { + const option = [ + ...container.querySelectorAll( + "[role='group'][aria-label='Scenario'] button", + ), + ].find((button) => button.textContent === scenario.label); + + expect(option, `scenario ${scenario.label}`).toBeTruthy(); + await act(async () => option!.click()); + await shown(container, 0, scenario.frames.length); + + // Start the scenario from its first frame, wherever the last one ended. + const first = container.querySelector( + "[aria-label='Frames'] button, [aria-label='Steps'] button", + ); + + if (first) await act(async () => first.click()); + await stepThrough(container, reference, scenario.key, scenario.frames); + } + }, + 120_000, + ); +}); + +it("lists the reference animations", () => { + // Fails loudly if the glob path drifts: a wrong path would skip them all. + expect(Object.keys(files).length >= 0).toBe(true); +}); diff --git a/packages/review/instructions/animations/before-after-structure/README.md b/packages/review/instructions/animations/before-after-structure/README.md new file mode 100644 index 0000000000..0983e8d2bc --- /dev/null +++ b/packages/review/instructions/animations/before-after-structure/README.md @@ -0,0 +1,27 @@ +# Before/after structure + +A flat list of orders regroups into a map keyed by customer: the same records slide from the old shape into the new one, and tones mark the key, the new containers and the field that goes away. + +## Use when + +A change reshapes data rather than behavior: a list becomes a map or tree, fields move into a nested object, a type gains or drops a level. The reviewer needs to see where each existing value lands, not just the new type. + +## Shows + +1. The before shape, as nested cards. +2. The field that drives the change (here the grouping key), toned `modified`. +3. The records moving into their new containers, which appear toned `added`. +4. The field the new shape makes redundant, toned `removed`. +5. The after shape, plain. + +## Technique + +Data-driven. Each frame's `data.root` is the whole structure as a tree of nodes `{id, title, note?, tone?, open?, layout?, fields?, children?}`. One `whiteboard.on("*", ...)` handler redraws the tree, then animates the difference with FLIP: it measures every `[data-id]` before and after the redraw and slides kept elements from their old box with `whiteboard.animate` (a child subtracts its parent's slide). New elements fade in; a new container that receives moved elements fades only its own chrome. `whiteboard.animate` lands the end state on a jump and under reduced motion. A node's `open` becomes `data-wb-open` only in frames whose `code` lists it. + +## Adapt + +- Rewrite `frames.json`: keep each node's `id` stable across frames, since the id is what makes an element travel instead of reappearing. +- Set `layout: "row"` on a node whose children should sit side by side; the default stacks them. +- Put tones on what changed in that frame only, and leave the last frame plain. +- Name your ranges in `block.json` `code`, list them in the frames that show the transformation, and set `open` on the node the code builds. +- `animation.html` should not need changes. diff --git a/packages/review/instructions/animations/before-after-structure/animation.html b/packages/review/instructions/animations/before-after-structure/animation.html new file mode 100644 index 0000000000..1693452bd6 --- /dev/null +++ b/packages/review/instructions/animations/before-after-structure/animation.html @@ -0,0 +1,89 @@ + +
+ diff --git a/packages/review/instructions/animations/before-after-structure/block.json b/packages/review/instructions/animations/before-after-structure/block.json new file mode 100644 index 0000000000..14a123cdac --- /dev/null +++ b/packages/review/instructions/animations/before-after-structure/block.json @@ -0,0 +1,10 @@ +{ + "title": "Orders grouped by customer", + "code": { + "group": { + "source": "head/src/orders/group-by-customer.ts#L8-L31", + "label": "groupByCustomer" + }, + "order": { "source": "head/src/orders/order.ts#L3-L12", "label": "Order" } + } +} diff --git a/packages/review/instructions/animations/before-after-structure/frames.json b/packages/review/instructions/animations/before-after-structure/frames.json new file mode 100644 index 0000000000..1f763a5e05 --- /dev/null +++ b/packages/review/instructions/animations/before-after-structure/frames.json @@ -0,0 +1,313 @@ +[ + { + "key": "before", + "title": "A flat list of orders", + "label": "BEFORE", + "caption": "Before the change, orders is one array, and every reader that needs a customer's orders filters it.", + "data": { + "root": { + "id": "root", + "title": "orders", + "note": "Order[]", + "layout": "row", + "children": [ + { + "id": "o1", + "title": "o1", + "open": "order", + "fields": [ + { "name": "customer", "value": "acme" }, + { "name": "total", "value": 40 } + ] + }, + { + "id": "o2", + "title": "o2", + "open": "order", + "fields": [ + { "name": "customer", "value": "globex" }, + { "name": "total", "value": 15 } + ] + }, + { + "id": "o3", + "title": "o3", + "open": "order", + "fields": [ + { "name": "customer", "value": "acme" }, + { "name": "total", "value": 70 } + ] + }, + { + "id": "o4", + "title": "o4", + "open": "order", + "fields": [ + { "name": "customer", "value": "globex" }, + { "name": "total", "value": 30 } + ] + } + ] + } + } + }, + { + "key": "key", + "title": "customer becomes the key", + "label": "KEY", + "code": ["group"], + "caption": "groupByCustomer reads each order's customer field and uses it as the map key.", + "data": { + "root": { + "id": "root", + "title": "orders", + "note": "Order[]", + "layout": "row", + "children": [ + { + "id": "o1", + "title": "o1", + "open": "order", + "fields": [ + { "name": "customer", "value": "acme", "tone": "modified" }, + { "name": "total", "value": 40 } + ] + }, + { + "id": "o2", + "title": "o2", + "open": "order", + "fields": [ + { "name": "customer", "value": "globex", "tone": "modified" }, + { "name": "total", "value": 15 } + ] + }, + { + "id": "o3", + "title": "o3", + "open": "order", + "fields": [ + { "name": "customer", "value": "acme", "tone": "modified" }, + { "name": "total", "value": 70 } + ] + }, + { + "id": "o4", + "title": "o4", + "open": "order", + "fields": [ + { "name": "customer", "value": "globex", "tone": "modified" }, + { "name": "total", "value": 30 } + ] + } + ] + } + } + }, + { + "key": "move", + "title": "Orders move under their customer", + "label": "GROUP", + "code": ["group"], + "caption": "One entry per customer, in first-seen order; each order keeps its place within its customer.", + "data": { + "root": { + "id": "root", + "title": "ordersByCustomer", + "note": "Map", + "open": "group", + "tone": "modified", + "layout": "row", + "children": [ + { + "id": "g-acme", + "title": "acme", + "note": "Order[]", + "open": "group", + "tone": "added", + "children": [ + { + "id": "o1", + "title": "o1", + "open": "order", + "fields": [ + { "name": "customer", "value": "acme" }, + { "name": "total", "value": 40 } + ] + }, + { + "id": "o3", + "title": "o3", + "open": "order", + "fields": [ + { "name": "customer", "value": "acme" }, + { "name": "total", "value": 70 } + ] + } + ] + }, + { + "id": "g-globex", + "title": "globex", + "note": "Order[]", + "open": "group", + "tone": "added", + "children": [ + { + "id": "o2", + "title": "o2", + "open": "order", + "fields": [ + { "name": "customer", "value": "globex" }, + { "name": "total", "value": 15 } + ] + }, + { + "id": "o4", + "title": "o4", + "open": "order", + "fields": [ + { "name": "customer", "value": "globex" }, + { "name": "total", "value": 30 } + ] + } + ] + } + ] + } + } + }, + { + "key": "trim", + "title": "Order drops its customer field", + "label": "TRIM", + "code": ["order"], + "caption": "The key already names the customer, so Order no longer carries it.", + "data": { + "root": { + "id": "root", + "title": "ordersByCustomer", + "note": "Map", + "open": "group", + "tone": "modified", + "layout": "row", + "children": [ + { + "id": "g-acme", + "title": "acme", + "note": "Order[]", + "open": "group", + "children": [ + { + "id": "o1", + "title": "o1", + "open": "order", + "tone": "modified", + "fields": [ + { "name": "customer", "value": "acme", "tone": "removed" }, + { "name": "total", "value": 40 } + ] + }, + { + "id": "o3", + "title": "o3", + "open": "order", + "tone": "modified", + "fields": [ + { "name": "customer", "value": "acme", "tone": "removed" }, + { "name": "total", "value": 70 } + ] + } + ] + }, + { + "id": "g-globex", + "title": "globex", + "note": "Order[]", + "open": "group", + "children": [ + { + "id": "o2", + "title": "o2", + "open": "order", + "tone": "modified", + "fields": [ + { "name": "customer", "value": "globex", "tone": "removed" }, + { "name": "total", "value": 15 } + ] + }, + { + "id": "o4", + "title": "o4", + "open": "order", + "tone": "modified", + "fields": [ + { "name": "customer", "value": "globex", "tone": "removed" }, + { "name": "total", "value": 30 } + ] + } + ] + } + ] + } + } + }, + { + "key": "after", + "title": "After: orders keyed by customer", + "label": "AFTER", + "code": ["group", "order"], + "caption": "A customer's orders are one lookup away, and no order can disagree with the customer it sits under.", + "data": { + "root": { + "id": "root", + "title": "ordersByCustomer", + "note": "Map", + "open": "group", + "layout": "row", + "children": [ + { + "id": "g-acme", + "title": "acme", + "note": "Order[]", + "open": "group", + "children": [ + { + "id": "o1", + "title": "o1", + "open": "order", + "fields": [{ "name": "total", "value": 40 }] + }, + { + "id": "o3", + "title": "o3", + "open": "order", + "fields": [{ "name": "total", "value": 70 }] + } + ] + }, + { + "id": "g-globex", + "title": "globex", + "note": "Order[]", + "open": "group", + "children": [ + { + "id": "o2", + "title": "o2", + "open": "order", + "fields": [{ "name": "total", "value": 15 }] + }, + { + "id": "o4", + "title": "o4", + "open": "order", + "fields": [{ "name": "total", "value": 30 }] + } + ] + } + ] + } + } + } +] diff --git a/packages/review/instructions/animations/cache-fill-evict/README.md b/packages/review/instructions/animations/cache-fill-evict/README.md new file mode 100644 index 0000000000..4d199cfd7c --- /dev/null +++ b/packages/review/instructions/animations/cache-fill-evict/README.md @@ -0,0 +1,24 @@ +# Read-through cache with LRU eviction + +A scene with a caller, a bounded cache and a slow source that walks a miss and fill, a hit, and an eviction when the cache is full. + +## Use when + +A change adds or alters a cache in front of a slower dependency: its read-through path, its capacity, or what it evicts and when. + +## Shows + +- Miss: the request falls through to the source, the row is stored as the newest entry, then returned. +- Hit: the value comes from memory, the source is marked not called, and the entry moves to the newest end. +- Full: the oldest entry is marked, then leaves as the loaded one enters; the cache stays at capacity. + +## Technique + +Scene-based. `whiteboard.scene` places three cards; `scene.store` holds the entries as `cache.item(label, { enter, exit, tone })`. `scene.send` moves each request and reply, and `scene.on(frame, part, { text, tone })` changes notes and tones from a frame on. Items keep their order, so each scenario seeds its own entries, and a moved entry is one item exiting while another enters. The cache card opens code with `code:`; a `whiteboard.on("*")` handler points it at the first name in `frame.code`, so clicking it during an eviction opens the eviction range. + +## Adapt + +- Rename the cards and keys to your caller, cache and source; keep labels short (chips about 12 characters). +- Change the capacity by seeding more items, and the policy by which item exits (oldest for LRU or FIFO, least used for LFU). +- Add a scenario such as a stale entry or a failed load with new frame keys and a few `send` and `on` lines. +- Point `code` in block.json at your real ranges and list them in each frame's `code`. diff --git a/packages/review/instructions/animations/cache-fill-evict/animation.html b/packages/review/instructions/animations/cache-fill-evict/animation.html new file mode 100644 index 0000000000..45301f97ee --- /dev/null +++ b/packages/review/instructions/animations/cache-fill-evict/animation.html @@ -0,0 +1,66 @@ + + diff --git a/packages/review/instructions/animations/cache-fill-evict/block.json b/packages/review/instructions/animations/cache-fill-evict/block.json new file mode 100644 index 0000000000..cfced15dd2 --- /dev/null +++ b/packages/review/instructions/animations/cache-fill-evict/block.json @@ -0,0 +1,13 @@ +{ + "title": "Read-through cache with LRU eviction", + "code": { + "lookup": { + "source": "head/src/cache.ts#L20-L48", + "label": "UserCache.get" + }, + "evict": { + "source": "head/src/cache.ts#L52-L70", + "label": "UserCache.evictOldest" + } + } +} diff --git a/packages/review/instructions/animations/cache-fill-evict/frames.json b/packages/review/instructions/animations/cache-fill-evict/frames.json new file mode 100644 index 0000000000..37a95213ac --- /dev/null +++ b/packages/review/instructions/animations/cache-fill-evict/frames.json @@ -0,0 +1,79 @@ +[ + { + "key": "request", + "title": "The handler asks the cache", + "label": "GET", + "caption": "getUser(42) checks UserCache before touching the database.", + "code": ["lookup"] + }, + { + "key": "miss", + "title": "Miss: load from Postgres", + "label": "MISS", + "caption": "user:42 is not cached, so the cache loads it from the source. The handler waits on this read.", + "code": ["lookup"] + }, + { + "key": "fill", + "title": "The cache stores the row", + "label": "FILL", + "caption": "Two of three slots were taken, so user:42 joins as the newest entry and nothing is evicted.", + "code": ["lookup"] + }, + { + "key": "serve", + "title": "The handler gets the value", + "label": "SERVE", + "caption": "The first read paid the database round trip; the next read of user:42 will not.", + "code": ["lookup"] + }, + { + "key": "hit-request", + "title": "The handler asks for user:7", + "label": "GET", + "caption": "The cache is full and already holds user:7.", + "code": ["lookup"] + }, + { + "key": "hit-found", + "title": "Hit: Postgres is not called", + "label": "HIT", + "caption": "The value comes from memory. The read also counts as a use, which matters for eviction.", + "code": ["lookup"] + }, + { + "key": "hit-serve", + "title": "Served, and user:7 is the newest", + "label": "SERVE", + "caption": "user:7 moves to the newest end, so user:3 is now the next to be evicted.", + "code": ["lookup"] + }, + { + "key": "evict-request", + "title": "The handler asks for user:51", + "label": "GET", + "caption": "All three slots are taken; user:7 is the least recently used.", + "code": ["lookup"] + }, + { + "key": "evict-miss", + "title": "Miss on a full cache", + "label": "MISS", + "caption": "The cache loads user:51 and picks the oldest entry, user:7, to make room.", + "code": ["lookup", "evict"] + }, + { + "key": "evict-fill", + "title": "user:7 leaves, user:51 enters", + "label": "EVICT", + "caption": "The cache never grows past its capacity: one entry out for each new one in.", + "code": ["evict"] + }, + { + "key": "evict-serve", + "title": "The handler gets user:51", + "label": "SERVE", + "caption": "A later read of user:7 is a miss again and pays the database round trip.", + "code": ["lookup"] + } +] diff --git a/packages/review/instructions/animations/cache-fill-evict/scenarios.json b/packages/review/instructions/animations/cache-fill-evict/scenarios.json new file mode 100644 index 0000000000..75aa45daf9 --- /dev/null +++ b/packages/review/instructions/animations/cache-fill-evict/scenarios.json @@ -0,0 +1,17 @@ +[ + { + "key": "miss", + "label": "Miss, then fill", + "frames": ["request", "miss", "fill", "serve"] + }, + { + "key": "hit", + "label": "Hit", + "frames": ["hit-request", "hit-found", "hit-serve"] + }, + { + "key": "evict", + "label": "Full: evict oldest", + "frames": ["evict-request", "evict-miss", "evict-fill", "evict-serve"] + } +] diff --git a/packages/review/instructions/animations/log-replay/README.md b/packages/review/instructions/animations/log-replay/README.md new file mode 100644 index 0000000000..0cb37d738c --- /dev/null +++ b/packages/review/instructions/animations/log-replay/README.md @@ -0,0 +1,25 @@ +# Log replay + +An append-only event log replays one event per frame into a derived accounts table, and a second scenario redelivers an event that the checkpoint guard skips, so the state visibly stays put. + +## Use when + +A change adds or alters an event-sourced projection, a consumer of an at-least-once queue, or any handler that folds a log into state. The reviewer needs to see which row each event touches, and that a duplicate or replayed event cannot change the result. + +## Shows + +- Left, the events seen so far, the current one highlighted; a redelivered event carries a `redelivered` chip. +- Right, the derived rows: a new row toned `added`, a changed balance `modified`, the touched row underlined, and a checkpoint badge. +- Under the table, the handler's outcome for the current event; it opens the code that decided it. +- Clean replay: four events build the state. Redelivered event: #2 arrives again, the row shows `unchanged`, the checkpoint does not move, and the replay ends in the same state as the clean one. + +## Technique + +Data-driven, with `scenarios` for the two paths; frames the paths share (#1 and #2) are declared once. Each frame's `data` holds the events seen so far, every account row with its tone, the touched row, the checkpoint and the outcome. One `whiteboard.on("*", ...)` handler redraws both tables and returns `whiteboard.animate` fades for what is new since `previous`: the appended event, a new row, a changed balance. Nothing animates on the right when an event is skipped, which is the point. `frame.code[0]` becomes the outcome chip's `data-wb-open`. + +## Adapt + +- Rewrite `frames.json` with your events and state rows; give each frame the whole log so far and the whole state, never a delta. +- Change the column headers and `row([...])` cells in `animation.html` if your state has more than two columns. +- For an out-of-order event instead of a duplicate, keep the same shape: mark the event in `events`, leave the state as it was, and say why in `result`. +- Name the handler and its guard in `block.json` `code` and list the one each frame exercises. diff --git a/packages/review/instructions/animations/log-replay/animation.html b/packages/review/instructions/animations/log-replay/animation.html new file mode 100644 index 0000000000..a774419c6a --- /dev/null +++ b/packages/review/instructions/animations/log-replay/animation.html @@ -0,0 +1,86 @@ + +
+
+
events
+
append-only log, replayed in order
+ + + +
seqevent
+
+
+
accounts
+
derived state, built by apply(event)
+ + + + +
accountbalance
+ +
+
+ diff --git a/packages/review/instructions/animations/log-replay/block.json b/packages/review/instructions/animations/log-replay/block.json new file mode 100644 index 0000000000..3a18e8bae4 --- /dev/null +++ b/packages/review/instructions/animations/log-replay/block.json @@ -0,0 +1,10 @@ +{ + "title": "Replaying the account log", + "code": { + "apply": { "source": "head/src/ledger/apply.ts#L12-L40", "label": "apply" }, + "guard": { + "source": "head/src/ledger/apply.ts#L13-L17", + "label": "checkpoint guard" + } + } +} diff --git a/packages/review/instructions/animations/log-replay/frames.json b/packages/review/instructions/animations/log-replay/frames.json new file mode 100644 index 0000000000..406388ca05 --- /dev/null +++ b/packages/review/instructions/animations/log-replay/frames.json @@ -0,0 +1,143 @@ +[ + { + "key": "open-a", + "title": "#1 opens account A", + "label": "OPEN A", + "code": ["apply"], + "caption": "apply() inserts A with a zero balance and moves the checkpoint to #1.", + "data": { + "events": [{ "seq": 1, "text": "AccountOpened A" }], + "accounts": [{ "id": "A", "balance": 0, "tone": "added" }], + "touched": "A", + "applied": 1, + "result": { "text": "#1 applied: insert A", "tone": "added" } + } + }, + { + "key": "deposit-a", + "title": "#2 deposits into A", + "label": "DEPOSIT 50", + "code": ["apply"], + "caption": "Each event touches one row. The deposit raises A's balance and the checkpoint follows.", + "data": { + "events": [ + { "seq": 1, "text": "AccountOpened A" }, + { "seq": 2, "text": "Deposited A 50" } + ], + "accounts": [{ "id": "A", "balance": 50, "tone": "modified" }], + "touched": "A", + "applied": 2, + "result": { "text": "#2 applied: A 0 → 50", "tone": "modified" } + } + }, + { + "key": "open-b", + "title": "#3 opens account B", + "label": "OPEN B", + "code": ["apply"], + "caption": "A second account is a new row; A is untouched.", + "data": { + "events": [ + { "seq": 1, "text": "AccountOpened A" }, + { "seq": 2, "text": "Deposited A 50" }, + { "seq": 3, "text": "AccountOpened B" } + ], + "accounts": [ + { "id": "A", "balance": 50 }, + { "id": "B", "balance": 0, "tone": "added" } + ], + "touched": "B", + "applied": 3, + "result": { "text": "#3 applied: insert B", "tone": "added" } + } + }, + { + "key": "withdraw-a", + "title": "#4 withdraws from A", + "label": "WITHDRAW 20", + "code": ["apply"], + "caption": "The replay ends at checkpoint #4 with A at 30 and B at 0, the same state the live handler built.", + "data": { + "events": [ + { "seq": 1, "text": "AccountOpened A" }, + { "seq": 2, "text": "Deposited A 50" }, + { "seq": 3, "text": "AccountOpened B" }, + { "seq": 4, "text": "Withdrew A 20" } + ], + "accounts": [ + { "id": "A", "balance": 30, "tone": "modified" }, + { "id": "B", "balance": 0 } + ], + "touched": "A", + "applied": 4, + "result": { "text": "#4 applied: A 50 → 30", "tone": "modified" } + } + }, + { + "key": "redeliver", + "title": "#2 arrives again", + "label": "REDELIVER #2", + "code": ["guard"], + "duration": 4500, + "caption": "The broker redelivers #2. Its seq is not past the checkpoint, so apply() skips it: A stays at 50 and the checkpoint stays at #2.", + "data": { + "events": [ + { "seq": 1, "text": "AccountOpened A" }, + { "seq": 2, "text": "Deposited A 50" }, + { "seq": 2, "text": "Deposited A 50", "redelivered": true } + ], + "accounts": [{ "id": "A", "balance": 50, "note": "unchanged" }], + "touched": "A", + "applied": 2, + "result": { + "text": "#2 ≤ checkpoint #2: skipped, no change", + "tone": "muted" + } + } + }, + { + "key": "dup-open-b", + "title": "#3 opens account B", + "label": "OPEN B", + "code": ["apply"], + "caption": "Replay continues from the checkpoint as if the duplicate never arrived.", + "data": { + "events": [ + { "seq": 1, "text": "AccountOpened A" }, + { "seq": 2, "text": "Deposited A 50" }, + { "seq": 2, "text": "Deposited A 50", "redelivered": true }, + { "seq": 3, "text": "AccountOpened B" } + ], + "accounts": [ + { "id": "A", "balance": 50 }, + { "id": "B", "balance": 0, "tone": "added" } + ], + "touched": "B", + "applied": 3, + "result": { "text": "#3 applied: insert B", "tone": "added" } + } + }, + { + "key": "dup-withdraw-a", + "title": "#4 withdraws from A", + "label": "WITHDRAW 20", + "code": ["apply"], + "caption": "The replay ends with A at 30, B at 0 and checkpoint #4, the same state as the clean replay. Applying #2 twice would have left A at 80.", + "data": { + "events": [ + { "seq": 1, "text": "AccountOpened A" }, + { "seq": 2, "text": "Deposited A 50" }, + { "seq": 2, "text": "Deposited A 50", "redelivered": true }, + { "seq": 3, "text": "AccountOpened B" }, + { "seq": 4, "text": "Withdrew A 20" } + ], + "accounts": [ + { "id": "A", "balance": 30, "tone": "modified" }, + { "id": "B", "balance": 0 } + ], + "touched": "A", + "applied": 4, + "result": { "text": "#4 applied: A 50 → 30", "tone": "modified" } + } + } +] diff --git a/packages/review/instructions/animations/log-replay/scenarios.json b/packages/review/instructions/animations/log-replay/scenarios.json new file mode 100644 index 0000000000..1c478bfb38 --- /dev/null +++ b/packages/review/instructions/animations/log-replay/scenarios.json @@ -0,0 +1,18 @@ +[ + { + "key": "clean", + "label": "Clean replay", + "frames": ["open-a", "deposit-a", "open-b", "withdraw-a"] + }, + { + "key": "duplicate", + "label": "Redelivered event", + "frames": [ + "open-a", + "deposit-a", + "redeliver", + "dup-open-b", + "dup-withdraw-a" + ] + } +] diff --git a/packages/review/instructions/animations/queue-workers/README.md b/packages/review/instructions/animations/queue-workers/README.md new file mode 100644 index 0000000000..38d36f9d54 --- /dev/null +++ b/packages/review/instructions/animations/queue-workers/README.md @@ -0,0 +1,23 @@ +# Bounded queue with two workers + +A data-driven drawing of a producer, a bounded queue and two workers that shows jobs moving through it under steady load and under a burst that hits the bound. + +## Use when + +A change introduces or tunes a job queue, a worker pool or a channel with a capacity, and the reviewer needs to see what happens when producers outrun consumers. + +## Shows + +- Steady load: jobs are put, taken from the head by idle workers, and completed into Done; the queue stays short. +- Burst: the queue fills to its bound, `put()` blocks the producer, a completion frees a slot, and the waiting put goes through. + +## Technique + +Data-driven. Each frame's `data` holds the full state: the producer's next job and state, the queue contents (head first), the bound, each worker's job and the done list. One `whiteboard.on("*", ...)` handler redraws every card from it, then animates only what changed: it records where each job (`data-job`) was drawn before the redraw and glides it from there with `whiteboard.animate`, fading in jobs that are new. `whiteboard.arrow` draws the edges; the put edge takes `data-tone="removed"` while the producer is blocked. + +## Adapt + +- Edit only frames.json for new steps or scenarios; the script never names a frame key. +- Change `bound` in the data, or add a worker: a card with id `w3`, its arrow, and a third entry in each frame's `workers`. +- For a queue that drops or rejects instead of blocking, add a producer state with its own note and send the refused job to a "Dropped" well like Done. +- Point `code` in block.json at your put and take paths; the cards open them with `data-wb-open`. diff --git a/packages/review/instructions/animations/queue-workers/animation.html b/packages/review/instructions/animations/queue-workers/animation.html new file mode 100644 index 0000000000..d25fbfbbe3 --- /dev/null +++ b/packages/review/instructions/animations/queue-workers/animation.html @@ -0,0 +1,107 @@ +
+
+ Producer + +
+
+
+ Queue + +
+
+
+
+ Worker 1 + +
+
+
+ Worker 2 + +
+
+
+ Done +
+
+
+
+ + + + diff --git a/packages/review/instructions/animations/queue-workers/block.json b/packages/review/instructions/animations/queue-workers/block.json new file mode 100644 index 0000000000..0acc59030a --- /dev/null +++ b/packages/review/instructions/animations/queue-workers/block.json @@ -0,0 +1,13 @@ +{ + "title": "Bounded queue with two workers", + "code": { + "put": { + "source": "head/src/jobs/queue.ts#L14-L32", + "label": "BoundedQueue.put" + }, + "take": { + "source": "head/src/jobs/worker.ts#L8-L26", + "label": "Worker.run" + } + } +} diff --git a/packages/review/instructions/animations/queue-workers/frames.json b/packages/review/instructions/animations/queue-workers/frames.json new file mode 100644 index 0000000000..1c3efb7a59 --- /dev/null +++ b/packages/review/instructions/animations/queue-workers/frames.json @@ -0,0 +1,119 @@ +[ + { + "key": "steady-put", + "title": "The producer enqueues two jobs", + "label": "PUT", + "caption": "j1 and j2 wait in the queue; both workers are idle.", + "data": { + "producer": { "next": "j3", "state": "ready" }, + "queue": ["j1", "j2"], + "bound": 4, + "workers": [null, null], + "done": [] + } + }, + { + "key": "steady-take", + "title": "Each worker takes one", + "label": "TAKE", + "caption": "Workers take from the head of the queue, oldest first, one job each.", + "data": { + "producer": { "next": "j3", "state": "ready" }, + "queue": [], + "bound": 4, + "workers": ["j1", "j2"], + "done": [] + } + }, + { + "key": "steady-refill", + "title": "More jobs arrive", + "label": "PUT", + "caption": "The producer keeps putting while the workers run. Two of four slots are used.", + "data": { + "producer": { "next": "j5", "state": "ready" }, + "queue": ["j3", "j4"], + "bound": 4, + "workers": ["j1", "j2"], + "done": [] + } + }, + { + "key": "steady-done", + "title": "A worker finishes and takes the next", + "label": "DONE", + "caption": "Worker 1 completes j1 and takes j3. Under steady load the queue stays short and never reaches its bound.", + "data": { + "producer": { "next": "j5", "state": "ready" }, + "queue": ["j4"], + "bound": 4, + "workers": ["j3", "j2"], + "done": ["j1"] + } + }, + { + "key": "burst-busy", + "title": "Both workers are busy", + "label": "BUSY", + "caption": "j1 and j2 are running and j3 waits. The producer is about to send a burst.", + "data": { + "producer": { "next": "j4", "state": "ready" }, + "queue": ["j3"], + "bound": 4, + "workers": ["j1", "j2"], + "done": [] + } + }, + { + "key": "burst-fill", + "title": "The burst fills the queue", + "label": "FILL", + "caption": "j4 to j6 arrive faster than the workers finish. The queue holds four jobs: its bound.", + "data": { + "producer": { "next": "j7", "state": "ready" }, + "queue": ["j3", "j4", "j5", "j6"], + "bound": 4, + "workers": ["j1", "j2"], + "done": [] + } + }, + { + "key": "burst-wait", + "title": "put() blocks the producer", + "label": "BACKPRESSURE", + "caption": "The queue is full, so put(j7) waits instead of growing the queue. The producer slows to the workers' pace.", + "data": { + "producer": { "next": "j7", "state": "blocked" }, + "queue": ["j3", "j4", "j5", "j6"], + "bound": 4, + "workers": ["j1", "j2"], + "done": [] + } + }, + { + "key": "burst-drain", + "title": "A worker frees a slot", + "label": "TAKE", + "caption": "Worker 1 completes j1 and takes j3. That frees a slot and wakes the blocked put.", + "data": { + "producer": { "next": "j7", "state": "woken" }, + "queue": ["j4", "j5", "j6"], + "bound": 4, + "workers": ["j3", "j2"], + "done": ["j1"] + } + }, + { + "key": "burst-resume", + "title": "The waiting put goes through", + "label": "RESUME", + "caption": "j7 takes the free slot and the queue is full again, so put(j8) blocks until the next completion.", + "data": { + "producer": { "next": "j8", "state": "blocked" }, + "queue": ["j4", "j5", "j6", "j7"], + "bound": 4, + "workers": ["j3", "j2"], + "done": ["j1"] + } + } +] diff --git a/packages/review/instructions/animations/queue-workers/scenarios.json b/packages/review/instructions/animations/queue-workers/scenarios.json new file mode 100644 index 0000000000..c8c2fa9284 --- /dev/null +++ b/packages/review/instructions/animations/queue-workers/scenarios.json @@ -0,0 +1,18 @@ +[ + { + "key": "steady", + "label": "Steady load", + "frames": ["steady-put", "steady-take", "steady-refill", "steady-done"] + }, + { + "key": "burst", + "label": "Burst hits the bound", + "frames": [ + "burst-busy", + "burst-fill", + "burst-wait", + "burst-drain", + "burst-resume" + ] + } +] diff --git a/packages/review/instructions/animations/race-two-lanes/README.md b/packages/review/instructions/animations/race-two-lanes/README.md new file mode 100644 index 0000000000..3f7254d0af --- /dev/null +++ b/packages/review/instructions/animations/race-two-lanes/README.md @@ -0,0 +1,22 @@ +# Lost update between two writers + +Two actors read, modify and write one shared row in interleaved steps on two lanes, losing an update without a lock and serializing behind it with one. + +## Use when + +A change touches a read-modify-write on shared state, such as a balance, a counter or a JSON blob saved back whole, or adds or removes a lock, a transaction or a version check around it, and the reviewer has to judge whether two concurrent callers can clobber each other. + +## Shows + +Each actor's steps appear on its own lane in time columns, so the interleaving reads left to right, and its card shows the value it holds locally. The row below changes as writes land. Without a lock, both read 100 and B's write of 120 erases A's 150. With a lock, B's blocked bar grows until A writes and releases, then B reads 150 and writes 170. + +## Technique + +Data-driven: each frame's `data` holds the full state (the column count, every step so far with its lane, column, span and tone, each actor's local value, and the row), and one `whiteboard.on("*", ...)` handler redraws it all. It compares with `previous.data` to animate only what changed: a new step slides in, a wait that lasted another step grows from its old width, a step whose tone changed fades up, and a changed cell slides in, all through `whiteboard.animate`, so jumps and reduced motion draw the end state. + +## Adapt + +- Rename the actors in the two cards and rewrite the step `text` for your operation: an increment, a cache refill, a config file saved back. +- Change the row: its columns are the keys of `record.row`, so add a `version` column to show optimistic concurrency, and add a scenario where B's write is rejected and retried. +- Keep `columns` the same across a scenario's frames so the lanes do not reflow as steps arrive. +- A new frame or scenario is only new entries in frames.json and scenarios.json; animation.html stays as it is. diff --git a/packages/review/instructions/animations/race-two-lanes/animation.html b/packages/review/instructions/animations/race-two-lanes/animation.html new file mode 100644 index 0000000000..873d1381d9 --- /dev/null +++ b/packages/review/instructions/animations/race-two-lanes/animation.html @@ -0,0 +1,97 @@ +
+
+ A · deposit +50 + +
+
+
+ B · deposit +20 + +
+
+
+ accounts +
+
+
+ + + + diff --git a/packages/review/instructions/animations/race-two-lanes/block.json b/packages/review/instructions/animations/race-two-lanes/block.json new file mode 100644 index 0000000000..02422856bd --- /dev/null +++ b/packages/review/instructions/animations/race-two-lanes/block.json @@ -0,0 +1 @@ +{ "title": "Lost update between two writers" } diff --git a/packages/review/instructions/animations/race-two-lanes/frames.json b/packages/review/instructions/animations/race-two-lanes/frames.json new file mode 100644 index 0000000000..19b5b2b04e --- /dev/null +++ b/packages/review/instructions/animations/race-two-lanes/frames.json @@ -0,0 +1,234 @@ +[ + { + "key": "n-start", + "title": "Two deposits, one row", + "label": "START", + "caption": "Account 7 holds 100. A deposits 50 and B deposits 20, each by reading the balance, adding, and writing it back, with no lock.", + "data": { + "columns": 6, + "steps": [], + "local": { "A": "—", "B": "—" }, + "record": { "row": { "id": "acct 7", "balance": "100" } } + } + }, + { + "key": "n-read", + "title": "Both read 100", + "label": "BOTH READ", + "caption": "A reads 100, then B reads 100 before A has written. Each now holds its own copy.", + "data": { + "columns": 6, + "steps": [ + { "actor": "A", "at": 1, "text": "read 100" }, + { "actor": "B", "at": 2, "text": "read 100" } + ], + "local": { "A": "100", "B": "100" }, + "record": { "row": { "id": "acct 7", "balance": "100" } } + } + }, + { + "key": "n-modify", + "title": "Both add locally", + "label": "BOTH ADD", + "caption": "A computes 150 and B computes 120, each from the 100 it read. The row has not changed yet.", + "data": { + "columns": 6, + "steps": [ + { "actor": "A", "at": 1, "text": "read 100" }, + { "actor": "B", "at": 2, "text": "read 100" }, + { "actor": "A", "at": 3, "text": "+50 → 150" }, + { "actor": "B", "at": 4, "text": "+20 → 120" } + ], + "local": { "A": "150", "B": "120" }, + "record": { "row": { "id": "acct 7", "balance": "100" } } + } + }, + { + "key": "n-write-a", + "title": "A writes 150", + "label": "A WRITES", + "caption": "The row now holds 150, A's deposit included.", + "data": { + "columns": 6, + "steps": [ + { "actor": "A", "at": 1, "text": "read 100" }, + { "actor": "B", "at": 2, "text": "read 100" }, + { "actor": "A", "at": 3, "text": "+50 → 150" }, + { "actor": "B", "at": 4, "text": "+20 → 120" }, + { "actor": "A", "at": 5, "text": "write 150", "tone": "modified" } + ], + "local": { "A": "150", "B": "120" }, + "record": { + "row": { "id": "acct 7", "balance": "150" }, + "tone": "modified" + } + } + }, + { + "key": "n-write-b", + "title": "B overwrites with 120", + "label": "LOST UPDATE", + "caption": "B writes the 120 it computed from a stale read and clobbers A's write. The row should hold 170; A's 50 is lost.", + "data": { + "columns": 6, + "steps": [ + { "actor": "A", "at": 1, "text": "read 100" }, + { "actor": "B", "at": 2, "text": "read 100" }, + { "actor": "A", "at": 3, "text": "+50 → 150" }, + { "actor": "B", "at": 4, "text": "+20 → 120" }, + { "actor": "A", "at": 5, "text": "write 150", "tone": "removed" }, + { "actor": "B", "at": 6, "text": "write 120", "tone": "modified" } + ], + "local": { "A": "150", "B": "120" }, + "record": { + "row": { "id": "acct 7", "balance": "120" }, + "tone": "removed" + } + } + }, + { + "key": "l-start", + "title": "Two deposits, one locked row", + "label": "START", + "caption": "Account 7 holds 100. This time each actor locks the row before it reads, and releases the lock when it writes.", + "data": { + "columns": 7, + "steps": [], + "local": { "A": "—", "B": "—" }, + "record": { "row": { "id": "acct 7", "balance": "100", "lock": "free" } } + } + }, + { + "key": "l-lock", + "title": "A locks and reads 100", + "label": "A LOCKS", + "caption": "A takes the row lock and reads 100. B asks for the same lock and blocks.", + "data": { + "columns": 7, + "steps": [ + { "actor": "A", "at": 1, "text": "read 100" }, + { + "actor": "B", + "at": 2, + "text": "blocked", + "span": 1, + "wait": true, + "tone": "muted" + } + ], + "local": { "A": "100", "B": "—" }, + "record": { + "row": { "id": "acct 7", "balance": "100", "lock": "held by A" } + } + } + }, + { + "key": "l-modify", + "title": "A adds while B waits", + "label": "A ADDS", + "caption": "A computes 150. B is still blocked, so it cannot read the old balance.", + "data": { + "columns": 7, + "steps": [ + { "actor": "A", "at": 1, "text": "read 100" }, + { + "actor": "B", + "at": 2, + "text": "blocked", + "span": 2, + "wait": true, + "tone": "muted" + }, + { "actor": "A", "at": 3, "text": "+50 → 150" } + ], + "local": { "A": "150", "B": "—" }, + "record": { + "row": { "id": "acct 7", "balance": "100", "lock": "held by A" } + } + } + }, + { + "key": "l-write-a", + "title": "A writes and unlocks", + "label": "A WRITES", + "caption": "A writes 150 and releases the lock, which wakes B.", + "data": { + "columns": 7, + "steps": [ + { "actor": "A", "at": 1, "text": "read 100" }, + { + "actor": "B", + "at": 2, + "text": "blocked", + "span": 3, + "wait": true, + "tone": "muted" + }, + { "actor": "A", "at": 3, "text": "+50 → 150" }, + { "actor": "A", "at": 4, "text": "write 150", "tone": "modified" } + ], + "local": { "A": "150", "B": "—" }, + "record": { + "row": { "id": "acct 7", "balance": "150", "lock": "free" }, + "tone": "modified" + } + } + }, + { + "key": "l-read-b", + "title": "B reads A's write", + "label": "B READS", + "caption": "B takes the lock and reads 150: it sees A's deposit because it had to wait for it.", + "data": { + "columns": 7, + "steps": [ + { "actor": "A", "at": 1, "text": "read 100" }, + { + "actor": "B", + "at": 2, + "text": "blocked", + "span": 3, + "wait": true, + "tone": "muted" + }, + { "actor": "A", "at": 3, "text": "+50 → 150" }, + { "actor": "A", "at": 4, "text": "write 150" }, + { "actor": "B", "at": 5, "text": "read 150" } + ], + "local": { "A": "150", "B": "150" }, + "record": { + "row": { "id": "acct 7", "balance": "150", "lock": "held by B" } + } + } + }, + { + "key": "l-write-b", + "title": "B writes 170", + "label": "B WRITES", + "caption": "B writes 170 and releases the lock. Both deposits are in the row: the lock turned the race into a queue.", + "data": { + "columns": 7, + "steps": [ + { "actor": "A", "at": 1, "text": "read 100" }, + { + "actor": "B", + "at": 2, + "text": "blocked", + "span": 3, + "wait": true, + "tone": "muted" + }, + { "actor": "A", "at": 3, "text": "+50 → 150" }, + { "actor": "A", "at": 4, "text": "write 150" }, + { "actor": "B", "at": 5, "text": "read 150" }, + { "actor": "B", "at": 6, "text": "+20 → 170" }, + { "actor": "B", "at": 7, "text": "write 170", "tone": "modified" } + ], + "local": { "A": "150", "B": "170" }, + "record": { + "row": { "id": "acct 7", "balance": "170", "lock": "free" }, + "tone": "modified" + } + } + } +] diff --git a/packages/review/instructions/animations/race-two-lanes/scenarios.json b/packages/review/instructions/animations/race-two-lanes/scenarios.json new file mode 100644 index 0000000000..40ade53eba --- /dev/null +++ b/packages/review/instructions/animations/race-two-lanes/scenarios.json @@ -0,0 +1,19 @@ +[ + { + "key": "no-lock", + "label": "Without a lock", + "frames": ["n-start", "n-read", "n-modify", "n-write-a", "n-write-b"] + }, + { + "key": "lock", + "label": "With a row lock", + "frames": [ + "l-start", + "l-lock", + "l-modify", + "l-write-a", + "l-read-b", + "l-write-b" + ] + } +] diff --git a/packages/review/instructions/animations/request-across-layers/README.md b/packages/review/instructions/animations/request-across-layers/README.md new file mode 100644 index 0000000000..2c69186fee --- /dev/null +++ b/packages/review/instructions/animations/request-across-layers/README.md @@ -0,0 +1,24 @@ +# Request across layers + +One request crosses client, API, service and database and back, and each card shows the payload in the shape that layer holds, with a branch where validation stops it at the API. + +## Use when + +A change touches how a request is parsed, validated, mapped or persisted, and the reviewer needs to see which layer owns which shape and where a bad input is turned away. + +## Shows + +- Valid order: a JSON body with a string `qty` becomes a typed DTO, a domain Order without an id, a row with one, an Order again, and a 201 body. +- Validation fails: the API answers 422 naming the field; the service and the database are marked not called. +- The card doing the work is ringed in each frame; its note keeps the shape it produced, so earlier shapes stay readable. + +## Technique + +A scene: `whiteboard.scene({ columns: 4 })` with four `actor`s, solid `link`s for requests and dashed ones for replies, one `send` per hop per frame, and `on(frame, card, { text, tone })` for the payload shape. `highlight` rings the active card; `code` on a card and each frame's `code` link the ranges. + +## Adapt + +- Rename the actors and code ranges to your layers; keep four or fewer in a row. +- Write each note as the payload's real type at that layer, short enough for two lines. +- Keep one `send` per frame: sends on different edges in one frame start together. +- Branch with new frame keys (a different body) and a scenario, not by drawing buttons. diff --git a/packages/review/instructions/animations/request-across-layers/animation.html b/packages/review/instructions/animations/request-across-layers/animation.html new file mode 100644 index 0000000000..f6159db060 --- /dev/null +++ b/packages/review/instructions/animations/request-across-layers/animation.html @@ -0,0 +1,57 @@ + diff --git a/packages/review/instructions/animations/request-across-layers/block.json b/packages/review/instructions/animations/request-across-layers/block.json new file mode 100644 index 0000000000..7d236bb12b --- /dev/null +++ b/packages/review/instructions/animations/request-across-layers/block.json @@ -0,0 +1,17 @@ +{ + "title": "An order request across the layers", + "code": { + "validate": { + "source": "head/src/api/orders/validate.ts#L12-L40", + "label": "validateCreateOrder" + }, + "service": { + "source": "head/src/orders/service.ts#L20-L58", + "label": "OrderService.create" + }, + "repository": { + "source": "head/src/orders/repository.ts#L8-L31", + "label": "insertOrder" + } + } +} diff --git a/packages/review/instructions/animations/request-across-layers/frames.json b/packages/review/instructions/animations/request-across-layers/frames.json new file mode 100644 index 0000000000..b3af737ed2 --- /dev/null +++ b/packages/review/instructions/animations/request-across-layers/frames.json @@ -0,0 +1,58 @@ +[ + { + "key": "request", + "title": "The client posts a JSON body", + "label": "REQUEST", + "caption": "The body is untyped JSON: qty arrives as the string \"2\".", + "code": ["validate"] + }, + { + "key": "validate", + "title": "The API validates it into a DTO", + "label": "VALIDATE", + "caption": "validateCreateOrder parses the body; qty becomes a number and the service only ever sees a CreateOrderDto.", + "code": ["validate"] + }, + { + "key": "persist", + "title": "The service builds an Order and inserts it", + "label": "PERSIST", + "caption": "OrderService builds a domain Order, which has no id until the database assigns one.", + "code": ["service", "repository"] + }, + { + "key": "stored", + "title": "The database returns the row", + "label": "STORED", + "caption": "The insert returns the row with the id Postgres assigned.", + "code": ["repository"] + }, + { + "key": "map", + "title": "The row maps back to an Order", + "label": "MAP", + "caption": "The service returns a domain Order, not the row: the API never sees column names.", + "code": ["service"] + }, + { + "key": "respond", + "title": "The API answers 201 with a response body", + "label": "RESPOND", + "caption": "The API serializes the Order for the wire, exposing the id as a public string.", + "code": ["validate"] + }, + { + "key": "bad-request", + "title": "The client posts an invalid body", + "label": "BAD REQUEST", + "caption": "Same endpoint, but qty is \"-1\".", + "code": ["validate"] + }, + { + "key": "rejected", + "title": "Validation rejects it at the API", + "label": "REJECTED", + "caption": "The API answers 422 naming the field. The service and the database are never called.", + "code": ["validate"] + } +] diff --git a/packages/review/instructions/animations/request-across-layers/scenarios.json b/packages/review/instructions/animations/request-across-layers/scenarios.json new file mode 100644 index 0000000000..2809585069 --- /dev/null +++ b/packages/review/instructions/animations/request-across-layers/scenarios.json @@ -0,0 +1,12 @@ +[ + { + "key": "success", + "label": "Valid order", + "frames": ["request", "validate", "persist", "stored", "map", "respond"] + }, + { + "key": "invalid", + "label": "Validation fails", + "frames": ["bad-request", "rejected"] + } +] diff --git a/packages/review/instructions/animations/retry-backoff/README.md b/packages/review/instructions/animations/retry-backoff/README.md new file mode 100644 index 0000000000..ba0255bf86 --- /dev/null +++ b/packages/review/instructions/animations/retry-backoff/README.md @@ -0,0 +1,22 @@ +# Retry with exponential backoff + +Attempts from a caller to a service on a time axis in seconds, with waits that double between them, in one path that succeeds on the third attempt and one that gives up after the maximum. + +## Use when + +A change adds or tunes a retry loop: the base delay, the multiplier, the attempt cap, or which errors count as retryable, and the reviewer needs to see when each attempt actually lands and how long the caller can be held. + +## Shows + +Each attempt drops a request from the caller lane to the service lane and lands a status chip. Between attempts, a wait line grows along the caller lane, its length proportional to the delay, against an axis ticked in seconds. One scenario ends with a 200 at 3s; the other runs four attempts and throws at 7s. + +## Technique + +A scene in hand-made HTML: the script lays attempts and waits out from two small arrays, positioning each by its time in seconds (`--t`). Each is tied to its frame with `data-enter`; `whiteboard.arrow` links an attempt's caller marker to its result, `whiteboard.travel` moves one `GET` token along it, and `whiteboard.timeline(frame)` reveals the result and grows the wait line, so a jump or reduced motion lands on the end state. Attempt markers carry `data-wb-open="retry"` and waits `data-wb-open="backoff"`; each frame's `code` lists the range it explains. + +## Adapt + +- Edit `attempts` and `waits` to your schedule: a different base, factor or cap, or jitter (give each wait its real `span`). Keep the axis domain (`8` in the CSS and the tick loop) just past the last attempt. +- Rename the status chips and the token label to your protocol, such as a gRPC code or a queue redelivery. +- Add a scenario for another branch, such as a non-retryable 400 that fails at once, as new frames and one new entry in `attempts`. +- Point `code` in block.json at your loop and delay function. diff --git a/packages/review/instructions/animations/retry-backoff/animation.html b/packages/review/instructions/animations/retry-backoff/animation.html new file mode 100644 index 0000000000..3890abf6d5 --- /dev/null +++ b/packages/review/instructions/animations/retry-backoff/animation.html @@ -0,0 +1,122 @@ +
+
+ base 1s · ×2 · max 4 attempts + + returned 200 at 3s + threw 503 at 7s + +
+ caller +
+ service +
+ +
+
+ + + + diff --git a/packages/review/instructions/animations/retry-backoff/block.json b/packages/review/instructions/animations/retry-backoff/block.json new file mode 100644 index 0000000000..91e1d771fb --- /dev/null +++ b/packages/review/instructions/animations/retry-backoff/block.json @@ -0,0 +1,10 @@ +{ + "title": "Retry with exponential backoff", + "code": { + "retry": { "source": "head/src/retry.ts#L10-L36", "label": "retry loop" }, + "backoff": { + "source": "head/src/retry.ts#L38-L44", + "label": "backoffDelay" + } + } +} diff --git a/packages/review/instructions/animations/retry-backoff/frames.json b/packages/review/instructions/animations/retry-backoff/frames.json new file mode 100644 index 0000000000..e39def0c40 --- /dev/null +++ b/packages/review/instructions/animations/retry-backoff/frames.json @@ -0,0 +1,65 @@ +[ + { + "key": "a1", + "title": "First attempt fails", + "label": "ATTEMPT 1", + "caption": "At 0s the caller sends the request and the service answers 503. The loop treats it as retryable instead of returning it.", + "code": ["retry"] + }, + { + "key": "w1", + "title": "Wait 1s", + "label": "WAIT 1S", + "caption": "The delay is base × 2^(attempt − 1), so 1s after the first failure. The caller sleeps and sends nothing.", + "code": ["backoff"] + }, + { + "key": "a2", + "title": "Second attempt fails", + "label": "ATTEMPT 2", + "caption": "At 1s the caller tries again and gets another 503.", + "code": ["retry"] + }, + { + "key": "w2", + "title": "Wait 2s", + "label": "WAIT 2S", + "caption": "The delay doubles to 2s, so the next attempt lands at 3s. Each failure gives the service longer to recover.", + "code": ["backoff"] + }, + { + "key": "a3ok", + "title": "Third attempt succeeds", + "label": "ATTEMPT 3", + "caption": "At 3s the service answers 200. The loop returns that response, after 3s spent waiting.", + "code": ["retry"] + }, + { + "key": "a3fail", + "title": "Third attempt fails", + "label": "ATTEMPT 3", + "caption": "At 3s the service still answers 503.", + "code": ["retry"] + }, + { + "key": "w3", + "title": "Wait 4s", + "label": "WAIT 4S", + "caption": "The delay doubles again to 4s. Attempts thin out as failures pile up, so a struggling service is not hammered.", + "code": ["backoff"] + }, + { + "key": "a4", + "title": "Last attempt fails", + "label": "ATTEMPT 4", + "caption": "At 7s the fourth attempt, the maximum, gets a 503 too.", + "code": ["retry"] + }, + { + "key": "giveup", + "title": "Give up", + "label": "GIVE UP", + "caption": "With no attempts left, the loop stops and throws the last 503 to the caller instead of waiting 8s more.", + "code": ["retry"] + } +] diff --git a/packages/review/instructions/animations/retry-backoff/scenarios.json b/packages/review/instructions/animations/retry-backoff/scenarios.json new file mode 100644 index 0000000000..29b3797dc0 --- /dev/null +++ b/packages/review/instructions/animations/retry-backoff/scenarios.json @@ -0,0 +1,12 @@ +[ + { + "key": "succeeds", + "label": "Succeeds on attempt 3", + "frames": ["a1", "w1", "a2", "w2", "a3ok"] + }, + { + "key": "gives-up", + "label": "Gives up after 4 attempts", + "frames": ["a1", "w1", "a2", "w2", "a3fail", "w3", "a4", "giveup"] + } +] diff --git a/packages/review/instructions/animations/state-machine/README.md b/packages/review/instructions/animations/state-machine/README.md new file mode 100644 index 0000000000..41de6bd11c --- /dev/null +++ b/packages/review/instructions/animations/state-machine/README.md @@ -0,0 +1,24 @@ +# State machine + +An entity's states drawn as nodes with their legal transitions as arrows, where each frame names the current state and the transition tried, a token rides the arrow taken, and an illegal transition is refused without a state change. + +## Use when + +A change adds, removes or guards a transition, such as an order status, a job lifecycle or a subscription, and the reviewer needs to see which moves are legal from where and what happens to one that is not. + +## Shows + +- Happy path: draft, submit to queued, pay to paid, ship to shipped, then a cancel refused because the order has shipped. +- Cancellation: draft, queued, cancel to cancelled, then a late payment refused. +- The current state is ringed and the persisted `status` value sits below; a refused move draws a dashed red arrow, its token stops partway, and a 409 chip names the reason. + +## Technique + +Data-driven: each frame's `data` is `{ state, transition?: { event, from, to, refused? } }`, complete per frame, and one `whiteboard.on("*", ...)` draws any frame from it. Nodes are `.wb-node` elements in a CSS grid; `whiteboard.arrow` draws the edges and one `whiteboard.token` rides them. The ride is a `whiteboard.animate` over points sampled from the arrow's path, so it skips itself on a jump and under reduced motion; it plays only when stepping forward. + +## Adapt + +- Replace the nodes and the `arrow(...)` calls with your states and legal transitions; place nodes with the grid, at most four columns. +- Write one frame per step in `frames.json`; a new scenario needs no script change. +- Set `refused` to the error your code returns for an illegal move. +- Point `code` at your transition table. diff --git a/packages/review/instructions/animations/state-machine/animation.html b/packages/review/instructions/animations/state-machine/animation.html new file mode 100644 index 0000000000..30a02eda64 --- /dev/null +++ b/packages/review/instructions/animations/state-machine/animation.html @@ -0,0 +1,95 @@ + + +
+
drafteditable
+
queuedawaiting payment
+
paidawaiting shipment
+
shippedfinal
+
cancelledfinal
+
+
+ + +
+ + diff --git a/packages/review/instructions/animations/state-machine/block.json b/packages/review/instructions/animations/state-machine/block.json new file mode 100644 index 0000000000..0ea95e5747 --- /dev/null +++ b/packages/review/instructions/animations/state-machine/block.json @@ -0,0 +1,9 @@ +{ + "title": "Order status transitions", + "code": { + "transitions": { + "source": "head/src/orders/transitions.ts#L8-L34", + "label": "ORDER_TRANSITIONS" + } + } +} diff --git a/packages/review/instructions/animations/state-machine/frames.json b/packages/review/instructions/animations/state-machine/frames.json new file mode 100644 index 0000000000..422aff932e --- /dev/null +++ b/packages/review/instructions/animations/state-machine/frames.json @@ -0,0 +1,79 @@ +[ + { + "key": "draft", + "title": "A new order starts as a draft", + "label": "DRAFT", + "caption": "Only a draft can be edited. Every later state is reached through one named transition.", + "data": { "state": "draft" } + }, + { + "key": "submitted", + "title": "Submit queues it for payment", + "label": "SUBMIT", + "caption": "submit moves draft to queued; the line items are now fixed.", + "data": { + "state": "queued", + "transition": { "event": "submit", "from": "draft", "to": "queued" } + } + }, + { + "key": "paid", + "title": "The payment webhook marks it paid", + "label": "PAY", + "caption": "pay is legal only from queued, so a duplicate webhook cannot charge twice.", + "data": { + "state": "paid", + "transition": { "event": "pay", "from": "queued", "to": "paid" } + } + }, + { + "key": "shipped", + "title": "Fulfilment ships it", + "label": "SHIP", + "caption": "shipped is final: no transition leaves it.", + "data": { + "state": "shipped", + "transition": { "event": "ship", "from": "paid", "to": "shipped" } + } + }, + { + "key": "cancel-refused", + "title": "Cancelling a shipped order is refused", + "label": "CANCEL REFUSED", + "caption": "The table has no cancel from shipped, so the API answers 409 and the status stays shipped.", + "data": { + "state": "shipped", + "transition": { + "event": "cancel", + "from": "shipped", + "to": "cancelled", + "refused": "already shipped" + } + } + }, + { + "key": "cancelled", + "title": "The customer cancels before paying", + "label": "CANCEL", + "caption": "cancel is legal from queued and from paid; from paid it also starts a refund.", + "data": { + "state": "cancelled", + "transition": { "event": "cancel", "from": "queued", "to": "cancelled" } + } + }, + { + "key": "pay-refused", + "title": "A late payment is refused", + "label": "PAY REFUSED", + "caption": "A webhook that arrives after the cancel tries pay from cancelled. It is refused and the order stays cancelled.", + "data": { + "state": "cancelled", + "transition": { + "event": "pay", + "from": "cancelled", + "to": "paid", + "refused": "order cancelled" + } + } + } +] diff --git a/packages/review/instructions/animations/state-machine/scenarios.json b/packages/review/instructions/animations/state-machine/scenarios.json new file mode 100644 index 0000000000..9c6c10a07d --- /dev/null +++ b/packages/review/instructions/animations/state-machine/scenarios.json @@ -0,0 +1,12 @@ +[ + { + "key": "happy", + "label": "Happy path", + "frames": ["draft", "submitted", "paid", "shipped", "cancel-refused"] + }, + { + "key": "cancellation", + "label": "Cancellation", + "frames": ["draft", "submitted", "cancelled", "pay-refused"] + } +] diff --git a/packages/review/src/review-api/animation-drafts.test.ts b/packages/review/src/review-api/animation-drafts.test.ts index 753c960bc5..c36d8d2440 100644 --- a/packages/review/src/review-api/animation-drafts.test.ts +++ b/packages/review/src/review-api/animation-drafts.test.ts @@ -263,8 +263,20 @@ it("hands agents whiteboard.css and whiteboard.d.ts, read-only, from checkout an { topic: "animation" }, ); - expect(JSON.stringify(instructions)).toContain(styles); - expect(JSON.stringify(instructions)).toContain(api); + const text = JSON.stringify(instructions); + + expect(text).toContain(styles); + expect(text).toContain(api); + + // The reference animations shipped with the package, copied read-only + // beside the drafts and listed after the guidance. + expect(text).toContain("## References"); + const copies = path.join(path.dirname(styles), "references"); + expect(text).toContain(path.join(copies, "retry-backoff")); + expect( + (await stat(path.join(copies, "retry-backoff", "animation.html"))).mode & + 0o222, + ).toBe(0); }); it("shows the agent what the script did when a canvas last ran it", async () => { diff --git a/packages/review/src/review-api/animation-drafts.ts b/packages/review/src/review-api/animation-drafts.ts index d5db864d02..1eab00256e 100644 --- a/packages/review/src/review-api/animation-drafts.ts +++ b/packages/review/src/review-api/animation-drafts.ts @@ -3,7 +3,15 @@ // agent's output. import { createHash } from "node:crypto"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { + chmod, + cp, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; import path from "node:path"; import { @@ -24,6 +32,11 @@ import { isMissingFileError } from "@review/fs-utils.js"; import { findReviewPackageRoot } from "@review/package-paths.js"; import { z } from "zod"; +import { + type AnimationReference, + readAnimationReferences, +} from "./animation-references.js"; + const FILES = { html: "animation.html", frames: "frames.json", @@ -57,10 +70,12 @@ const handle = /^[\w-]{1,128}$/; /** The animation references, read-only beside the drafts so they always match * the Whiteboard that wrote them: whiteboard.css, the style kit every - * animation loads, and whiteboard.d.ts, the API its scripts call. */ + * animation loads, whiteboard.d.ts, the API its scripts call, and the + * reference animations an agent copies from. */ export interface StageReference { styles: string; api: string; + references: AnimationReference[]; } async function writeReadOnly(file: string, content: string): Promise { @@ -69,27 +84,83 @@ async function writeReadOnly(file: string, content: string): Promise { await writeFile(file, content, { mode: 0o444 }); } -export async function writeStageReference(): Promise { +/** Copies `source` to `target` with every file read-only, replacing what was there. */ +async function copyReadOnly(source: string, target: string): Promise { + await rm(target, { recursive: true, force: true }); + await cp(source, target, { recursive: true }); + + for (const entry of await readdir(target, { + recursive: true, + withFileTypes: true, + })) + if (entry.isFile()) + await chmod(path.join(entry.parentPath, entry.name), 0o444); +} + +export async function writeStageReference( + root = findReviewPackageRoot(import.meta.url), +): Promise { const directory = path.join(devReviewHome(), "animation-drafts"); const styles = path.join(directory, "whiteboard.css"); const api = path.join(directory, "whiteboard.d.ts"); + const copies = path.join(directory, "references"); + const sources = await readAnimationReferences(root, { strict: false }); - const source = path.join( - findReviewPackageRoot(import.meta.url), - "instructions", - "whiteboard.d.ts", + await mkdir(copies, { recursive: true }); + await writeReadOnly(styles, stageStyleReference()); + await writeReadOnly( + api, + await readFile(path.join(root, "instructions", "whiteboard.d.ts"), "utf8"), ); - await mkdir(directory, { recursive: true }); - await writeReadOnly(styles, stageStyleReference()); - await writeReadOnly(api, await readFile(source, "utf8")); + const names = new Set(sources.map(({ name }) => name)); + + for (const stale of await readdir(copies)) + if (!names.has(stale)) + await rm(path.join(copies, stale), { recursive: true, force: true }); + + const references = await Promise.all( + sources.map(async (reference) => { + const copy = path.join(copies, reference.name); + + await copyReadOnly(reference.directory, copy); - return { styles, api }; + return { ...reference, directory: copy }; + }), + ); + + return { styles, api, references }; } /** The line that sends an agent to the references before it writes an animation. */ -export const stageReferenceHint = ({ styles, api }: StageReference) => - `Before writing or restyling an animation's HTML, read ${styles} (the classes and --wb-* variables every animation loads) and ${api} (the whiteboard API its scripts call). Both are read-only and rewritten by Whiteboard.`; +export const stageReferenceHint = ({ + styles, + api, + references, +}: StageReference) => + `Before writing or restyling an animation's HTML, read ${styles} (the classes and --wb-* variables every animation loads) and ${api} (the whiteboard API its scripts call).${references.length ? ` Reference animations to start from are in ${path.dirname(references[0]!.directory)}: read the README of the closest one.` : ""} All are read-only and rewritten by Whiteboard.`; + +/** Lists the reference animations where the agent can read them. */ +function referencesSection({ references }: StageReference): string[] { + if (!references.length) return []; + + return [ + [ + "## References", + "", + "Before you write an animation, read the README of the reference closest to what you are drawing, then copy its files into your draft folder as a starting point.", + "", + ...references.map( + ({ name, summary, directory }) => + `- \`${name}\`: ${summary} ${directory}`, + ), + ].join("\n"), + ]; +} + +/** What follows the animation instructions: the hint, then the references. */ +export const stageReferenceText = (reference: StageReference) => + [stageReferenceHint(reference), ...referencesSection(reference)].join("\n\n"); export function draftDirectory( sessionId: string, @@ -167,7 +238,8 @@ export async function checkoutAnimation( return { directory, files, - ...reference, + styles: reference.styles, + api: reference.api, next: `Edit these files with your file tools, then publish the folder: session_edit {sessionId, edit: {type: "update", targetId: "${targetId}", fromFiles: "${directory}"}}. ${stageReferenceHint(reference)}`, }; } @@ -205,14 +277,15 @@ async function readJson(directory: string, name: string, required: boolean) { } } -/** The animation a folder holds, as a block's fields, and where it came from. */ -async function readDraft(directory: string) { - const [html, frames, scenarios, block, checkout] = await Promise.all([ +/** The animation a folder holds, as a block's fields, checked as a publish would be. */ +export async function readAnimationFolder( + directory: string, +): Promise { + const [html, frames, scenarios, block] = await Promise.all([ readText(directory, FILES.html, true), readJson(directory, FILES.frames, true), readJson(directory, FILES.scenarios, false), readJson(directory, FILES.block, true), - readJson(directory, FILES.checkout, false), ]); if (!isJsonObject(block)) @@ -231,6 +304,16 @@ async function readDraft(directory: string) { validate(fields); + return fields; +} + +/** The animation a folder holds, as a block's fields, and where it came from. */ +async function readDraft(directory: string) { + const [fields, checkout] = await Promise.all([ + readAnimationFolder(directory), + readJson(directory, FILES.checkout, false), + ]); + const parsed = checkoutSchema.safeParse(checkout); return { fields, checkout: parsed.success ? parsed.data : undefined }; diff --git a/packages/review/src/review-api/animation-references.test.ts b/packages/review/src/review-api/animation-references.test.ts new file mode 100644 index 0000000000..b67ac087dd --- /dev/null +++ b/packages/review/src/review-api/animation-references.test.ts @@ -0,0 +1,171 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { type JsonObject, type JsonValue, isJsonObject } from "@dev.fast/json"; +import type { Hono } from "hono"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { readAnimationFolder } from "./animation-drafts.js"; +import { readAnimationReferences } from "./animation-references.js"; +import { createReviewApi } from "./http.js"; +import { openLocalReviewStore } from "./local-data.js"; + +let directory: string, repository: string, reviewId: string; + +let local: ReturnType; + +let app: Hono; + +const git = (...args: string[]) => + execFileSync("git", args, { cwd: repository, encoding: "utf8" }).trim(); + +async function post(route: string, body: JsonValue) { + const response = await app.request(route, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + + return { + status: response.status, + body: (await response.json()) as { error?: string; reviewId?: string }, + }; +} + +beforeEach(async () => { + directory = mkdtempSync(path.join(tmpdir(), "animation-references-")); + repository = path.join(directory, "repository"); + vi.stubEnv("DEV_REVIEW_HOME", directory); + mkdirSync(repository); + git("init", "-q"); + git("config", "user.name", "Reference Test"); + git("config", "user.email", "references@example.invalid"); + writeFileSync(path.join(repository, "order.ts"), 'status = "draft";\n'); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-qm", "Base"); + writeFileSync(path.join(repository, "order.ts"), 'status = "queued";\n'); + git("-c", "commit.gpgsign=false", "commit", "-qam", "Head"); + local = openLocalReviewStore(path.join(directory, "reviews.db")); + + const registered = await local.data.register(repository); + const pins = await local.data.resolvePins(registered.id, "HEAD^", "HEAD"); + + // Animations sit behind a preference. + app = createReviewApi( + local.store, + local.data, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + () => true, + ); + + const created = await post("/commands", { + operation: { + type: "create", + title: "References", + target: { + kind: "commits", + repositoryPath: repository, + base: pins.base, + head: pins.head, + }, + }, + }); + + reviewId = created.body.reviewId!; +}); + +afterEach(async () => { + await local.store.close(); + await local.data.close(); + vi.unstubAllEnvs(); + rmSync(directory, { recursive: true, force: true }); +}); + +/** Code anchors in references are illustrative; the test repository has this one. */ +const ANCHOR = "head/order.ts#L1"; + +describe("the reference animations are what the API accepts", () => { + it("inserts every reference through the API", async () => { + for (const reference of await readAnimationReferences()) { + const fields = await readAnimationFolder(reference.directory); + const content: JsonObject = { type: "animation", ...fields }; + + if (isJsonObject(fields.code)) + content.code = Object.fromEntries( + Object.entries(fields.code).map(([name, range]) => [ + name, + isJsonObject(range) ? { ...range, source: ANCHOR } : ANCHOR, + ]), + ); + + const result = await post("/commands", { + operation: { + type: "edit", + reviewId, + edit: { type: "insert", content }, + }, + }); + + expect({ name: reference.name, ...result }).toEqual({ + name: reference.name, + status: 200, + body: result.body, + }); + } + }); + + it("reads no references from a package without any", async () => { + expect(await readAnimationReferences(directory)).toEqual([]); + }); + + it("throws on a broken reference for the tests, and leaves it out for agents", async () => { + const animations = path.join(directory, "instructions", "animations"); + + const write = (name: string, files: Record) => { + mkdirSync(path.join(animations, name), { recursive: true }); + + for (const [file, text] of Object.entries(files)) + writeFileSync(path.join(animations, name, file), text); + }; + + write("good", { + "animation.html": "

Good

", + "frames.json": JSON.stringify([{ key: "one", title: "One" }]), + "block.json": JSON.stringify({ title: "Good" }), + "README.md": "# Good\n\nA good reference.\n", + }); + // A frame key the schema refuses. + write("broken", { + "animation.html": "

Broken

", + "frames.json": JSON.stringify([{ key: "1 bad", title: "Bad" }]), + "block.json": JSON.stringify({ title: "Broken" }), + "README.md": "# Broken\n\nA broken reference.\n", + }); + + await expect(readAnimationReferences(directory)).rejects.toThrow( + /Reference animation broken/, + ); + expect( + (await readAnimationReferences(directory, { strict: false })).map( + ({ name }) => name, + ), + ).toEqual(["good"]); + }); + + it("gives every reference a title and a summary from its README", async () => { + for (const { name, title, summary } of await readAnimationReferences()) + expect({ + name, + title: Boolean(title), + summary: Boolean(summary), + }).toEqual({ name, title: true, summary: true }); + }); +}); diff --git a/packages/review/src/review-api/animation-references.ts b/packages/review/src/review-api/animation-references.ts new file mode 100644 index 0000000000..ab56550af2 --- /dev/null +++ b/packages/review/src/review-api/animation-references.ts @@ -0,0 +1,92 @@ +// The reference animations shipped in instructions/animations: worked examples +// an agent copies as a starting point. Each folder is an animation draft plus a +// README, read by the same code that publishes a draft. + +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; + +import { ReviewInputError } from "@dev.fast/whiteboard-document"; +import { isMissingFileError } from "@review/fs-utils.js"; +import { findReviewPackageRoot } from "@review/package-paths.js"; + +import { readAnimationFolder } from "./animation-drafts.js"; + +export type AnimationReference = { + name: string; + directory: string; + title: string; + summary: string; +}; + +/** The README's H1 and the paragraph right after it. */ +function readmeHeading(file: string, text: string) { + const lines = text.split(/\r?\n/); + const start = lines.findIndex((line) => line.trim()); + const title = lines[start]?.match(/^# (.+)$/)?.[1]?.trim(); + const rest = lines.slice(start + 1); + const from = rest.findIndex((line) => line.trim()); + const paragraph = []; + + for (const line of from < 0 ? [] : rest.slice(from)) { + if (!line.trim() || line.startsWith("#")) break; + paragraph.push(line.trim()); + } + + const summary = paragraph.join(" "); + + if (!title || !summary) + throw new ReviewInputError( + `${file} must open with "# Title" followed by a one-sentence summary paragraph.`, + ); + + return { title, summary }; +} + +/** Every reference animation, sorted by name, each checked as a publish would + * be. Strict (the default) throws on a broken reference, for the tests that + * keep them valid; otherwise a broken one is left out, so an agent's checkout + * and instructions never fail on a reference. */ +export async function readAnimationReferences( + root = findReviewPackageRoot(import.meta.url), + { strict = true } = {}, +): Promise { + const parent = path.join(root, "instructions", "animations"); + let entries; + + try { + entries = await readdir(parent, { withFileTypes: true }); + } catch (error) { + if (isMissingFileError(error)) return []; + throw error; + } + + const names = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + const references = await Promise.all( + names.map(async (name): Promise => { + const directory = path.join(parent, name); + const readme = path.join(directory, "README.md"); + + try { + await readAnimationFolder(directory); + + return { + name, + directory, + ...readmeHeading(readme, await readFile(readme, "utf8")), + }; + } catch (error) { + if (!strict) return undefined; + + throw new ReviewInputError( + `Reference animation ${name}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }), + ); + + return references.filter((reference) => reference !== undefined); +} diff --git a/packages/review/src/review-api/public-tools.ts b/packages/review/src/review-api/public-tools.ts index 1165259b05..fd512224fe 100644 --- a/packages/review/src/review-api/public-tools.ts +++ b/packages/review/src/review-api/public-tools.ts @@ -12,7 +12,7 @@ import { checkoutAnimation, recordPublish, resolveDraftEdit, - stageReferenceHint, + stageReferenceText, writeStageReference, } from "./animation-drafts.js"; import type { ReviewApiClient } from "./client.js"; @@ -131,7 +131,7 @@ export async function callPublicTool( if (tool.name === "session_get_instructions" && rest.topic === "animation") { const instructions = await callAuthoringTool(client, tool, fields, signal); - const hint = stageReferenceHint(await writeStageReference()); + const hint = stageReferenceText(await writeStageReference()); if (instructions instanceof ToolText) return new ToolText(`${instructions.text}\n\n${hint}`); From 1eade2d7dacafe9a5642ddcd4425ac1fe324fd10 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Sat, 31 Oct 2026 21:52:28 -0400 Subject: [PATCH 2/6] Tell animation authors about hand-made layouts and moving elements The eight reference animations turned up where the guidance misled their authors. The animation instructions now name the third way to draw, a hand-made layout with arrows, tokens, travels and timelines declared while the script first runs, and the lifecycle attributes that need no script; they say how to move an element between frames, that every keyframe needs its start value for a jump to land, and that whiteboard.frames follows the active scenario. The API reference says where a string is a selector and where it is a card id, and the class guide names the card's head and dot. Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f --- packages/review/instructions/animation.md | 22 +++++++++++++++++--- packages/review/instructions/whiteboard.d.ts | 17 ++++++++++----- packages/review/src/animation-stage-kit.ts | 2 +- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/packages/review/instructions/animation.md b/packages/review/instructions/animation.md index 54cd770621..391faf214f 100644 --- a/packages/review/instructions/animation.md +++ b/packages/review/instructions/animation.md @@ -15,7 +15,9 @@ For a new animation, write the same files into a folder of your own and publish Before you write HTML, read the two files whose paths `session_checkout` and these instructions give: `whiteboard.css`, the classes and `--wb-*` variables every animation loads, and `whiteboard.d.ts`, the `whiteboard` API. Use the classes and variables rather than your own colors, sizes or fonts, so the animation matches the board in light and dark. -There are two ways to draw. +Start from the reference closest to what you are drawing, listed under References at the end of these instructions: copy its folder into your draft, then change the data, the prose and the code links. + +There are three ways to draw. ### A scene: cards and messages @@ -34,7 +36,17 @@ For a few services, processes or stores with messages moving between them, use ` ``` -A scene names frame keys, so a new scenario means new frames and a few new lines like these. +A scene names frame keys, so a new scenario means new frames and a few new lines like these. A card's `id` is its element's id, so your own CSS can address it as `#api`. + +### A hand-made layout: lanes, axes, graphs + +When the drawing is not a grid of cards (attempts on a time axis, two lanes interleaving, a state graph), lay out your own elements with the kit (`wb-node`, `wb-lane`, `wb-table`) and connect them: + +- `whiteboard.arrow("#caller", "#service", { enter: "a1" })` draws an SVG arrow that follows the two elements. A string here is a CSS selector; in a scene's methods it is a card id. +- `whiteboard.token({ label: "GET" })` and `whiteboard.travel(token, arrow, "a1")` ride a chip along the arrow during that frame. +- `whiteboard.timeline("a1").add(".wait", { scaleX: [0, 1], duration: 600 })` adds tweens that play when the reader steps into the frame and land at their end on a jump. + +Declare arrows, tokens, travels and timelines while the script first runs, naming the frames they play in; they cannot be added from a frame handler. `data-enter="a1"`, `data-exit="a3"`, `data-only="a1 a3"` and `data-highlight="a1 a3"` on any element show, hide or ring it in those frames with no script. ### Your own HTML: draw each frame from its data @@ -65,7 +77,11 @@ Draw the frame in full, then animate what is new since the previous frame: ``` -Write each frame's data in full, not as a change from the one before: a reader can jump straight to any frame. `whiteboard.animate` skips the motion on a jump and under reduced motion, and returning its animations makes playback wait for them. +Write each frame's data in full, not as a change from the one before: a reader can jump straight to any frame. `whiteboard.animate` skips the motion on a jump and under reduced motion, and returning its animations makes playback wait for them. Give every keyframe its start value (`[{ opacity: 0 }, { opacity: 1 }]`, not `[{ opacity: 1 }]`): a jump finishes the animation, and a missing start lands wrong. + +Something that moves between frames (a job changing queue slot, a record regrouped under a new parent) is drawn the same way: measure every element's position, redraw, then animate each moved element from its old position to `transform: none`. Measure everything before starting any animation; one that has started already reports its transform. + +`whiteboard.frames` and `frame.index` follow the active scenario, so a frame outside it is not listed. Key your drawing on `frame.data`, not on positions. ## Link the code diff --git a/packages/review/instructions/whiteboard.d.ts b/packages/review/instructions/whiteboard.d.ts index 13e6691e2c..1a2418b067 100644 --- a/packages/review/instructions/whiteboard.d.ts +++ b/packages/review/instructions/whiteboard.d.ts @@ -44,7 +44,8 @@ export type FrameResult = | Promise; export interface Whiteboard { - /** The active path's frames, in order: a scenario's, or all of them. */ + /** The active path's frames, in order: a scenario's, or all of them. A + * frame outside the active scenario is not listed. */ readonly frames: readonly Frame[]; /** The current frame object; read `.key` for its name. */ readonly frame: Frame; @@ -62,7 +63,9 @@ export interface Whiteboard { * another frame shows. Returns an unsubscribe function. */ on(key: string, handler: (change: FrameChange) => FrameResult): () => void; /** element.animate that holds its end state, lands at the end on a jump, and - * skips motion under reduced motion. */ + * skips motion under reduced motion. Give every keyframe its start value, and + * measure positions before starting one: a started animation already + * reports its transform. */ animate( target: Element | string, keyframes: Keyframe[] | PropertyIndexedKeyframes, @@ -70,7 +73,8 @@ export interface Whiteboard { ): Animation; /** The frame's part of one anime.js timeline: it plays when the reader steps * into the frame and lands at its end on a jump. A tween starts where the - * frames before it left the element, so write only where it goes. */ + * frames before it left the element, so write only where it goes. Add to it + * while the script first runs, not from a frame handler. */ timeline(key: string): FrameTimeline; /** anime.js v4, also the global `anime`: easings, stagger, utils. */ readonly anime: unknown; @@ -106,7 +110,8 @@ export interface Whiteboard { /** Move a token along an arrow during a frame, at the arrow's live geometry. * It fades out on arrival unless `stay` keeps it at the head. Trips append * to the frame; `position: 0` starts trips together, a number of - * milliseconds sequences one after another. */ + * milliseconds sequences one after another. Declare trips while the script + * first runs, not from a frame handler. */ travel( token: HTMLElement | string, arrow: SVGGElement, @@ -140,7 +145,9 @@ export interface TravelOptions { stay?: boolean; } -/** A scene card, its id, or an element. */ +/** A scene card, its id, or an element. In `whiteboard.arrow` and `travel` + * a string is a CSS selector (`"#api"`); in a scene's methods it is a card + * id. */ export type CardRef = SceneCard | string | Element; /** 1-based column and row, with optional row and column spans: [1, 2, 1, 4] is diff --git a/packages/review/src/animation-stage-kit.ts b/packages/review/src/animation-stage-kit.ts index 7d1decb52d..3345113242 100644 --- a/packages/review/src/animation-stage-kit.ts +++ b/packages/review/src/animation-stage-kit.ts @@ -149,7 +149,7 @@ const CLASSES: [selector: string, use: string][] = [ ], [ ".wb-card", - "A box with a title and note: put .wb-card-title and .wb-card-note inside. The scene's cards are these.", + "A box with a title and note: a .wb-card-head holding a .wb-card-dot and the .wb-card-title, then a .wb-card-note. The scene's cards are these.", ], [ ".wb-node", From ef430f72ceda4aa48b3cf36175c2470eee5b68d8 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Sat, 31 Oct 2026 22:08:47 -0400 Subject: [PATCH 3/6] Walk an animation's code as a tour that steps with its frames An animation whose frames list code now has a tour: one stop per frame of the path showing, a frame's code as the stop's source and its caption as an explanation stop otherwise. The header's Expand becomes the diagrams' Tour control, which opens the fullscreen tour overlay with the figure as the stage and the guided tour panel beside it; an animation with no code keeps its plain Expand. The panel and the frames move together both ways: a stop picked in the panel shows its frame, and a frame reached by the controls, playback, the stage or a scenario switch moves the panel, which stays open on a scenario switch at the frame showing. Inline, a drawn element's code opens in the column with the tour at the frame showing, and while the column shows this tour it follows the frames and its own steps move them. Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f --- .../app/src/animation/animation-block.tsx | 305 +++++++++++++++--- .../app/src/animation/animation-tour.test.ts | 107 ++++++ .../app/src/animation/animation-tour.ts | 83 +++++ .../review/app/src/blocks.browser.test.tsx | 210 ++++++++++++ packages/review/app/src/review-panel-store.ts | 2 +- packages/review/app/src/review-view-state.ts | 5 +- 6 files changed, 671 insertions(+), 41 deletions(-) create mode 100644 packages/review/app/src/animation/animation-tour.test.ts create mode 100644 packages/review/app/src/animation/animation-tour.ts diff --git a/packages/review/app/src/animation/animation-block.tsx b/packages/review/app/src/animation/animation-block.tsx index d7596a2ba1..ed40d5176c 100644 --- a/packages/review/app/src/animation/animation-block.tsx +++ b/packages/review/app/src/animation/animation-block.tsx @@ -3,9 +3,11 @@ import { CodePeekCard } from "@canvas/CodePeek"; import { useReviewDebugSettings } from "@canvas/debug-settings"; import { DiagramHeader } from "@canvas/diagram-header"; import { diagramStyles } from "@canvas/diagram-styles"; -import { useDiagramTourShell } from "@canvas/diagram-tour"; +import { DiagramTourOverlay, useDiagramTourShell } from "@canvas/diagram-tour"; import { drawStyles } from "@canvas/draw-styles"; -import { useOptionalReviewPanel } from "@canvas/review-panel"; +import { countLabel } from "@canvas/format"; +import { useReviewSession } from "@canvas/host/review-session"; +import { useReviewPanel, useReviewPanelStore } from "@canvas/review-panel"; import type { SourcePeekAnchor } from "@canvas/review-panel-model"; import { fontSize, @@ -18,10 +20,13 @@ import { shellStyles } from "@canvas/shell-styles"; import { withClass } from "@canvas/stylex-props"; import { themeClass, themeStyles } from "@canvas/theme-styles"; import { tokens } from "@canvas/tokens.stylex"; +import { captureUiEvent } from "@canvas/ui-telemetry"; import { Button, IconButton } from "@canvas/ui/button"; import { SegmentedControl } from "@canvas/ui/segmented"; import { textStyles } from "@canvas/ui/text"; import { useTooltip } from "@canvas/ui/tooltip"; +import { useDiagramSelection } from "@canvas/use-diagram-selection"; +import { useWhiteboardSettings } from "@canvas/whiteboard-settings"; import { type Block, anchorSelection, @@ -33,8 +38,10 @@ import * as stylex from "@stylexjs/stylex"; import { type KeyboardEvent, type ReactNode, + type RefObject, useCallback, useEffect, + useEffectEvent, useLayoutEffect, useMemo, useRef, @@ -42,6 +49,7 @@ import { } from "react"; import { createPortal } from "react-dom"; +import { animationTour, frameAnchorId, frameContent } from "./animation-tour"; import { useReportAnimationRun } from "./report-run"; import { type HostMessage, @@ -142,8 +150,9 @@ export function AnimationBlock({ children, }: BlockProps<"animation">) { const { theme } = useReviewDebugSettings(); - const openPeek = useOptionalReviewPanel((state) => state.openPeek); - const figureRef = useRef(null); + const panelStore = useReviewPanelStore(); + const session = useReviewSession(); + const { diagramFullscreenOnClick } = useWhiteboardSettings(); const frameRef = useRef(null); const portRef = useRef(null); const [index, setIndex] = useState(0); @@ -157,10 +166,11 @@ export function AnimationBlock({ const reportRun = useReportAnimationRun(data.snapshot.reviewId, node.id); - // The inline figure's height while it is out fullscreen, held by a - // placeholder so the document keeps its place. - const [expanded, setExpanded] = useState(null); - const expandRef = useRef(null); + // Fullscreen without a tour. Either way, a placeholder holds the inline + // figure's height so the document keeps its place. + const [expanded, setExpanded] = useState(false); + const [held, setHeld] = useState(0); + const openRef = useRef(null); const closeRef = useRef(null); const indexRef = useRef(index); indexRef.current = index; @@ -193,6 +203,33 @@ export function AnimationBlock({ const steps = activeFrames.every((item) => item.label) ? activeFrames : null; + // A stop per frame of the path showing, when its frames show code. + const tour = useMemo( + () => + animationTour( + { id: node.id, title: node.title }, + data.anchors, + activeFrames, + ), + [node.id, node.title, data.anchors, activeFrames], + ); + + const frameAnchor = tour && frameAnchorId(node.id, frame.key); + + // Unlike a diagram's, the tour stays open on a stop it no longer has: a + // scenario switch moves it to the frame showing instead. + const overlay = useReviewPanel((state) => + tour && state.overlayTour?.tourId === node.id ? state.overlayTour : null, + ); + + const tourOpen = overlay !== null; + + // Set while the column shows this tour. + const { anchor: inlineAnchor, figureRef } = useDiagramSelection( + tour, + tourOpen || expanded, + ); + // A new stage for each edit of what it runs; frame changes reuse it. const stageKey = useMemo( () => @@ -245,19 +282,53 @@ export function AnimationBlock({ [frames, send], ); - const reveal = useCallback( - (name: string) => { - const anchor = data.anchors.get(animationCodeId(node.id, name)); + const openTour = () => { + if (!tour || !frameAnchor) return; + + if (!tourOpen) { + captureUiEvent(session, "tour_started", { steps: tour.stops.length }); - if (!anchor || !openPeek) return; - openPeek({ + if (!expanded) setHeld(figureRef.current?.offsetHeight ?? 0); + } + + setExpanded(false); + panelStore + .getState() + .openOverlayTour({ tourId: node.id, kind: "animation" }, frameAnchor); + }; + + // A drawn element's code opens beside the board with the tour, at the + // frame showing, or with the tour fullscreen. + const reveal = (name: string) => { + const anchor = data.anchors.get(animationCodeId(node.id, name)); + + if (!anchor) return; + captureUiEvent(session, "peek_opened", { via: "diagram" }); + const { openPeek, moveOverlayTour, active } = panelStore.getState(); + + const stop = tour?.stops.find((item) => item.anchor.id === frameAnchor); + + if (!tour || !stop) + return openPeek({ kind: "peek", anchor, content: { kind: "source", source: anchor.peek }, }); - }, - [data.anchors, node.id, openPeek], - ); + + if (tourOpen) return moveOverlayTour(stop.anchor.id, { reveal: true }); + + if (expanded || diagramFullscreenOnClick) return openTour(); + + openPeek({ + kind: "peek", + anchor: stop.anchor, + content: frameContent(node.id, data.anchors, frame, name), + tour: { + definition: tour, + revealRequest: (active?.tour?.revealRequest ?? 0) + 1, + }, + }); + }; // A stage script's setSequence() and the scenario picker both land here. const selectSequence = (keys: string[]) => { @@ -466,21 +537,108 @@ export function AnimationBlock({ if (!activeFrames.some((item) => item.key === frame.key)) go(0, true); }, [activeFrames, frame.key, go]); - const collapse = useCallback(() => setExpanded(null), []); + const { closeOverlayTour, moveOverlayTour } = panelStore.getState(); + const collapse = useCallback(() => setExpanded(false), []); + const close = tourOpen ? closeOverlayTour : collapse; + + const { portalTarget } = useDiagramTourShell(tourOpen || expanded, close); - const { portalTarget } = useDiagramTourShell(expanded !== null, collapse); + const fullscreen = (tourOpen || expanded) && portalTarget !== null; // Focus follows the figure in and back out to the control that opened it. - const wasExpanded = useRef(false); + const wasFullscreen = useRef(false); + + useEffect(() => { + if (fullscreen) closeRef.current?.focus(); + else if (wasFullscreen.current) openRef.current?.focus(); + + wasFullscreen.current = fullscreen; + }, [fullscreen]); + + // The tour's panel picked a stop (its Next, a click, its scroll): show + // that frame, moving to it when it is the next one. A stop the tour no + // longer has, after a scenario switch, gives way to the frame showing. + const followPanel = useEffectEvent(() => { + const anchor = panelStore.getState().overlayTour?.anchor; + + if (!tour || !frameAnchor || !anchor) return; + const position = tour.stops.findIndex((stop) => stop.anchor.id === anchor); + + if (position < 0) moveOverlayTour(frameAnchor, { reveal: true }); + else if (position !== activeIndex) + go(position, Math.abs(position - activeIndex) > 1); + }); + + const overlayAnchor = overlay?.anchor; + + useEffect(() => { + if (overlayAnchor) followPanel(); + }, [overlayAnchor, tour]); + + // The column's tour picked a stop (its Next, a click, its scroll): show + // that frame, as the overlay's panel does. + const followColumn = useEffectEvent(() => { + if (!tour || fullscreen || !inlineAnchor) return; + + const position = tour.stops.findIndex( + (stop) => stop.anchor.id === inlineAnchor, + ); + + if (position >= 0 && position !== activeIndex) + go(position, Math.abs(position - activeIndex) > 1); + }); useEffect(() => { - if (expanded !== null) closeRef.current?.focus(); - else if (wasExpanded.current) expandRef.current?.focus(); + if (inlineAnchor) followColumn(); + }, [inlineAnchor, tour]); + + // The frame changed some other way (the controls, playback, the stage, a + // scenario): the open tour follows it, fullscreen or in the column. Each + // side writes only what differs, so neither echoes the other. + const followFrame = useEffectEvent(() => { + const { overlayTour, active, openPeek } = panelStore.getState(); + + if (!tour || !frameAnchor) return; - wasExpanded.current = expanded !== null; - }, [expanded]); + if (overlayTour?.tourId === node.id) { + if (overlayTour.anchor !== frameAnchor) + moveOverlayTour(frameAnchor, { reveal: true }); - const fullscreen = expanded !== null && portalTarget !== null; + return; + } + + const stop = tour.stops.find((item) => item.anchor.id === frameAnchor); + + if ( + !inlineAnchor || + fullscreen || + !stop || + (active?.anchor?.id === frameAnchor && active.tour?.definition === tour) + ) + return; + openPeek({ + kind: "peek", + anchor: stop.anchor, + content: stop.content, + tour: { + definition: tour, + revealRequest: (active?.tour?.revealRequest ?? 0) + 1, + }, + }); + }); + + // Not on mount: a restored tour's stop leads the frame there. + const followed = useRef({ frameAnchor, tour }); + + useEffect(() => { + if ( + followed.current.frameAnchor === frameAnchor && + followed.current.tour === tour + ) + return; + followed.current = { frameAnchor, tour }; + followFrame(); + }, [frameAnchor, tour]); const onKeyDown = (event: KeyboardEvent) => { if (event.key === "ArrowRight") go(activeIndex + 1, false); @@ -507,20 +665,34 @@ export function AnimationBlock({ titleStyle={styles.headerTitle} meta={`${activeIndex + 1} / ${activeFrames.length}`} action={ - + fullscreen ? ( + + ) : tour ? ( + + ) : ( + + ) } /> {node.scenarios && ( @@ -684,11 +856,32 @@ export function AnimationBlock({ if (!fullscreen) return figure; + const placeholder = - {steps && ( -
- {steps.map((item, position) => ( - - ))} -
+ {player.afterStage} + {player.strip && ( + player.pick(position, "controls")} + onKeyDown={player.onKeyDown} + /> )} -
- go(activeIndex - 1, false)} - > - - - = last ? "Replay" : "Play"} - onClick={() => { - if (playing) return setPlaying(false); - - if (activeIndex >= last) go(0, true); - - setPlaying(true); - }} - > - {playing ? ( - - ) : ( - - )} - - = last} - onClick={() => go(activeIndex + 1, false)} - > - - -
- {frame.title} - {frame.caption && ( - {frame.caption} - )} -
- {!steps && ( -
- {activeFrames.map((item, position) => ( - - go(position, Math.abs(position - activeIndex) > 1) - } - /> - ))} -
- )} +
+ {player.controls}
{error && (

- The animation reported an error: {error} + The {noun} reported an error: {error}

)} @@ -974,7 +773,7 @@ export function AnimationBlock({ )} role="dialog" aria-modal="true" - aria-label={node.title ?? "Animation"} + aria-label={node.title ?? (clip ? "Clip" : "Animation")} > {figure}
, @@ -984,9 +783,6 @@ export function AnimationBlock({ ); } -const sameKeys = (left: readonly string[], right: readonly string[] | null) => - right?.length === left.length && left.every((key, i) => key === right[i]); - /** A host component drawn where the stage put its placeholder. */ function SlotOverlay({ slot, @@ -1066,43 +862,10 @@ function TourButton({ ); } -function ControlButton({ - label, - disabled, - onClick, - children, -}: { - label: string; - disabled?: boolean; - onClick(): void; - children: ReactNode; -}) { - const tooltip = useTooltip(label, { quick: true }); - - return ( - - - - ); -} - /** What hovering a drawn element that opens code says: its label, file and * lines. */ function codeTitles( - node: BlockProps<"animation">["node"], + node: StageBlockProps["node"], anchors: ReadonlyMap, ): Record { return Object.fromEntries( @@ -1121,29 +884,6 @@ function codeTitles( ); } -function FrameDot({ - title, - current, - onClick, -}: { - title: string; - current: boolean; - onClick(): void; -}) { - const tooltip = useTooltip(title, { instant: true }); - - return ( - + ))} + + ); +} + +const styles = stylex.create({ + icon: { + width: "16px", + height: "16px", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.6, + strokeLinecap: "round", + strokeLinejoin: "round", + }, + caption: { + flex: "1 1 auto", + minWidth: 0, + marginLeft: space.s6, + paddingBlock: space.s8, + color: tokens.inkMuted, + lineHeight: leading.px18, + }, + steps: { + display: { default: "flex", [stylex.env.narrow]: "grid" }, + gridTemplateColumns: { + default: null, + [stylex.env.narrow]: "repeat(4, minmax(0, 1fr))", + }, + overflowX: { default: "auto", [stylex.env.narrow]: "visible" }, + borderTopWidth: "1px", + borderTopStyle: "solid", + borderTopColor: tokens.rule, + }, + step: { + display: "flex", + flex: "1 1 0", + alignItems: "center", + justifyContent: "center", + gap: space.s4, + minWidth: { default: "fit-content", [stylex.env.narrow]: 0 }, + padding: { + default: `${space.s6} ${space.s10}`, + [stylex.env.narrow]: `${space.s6} ${space.s4}`, + }, + borderStyle: "none", + borderBottomWidth: "2px", + borderBottomStyle: "solid", + borderBottomColor: tokens.transparent, + backgroundColor: tokens.transparent, + color: tokens.inkMuted, + font: `${fontSize.micro} ${tokens.fontMono}`, + letterSpacing: tracking.chrome, + textAlign: "center", + textTransform: "uppercase", + whiteSpace: { default: "nowrap", [stylex.env.narrow]: "normal" }, + cursor: "pointer", + outline: { default: null, ":focus-visible": `1px solid ${tokens.accent}` }, + outlineOffset: "-2px", + }, + stepCurrent: { + borderBottomColor: tokens.accent, + color: tokens.ink, + }, + stepIndex: { + color: "inherit", + }, +}); diff --git a/packages/review/app/src/animation/stage-player.ts b/packages/review/app/src/animation/stage-player.ts new file mode 100644 index 0000000000..47ed6cc311 --- /dev/null +++ b/packages/review/app/src/animation/stage-player.ts @@ -0,0 +1,58 @@ +// What differs between an animation, whose reader steps through frames, and +// a clip, whose reader plays one timeline and jumps between its markers. The +// block around either (the stage, its slots, the tour, painting in) is the +// same; each kind supplies one of these. + +import type { KeyboardEvent, ReactNode } from "react"; + +import type { HostMessage, StageInit, StageMessage } from "./stage-protocol"; + +/** One stop of the path showing: a frame, or one of a clip's markers. */ +export interface PlayerStop { + key: string; + title: string; + caption?: string; + /** The code names the stop lists, for its tour stop. */ + code?: string[]; +} + +/** Who picked a stop: the controls under the stage, or the tour. */ +export type PickedBy = "controls" | "tour"; + +export type StageSend = (message: HostMessage) => void; + +export interface StagePlayer { + /** The stops of the path showing, in order. */ + stops: readonly PlayerStop[]; + /** The stop showing, in `stops`. */ + activeIndex: number; + /** Show the stop at `position`, as the reader asked through `by`. */ + pick(position: number, by: PickedBy): void; + /** The kind's part of a new stage's first message; resets what the last + * stage reported. */ + boot(): Pick< + StageInit, + | "mode" + | "frames" + | "markers" + | "scenarios" + | "frame" + | "sequence" + | "scenario" + >; + /** A message only this kind's stage sends. */ + receive(message: StageMessage): void; + /** The scenario showing, by key. */ + scenario: string | undefined; + selectScenario(key: string): void; + /** The strip of named stops under the stage, when there is one. */ + strip: { label: string; items: { key: string; text: string }[] } | null; + /** Drawn right under the stage. */ + afterStage?: ReactNode; + /** The footer's controls and caption. */ + controls: ReactNode; + /** Keys pressed in the strip or the controls. */ + onKeyDown(event: KeyboardEvent): void; + /** Keys pressed on the figure itself, which takes focus when set. */ + onFigureKeyDown?(event: KeyboardEvent): void; +} diff --git a/packages/review/app/src/animation/stage-protocol.ts b/packages/review/app/src/animation/stage-protocol.ts index b32d3059b0..254f54325f 100644 --- a/packages/review/app/src/animation/stage-protocol.ts +++ b/packages/review/app/src/animation/stage-protocol.ts @@ -16,16 +16,39 @@ export interface StageFrame { code?: string[]; } +/** What the stage's script reads about one of a clip's markers. */ +export interface StageMarker { + key: string; + title: string; + caption?: string; + /** The code names the marker lists. */ + code?: string[]; +} + +/** A placed marker: where it sits on the active timeline, in milliseconds. */ +export interface PlacedMarker { + key: string; + at: number; +} + export type StageColorScheme = "light" | "dark"; /** The first message on the port: everything the stage needs to draw. */ export interface StageInit { type: "init"; + /** An animation's frames, stepped through, or a clip's one timeline, + * played. */ + mode: "frames" | "clip"; html: string; /** The style kit: the CSS every stage loads before its markup. */ kit: string; autoSize: boolean; + /** An animation's frames; empty for a clip. */ frames: StageFrame[]; + /** A clip's markers, as declared; empty for an animation. */ + markers: StageMarker[]; + /** A clip's scenario keys, which whiteboard.clip() accepts. */ + scenarios: string[]; /** The names whiteboard.code() accepts. */ code: string[]; /** What hovering an element that opens each code says. */ @@ -49,6 +72,8 @@ export interface StageInit { frame: number; /** The scenario the reader picked: the frame keys the stage starts on. */ sequence?: string[]; + /** The clip scenario the reader picked: the timeline the stage starts on. */ + scenario?: string; /** Library sources the stage runs before its own scripts: anime.js. */ libraries: string[]; } @@ -72,7 +97,14 @@ export type HostMessage = /** The reader picked a scenario: play these frame keys, in order. */ | { type: "sequence"; keys: string[] } /** The board's turn to draw this block came: paint the first frame in. */ - | { type: "paint" }; + | { type: "paint" } + /** A clip: play from the playhead, or from the start at the end. */ + | { type: "play" } + | { type: "pause" } + /** A clip: land on a marker, still playing if it was. */ + | { type: "seek"; marker: string } + /** A clip: the reader picked a scenario, a timeline of its own. */ + | { type: "scenario"; key: string }; /** The tallest an auto-sized stage may report itself. */ export const STAGE_MAX_HEIGHT = 4000; @@ -116,6 +148,30 @@ export const stageMessageSchema = z.discriminatedUnion("type", [ }), /** Every animation the frame started has finished. */ z.strictObject({ type: z.literal("settled"), index: z.number().int() }), + /** A clip's timeline is built: its length and every placed marker, by + * time. Sent again on a scenario change. */ + z.strictObject({ + type: z.literal("clip"), + duration: z.number().finite().nonnegative(), + markers: z + .array( + z.strictObject({ + key: z.string(), + at: z.number().finite().nonnegative(), + }), + ) + .max(60), + }), + /** A clip's playhead, while it plays and whenever it stops or jumps. */ + z.strictObject({ + type: z.literal("time"), + at: z.number().finite().nonnegative(), + playing: z.boolean(), + }), + /** A clip's playhead reached this marker. */ + z.strictObject({ type: z.literal("marker"), key: z.string() }), + /** A clip played to its end and paused there. */ + z.strictObject({ type: z.literal("ended") }), /** The first frame has painted in, after a `paint` message. */ z.strictObject({ type: z.literal("painted") }), z.strictObject({ type: z.literal("goto"), key: z.string() }), diff --git a/packages/review/app/src/animation/stage-reference.ts b/packages/review/app/src/animation/stage-reference.ts index 5b21b2b171..9ba9db0d52 100644 --- a/packages/review/app/src/animation/stage-reference.ts +++ b/packages/review/app/src/animation/stage-reference.ts @@ -6,9 +6,13 @@ import type * as Reference from "../../../instructions/whiteboard"; import type { ArrowOptions, + ClipMarker, + ClipTimeline, FrameChange, FrameTimeline, + MarkerChange, PaintChange, + ReachedMarker, Scene, SceneCardOptions, SceneChange, @@ -39,6 +43,10 @@ export type StageReferenceChecks = [ Check>, Check>, Check>, + Check>, + Check>, + Check>, + Check>, Check>, Check>, Check>, diff --git a/packages/review/app/src/animation/stage-runtime.ts b/packages/review/app/src/animation/stage-runtime.ts index d7821e4792..0bb1a8d0f7 100644 --- a/packages/review/app/src/animation/stage-runtime.ts +++ b/packages/review/app/src/animation/stage-runtime.ts @@ -8,10 +8,12 @@ import type { import type { HostMessage, + PlacedMarker, SlotLayout, StageColorScheme, StageFrame, StageInit, + StageMarker, StageMessage, } from "./stage-protocol"; @@ -35,10 +37,29 @@ type FrameResult = void | Animation | readonly Animation[] | Promise; type FrameHandler = (change: FrameChange) => FrameResult; +/** A clip's marker as its script reads it: declared, then placed in time. */ +export type ClipMarker = StageMarker & { + /** Milliseconds into the active timeline, once the scripts have run. */ + at?: number; +}; + +/** A marker the playhead has reached: placed, with its place in time order. */ +export type ReachedMarker = StageMarker & { at: number; index: number }; + +/** What a marker handler learns as a clip's playhead reaches its marker. */ +export interface MarkerChange { + marker: ReachedMarker; + /** A jump, the first draw, or reduced motion: draw the end state at once. */ + instant: boolean; +} + +type MarkerHandler = (change: MarkerChange) => FrameResult; + /** What a paint handler learns when the block paints its first frame in. */ export interface PaintChange { - /** The first frame, already drawn in its end state. */ - frame: StageFrame & { index: number }; + /** The first frame, already drawn in its end state; absent in a clip, + * which paints in at time 0. */ + frame?: StageFrame & { index: number }; /** Skip motion and leave everything in its end state: the block loaded * without painting, or the reader prefers reduced motion. */ instant: boolean; @@ -218,6 +239,24 @@ export interface FrameTimeline { call(callback: () => void, position?: TimelinePosition): FrameTimeline; } +/** A clip's one timeline, or one scenario's. Numeric positions count from + * the timeline's start; strings are anime.js positions. */ +export interface ClipTimeline { + add( + targets: TargetsParam, + params: AnimationParams, + position?: TimelinePosition, + ): ClipTimeline; + set( + targets: TargetsParam, + params: AnimationParams, + position?: TimelinePosition, + ): ClipTimeline; + call(callback: () => void, position?: TimelinePosition): ClipTimeline; + /** Place a declared marker at the timeline's end so far, or at `position`. */ + marker(key: string, position?: TimelinePosition): ClipTimeline; +} + /** The global `whiteboard` the stage's scripts call. */ export interface WhiteboardShim { readonly frames: readonly (StageFrame & { index: number })[]; @@ -235,6 +274,8 @@ export interface WhiteboardShim { on(key: "paint", handler: PaintHandler): () => void; /** Run when a frame shows; "*" runs on every frame. */ on(key: string, handler: FrameHandler): () => void; + /** In a clip: run when the playhead reaches the marker; "*" on every one. */ + on(key: string, handler: MarkerHandler): () => void; /** element.animate that holds its end state and honors instant changes. */ animate( target: Element | string, @@ -262,6 +303,13 @@ export interface WhiteboardShim { * steps into the frame, and lands at its end on a jump. "paint" is a * timeline of its own that plays once as the block paints in. */ timeline(key: string): FrameTimeline; + /** A clip's timeline, or a scenario's: built while the scripts first run, + * once per key. Without a key, the timeline of scenarios with none. */ + clip(scenario?: string): ClipTimeline; + /** A clip's declared markers, each with its time once placed. */ + readonly markers: readonly ClipMarker[]; + /** The marker a clip's playhead last reached. */ + readonly marker: ReachedMarker | undefined; /** An SVG arrow between two elements that follows them as they move. */ arrow(from: CardRef, to: CardRef, options?: ArrowOptions): SVGGElement; /** A dot, or a pill with a label, that travel() moves along arrows. @@ -326,6 +374,8 @@ export function stageRuntime(port: MessagePort): void { let instantMode = false; let activeSequence: string[] | undefined; const handlers = new Map>(); + // A clip's handlers, by marker key. + const markerHandlers = new Map>(); const paintHandlers = new Set(); let theme: Record = {}; let colorScheme: StageColorScheme = "dark"; @@ -340,11 +390,25 @@ export function stageRuntime(port: MessagePort): void { // jump lands on the right one. const sceneAppliers: ((key: string) => void)[] = []; + const clipMode = () => init?.mode === "clip"; + + // A clip's active timeline, its placed markers in time order, and the one + // its playhead last reached. + let clipTimeline: Timeline | undefined; + let clipMarkers: PlacedMarker[] = []; + let lastMarker = -1; + const declaredFrames = () => (init?.frames ?? []).map((frame, index) => ({ ...frame, index })); - const frameIndex = (key: string) => - (init?.frames ?? []).findIndex((frame) => frame.key === key); + // The keys element states and scene changes name: an animation's frames, + // or a clip's markers. + const declaredKeys = () => + clipMode() + ? (init?.markers ?? []).map((marker) => marker.key) + : (init?.frames ?? []).map((frame) => frame.key); + + const frameIndex = (key: string) => declaredKeys().indexOf(key); const activeFrames = () => { const declared = declaredFrames(); @@ -360,8 +424,17 @@ export function stageRuntime(port: MessagePort): void { return ordered.map((frame, index) => ({ ...frame, index })); }; + // A clip's markers order by time on the active timeline. const activeFrameIndex = (key: string) => - activeFrames().findIndex((frame) => frame.key === key); + clipMode() + ? clipMarkers.findIndex((marker) => marker.key === key) + : activeFrames().findIndex((frame) => frame.key === key); + + // The frame showing, or the marker a clip's playhead last reached. + const currentKey = () => + clipMode() + ? (clipMarkers[lastMarker]?.key ?? "") + : (init?.frames[current]?.key ?? ""); const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => resolve())); @@ -530,7 +603,7 @@ export function stageRuntime(port: MessagePort): void { // their exit frame; data-only shows them during the frames it lists. A // state, not an event, so a jump lands right. const applyStates = () => { - const key = init?.frames[current]?.key ?? ""; + const key = currentKey(); document .querySelectorAll("[data-highlight]") @@ -544,11 +617,10 @@ export function stageRuntime(port: MessagePort): void { document .querySelectorAll("[data-enter], [data-exit]") .forEach((element) => { + // Before a clip's first marker, the position is -1. const position = activeFrameIndex(key); - - const enter = element.dataset.enter - ? activeFrameIndex(element.dataset.enter) - : 0; + const enterKey = element.dataset.enter; + const enter = enterKey ? activeFrameIndex(enterKey) : -1; const exit = element.dataset.exit ? activeFrameIndex(element.dataset.exit) @@ -557,7 +629,7 @@ export function stageRuntime(port: MessagePort): void { // An enter boundary outside this path never occurs. An exit boundary // outside it never ends an element that has already entered. element.dataset.wbState = - enter < 0 || position < enter + (enterKey && enter < 0) || position < enter ? "before" : exit >= 0 && position >= exit ? "after" @@ -643,6 +715,11 @@ export function stageRuntime(port: MessagePort): void { const isPaint = (key: string) => key === "paint" && frameIndex(key) < 0; const frameTimeline = (key: string): FrameTimeline => { + if (clipMode() && !isPaint(key)) + throw new Error( + `A clip has no frames, so timeline("${key}"), travel() and scene.send() do not apply: build the clip's timeline with whiteboard.clip() and place its markers with .marker().`, + ); + if (!isPaint(key) && frameIndex(key) < 0) throw new Error(`Unknown frame "${key}".`); @@ -812,6 +889,358 @@ export function stageRuntime(port: MessagePort): void { }); }; + // A clip is one timeline per scenario, queued while the scripts run as + // frame timelines are, with its markers as labels. Only the active one is + // built: anime.js lets a later animation take over an earlier one's + // properties, so the inactive timelines must not exist side by side. + type ClipEntry = + | Entry + | { kind: "marker"; key: string; position?: TimelinePosition }; + + // By scenario key; "" is the timeline built without one. + const clipQueued = new Map(); + + const clipBuilder = (scenario?: string): ClipTimeline => { + if (!clipMode()) + throw new Error( + "whiteboard.clip() builds a clip's timeline, and this block is an animation: use whiteboard.timeline(frameKey) for its frames.", + ); + + if (built) + throw new Error( + "Build the clip's timeline while its scripts first run, not from a handler.", + ); + + const scenarios = init?.scenarios ?? []; + + if (scenario !== undefined && !scenarios.includes(scenario)) + throw new Error( + scenarios.length + ? `Unknown scenario "${scenario}": the clip declares ${scenarios.join(", ")}.` + : `The clip has no scenarios: call whiteboard.clip() without "${scenario}".`, + ); + + const slot = scenario ?? ""; + + if (clipQueued.has(slot)) + throw new Error( + scenario === undefined + ? "whiteboard.clip() was already called: build its timeline once." + : `whiteboard.clip("${scenario}") was already called: build each scenario's timeline once.`, + ); + + const list: ClipEntry[] = []; + const placed = new Set(); + clipQueued.set(slot, list); + + const api: ClipTimeline = { + add(targets, params, position) { + list.push({ kind: "add", targets, params, position }); + + return api; + }, + set(targets, params, position) { + list.push({ kind: "set", targets, params, position }); + + return api; + }, + call(callback, position) { + list.push({ kind: "call", callback, position }); + + return api; + }, + marker(key, position) { + if (frameIndex(key) < 0) + throw new Error( + `Unknown marker "${key}": declare it in the clip's markers.`, + ); + + if (placed.has(key)) + throw new Error( + `Marker "${key}" is already placed on this timeline.`, + ); + placed.add(key); + list.push({ kind: "marker", key, position }); + + return api; + }, + }; + + return api; + }; + + const buildClip = (entries: ClipEntry[]) => { + const next = lib().createTimeline({ + autoplay: false, + defaults: { duration: 600, ease: "outQuad" }, + }); + + for (const entry of entries) { + if (entry.kind === "marker") + next.label(entry.key, entry.position ?? next.duration); + else if (entry.kind === "call") next.call(entry.callback, entry.position); + else if (entry.kind === "add") + next.add(entry.targets, entry.params, entry.position); + else next.set(entry.targets, entry.params, entry.position); + } + + return next; + }; + + // Advances whenever the playhead's owner changes: a pause, a jump, a new + // scenario, so a stale watch loop or reduced-motion step stops. + let clipToken = 0; + let clipPlaying = false; + let playAfterPaint = false; + let clipTimer: ReturnType | undefined; + + // Under reduced motion, play holds each marker this long. + const CLIP_HOLD = 3000; + + const clipNow = () => + clipTimeline + ? Math.max(0, Math.min(clipTimeline.duration, clipTimeline.currentTime)) + : 0; + + const postTime = () => + post({ type: "time", at: clipNow(), playing: clipPlaying }); + + const reachedMarker = (index: number): ReachedMarker | undefined => { + const placed = clipMarkers[index]; + const declared = init?.markers.find((item) => item.key === placed?.key); + + return placed && declared + ? { ...declared, at: placed.at, index } + : undefined; + }; + + // The playhead reached a marker: its states, its handlers, and the host. + const reach = (index: number, instant: boolean) => { + lastMarker = index; + const marker = reachedMarker(index); + + if (marker) root.dataset.marker = marker.key; + else delete root.dataset.marker; + + applyStates(); + wake(); + + if (!marker) return; + const skip = instant || prefersReducedMotion(); + instantMode = skip; + + if (skip) root.setAttribute("data-wb-instant", ""); + + releaseFinished(); + const change: MarkerChange = { marker, instant: skip }; + + try { + for (const key of ["*", marker.key]) + for (const handler of markerHandlers.get(key) ?? []) { + try { + handler(change); + } catch (error) { + fail(String(error)); + } + } + } finally { + instantMode = false; + } + + if (skip) + nextFrame().then(() => { + for (const animation of document.getAnimations()) + if (finite(animation)) animation.finish(); + + root.removeAttribute("data-wb-instant"); + }); + + post({ type: "marker", key: marker.key }); + }; + + // The last marker at or before `at`, for landing on a time. + const markerAt = (at: number) => + clipMarkers.findLastIndex((marker) => marker.at <= at); + + const stopClip = () => { + clipToken++; + clearTimeout(clipTimer); + clipTimeline?.pause(); + clipPlaying = false; + }; + + const endClip = () => { + const tl = clipTimeline; + + if (!tl) return; + stopClip(); + tl.seek(tl.duration); + + for (let index = lastMarker + 1; index < clipMarkers.length; index++) + reach(index, true); + + post({ type: "ended" }); + postTime(); + }; + + // Reduced motion: no motion at all. Hold each marker, then land on the + // next one's time, and at the end after the last. + const stepClip = (token: number) => { + clipTimer = setTimeout(() => { + const tl = clipTimeline; + + if (token !== clipToken || !tl) return; + const next = lastMarker + 1; + + if (next >= clipMarkers.length) return endClip(); + tl.seek(clipMarkers[next]!.at); + reach(next, true); + postTime(); + stepClip(token); + }, CLIP_HOLD); + }; + + const playClip = () => { + const tl = clipTimeline; + + if (clipPlaying) return; + + // Before the scripts have built the timeline, and while the block paints + // in at time 0, play waits. + if (!tl || (init?.paint && !paintDone)) { + playAfterPaint = true; + + return; + } + + if (clipNow() >= tl.duration) { + tl.seek(0); + reach(markerAt(0), true); + } + + const token = ++clipToken; + clipPlaying = true; + wake(); + + if (prefersReducedMotion()) { + postTime(); + + return stepClip(token); + } + + if (tl.duration <= 0) return endClip(); + tl.resume(); + postTime(); + let posted = performance.now(); + + const watch = () => { + if (token !== clipToken) return; + const at = clipNow(); + + while ( + lastMarker + 1 < clipMarkers.length && + clipMarkers[lastMarker + 1]!.at <= at + ) + reach(lastMarker + 1, false); + + if (at >= tl.duration || tl.completed) return endClip(); + + if (performance.now() - posted >= 100) { + posted = performance.now(); + postTime(); + } + + requestAnimationFrame(watch); + }; + + requestAnimationFrame(watch); + }; + + const pauseClip = () => { + playAfterPaint = false; + + if (!clipTimeline) return; + stopClip(); + postTime(); + }; + + const seekClip = (key: string) => { + const tl = clipTimeline; + const index = clipMarkers.findIndex((marker) => marker.key === key); + + if (!tl || index < 0) return; + const playing = clipPlaying; + stopClip(); + tl.seek(clipMarkers[index]!.at); + reach(index, true); + + if (playing) playClip(); + else postTime(); + }; + + // Builds the scenario's timeline in place of the one showing, checks its + // markers, and lands it at 0. + const activateClip = (scenario: string | undefined) => { + stopClip(); + + const entries = + (scenario === undefined ? undefined : clipQueued.get(scenario)) ?? + clipQueued.get(""); + + if (clipTimeline) { + clipTimeline.seek(0); + clipTimeline.revert(); + clipTimeline = undefined; + } + + clipMarkers = []; + lastMarker = -1; + + if (!entries) { + fail( + scenario === undefined + ? "The clip has no timeline: build it with whiteboard.clip() while its script first runs." + : `The clip has no timeline for scenario "${scenario}": build it with whiteboard.clip("${scenario}"), or whiteboard.clip() for every scenario without its own.`, + ); + post({ type: "clip", duration: 0, markers: [] }); + postTime(); + + return; + } + + const tl = buildClip(entries); + clipTimeline = tl; + + const missing = (init?.markers ?? []).find( + (marker) => tl.labels[marker.key] === undefined, + ); + + if (missing) + fail( + `Marker "${missing.key}" is not placed on the clip's ${scenario === undefined ? "" : `"${scenario}" `}timeline: call .marker("${missing.key}") on whiteboard.clip(${scenario === undefined ? "" : `"${scenario}"`}).`, + ); + + clipMarkers = (init?.markers ?? []) + .flatMap((marker) => { + const at = tl.labels[marker.key]; + + return at === undefined + ? [] + : [ + { + key: marker.key, + at: Math.max(0, Math.min(tl.duration, at)), + }, + ]; + }) + .sort((a, b) => a.at - b.at); + + tl.seek(0); + post({ type: "clip", duration: tl.duration, markers: clipMarkers }); + reach(markerAt(0), true); + postTime(); + }; + const svg = "http://www.w3.org/2000/svg"; interface ArrowRecord { @@ -1497,7 +1926,7 @@ export function stageRuntime(port: MessagePort): void { const declared = declaredFrames(); const declaredFrame = declared[index]; - if (!declaredFrame) return; + if (clipMode() || !declaredFrame) return; const framePosition = activeFrameIndex(declaredFrame.key); const frame = { ...declaredFrame, index: framePosition }; const previousDeclared = declared[current]; @@ -1595,6 +2024,16 @@ export function stageRuntime(port: MessagePort): void { // frame renderers even when the frame key stays the same, so the live // whiteboard.reducedMotion getter cannot leave stale semantic content behind. motionPreference.addEventListener("change", () => { + if (clipMode()) { + // Play again in the new mode, from where the playhead is. + if (clipPlaying) { + stopClip(); + playClip(); + } else if (lastMarker >= 0) reach(lastMarker, true); + + return; + } + if (current >= 0) show(current, true).catch((error) => fail(String(error))); }); @@ -1849,7 +2288,12 @@ export function stageRuntime(port: MessagePort): void { const runPaintHandlers = (instant: boolean): Promise[] => { const waits: Promise[] = []; - const paint: PaintChange = { frame: whiteboard.frame, instant }; + + const paint: PaintChange = { + ...(!clipMode() && { frame: whiteboard.frame }), + instant, + }; + const wasInstant = instantMode; instantMode = instant; @@ -1898,6 +2342,11 @@ export function stageRuntime(port: MessagePort): void { paintDone = true; post({ type: "painted" }); wake(); + + if (playAfterPaint) { + playAfterPaint = false; + playClip(); + } } }; @@ -2173,7 +2622,7 @@ export function stageRuntime(port: MessagePort): void { let { tone, text } = part.base; for (const { at, change } of part.changes) { - const changeKey = init?.frames[at]?.key; + const changeKey = declaredKeys()[at]; const changePosition = changeKey ? activeFrameIndex(changeKey) : -1; if (changePosition >= 0 && changePosition <= position) { @@ -2400,11 +2849,22 @@ export function stageRuntime(port: MessagePort): void { }; }; + // An animation's frame API, called in a clip. + const framesOnly = (name: string) => { + if (clipMode()) + throw new Error( + `A clip has no frames, so whiteboard.${name} does not apply: its reader jumps between markers. Place them with whiteboard.clip().marker(), and read whiteboard.markers and whiteboard.marker.`, + ); + }; + const whiteboard: WhiteboardShim = { get frames() { + framesOnly("frames"); + return activeFrames(); }, get frame() { + framesOnly("frame"); const key = init?.frames[Math.max(current, 0)]?.key; return activeFrames().find((frame) => frame.key === key)!; @@ -2420,7 +2880,7 @@ export function stageRuntime(port: MessagePort): void { }, code: (name) => placeholder("wbCode", name), block: (id) => placeholder("wbBlock", id), - on(key: string, handler: FrameHandler | PaintHandler) { + on(key: string, handler: FrameHandler | PaintHandler | MarkerHandler) { if (isPaint(key)) { // SAFETY: the "paint" overload takes a paint handler. const paint = handler as PaintHandler; @@ -2430,7 +2890,21 @@ export function stageRuntime(port: MessagePort): void { } if (key !== "*" && frameIndex(key) < 0) - throw new Error(`Unknown frame "${key}".`); + throw new Error( + clipMode() ? `Unknown marker "${key}".` : `Unknown frame "${key}".`, + ); + + if (clipMode()) { + let markers = markerHandlers.get(key); + + if (!markers) markerHandlers.set(key, (markers = new Set())); + // SAFETY: in a clip, any key but "paint" takes a marker handler. + const marker = handler as MarkerHandler; + markers.add(marker); + + return () => markers.delete(marker); + } + let set = handlers.get(key); if (!set) handlers.set(key, (set = new Set())); @@ -2464,6 +2938,7 @@ export function stageRuntime(port: MessagePort): void { return animation; }, setSequence(keys) { + framesOnly("setSequence()"); const sequence = [...keys]; if (!sequence.length) @@ -2491,14 +2966,22 @@ export function stageRuntime(port: MessagePort): void { post({ type: "sequence", keys: sequence }); }, goto(key) { + framesOnly("goto()"); + if (frameIndex(key) < 0) throw new Error(`Unknown frame "${key}".`); if (activeSequence && !activeSequence.includes(key)) throw new Error(`Frame "${key}" is not in the active sequence.`); post({ type: "goto", key }); }, - next: () => post({ type: "step", delta: 1 }), - prev: () => post({ type: "step", delta: -1 }), + next() { + framesOnly("next()"); + post({ type: "step", delta: 1 }); + }, + prev() { + framesOnly("prev()"); + post({ type: "step", delta: -1 }); + }, reveal(name) { if (!init?.code.includes(name)) throw new Error(`Unknown code name "${name}".`); @@ -2508,6 +2991,17 @@ export function stageRuntime(port: MessagePort): void { return lib(); }, timeline: frameTimeline, + clip: (scenario) => clipBuilder(scenario), + get markers() { + return (init?.markers ?? []).map((marker) => { + const placed = clipMarkers.find((item) => item.key === marker.key); + + return placed ? { ...marker, at: placed.at } : { ...marker }; + }); + }, + get marker() { + return reachedMarker(lastMarker); + }, arrow(from, to, options = {}) { return createEdge(resolve(from), resolve(to), options).group; }, @@ -2601,6 +3095,7 @@ export function stageRuntime(port: MessagePort): void { const moving = () => showsInProgress > 0 || (timeline !== undefined && !timeline.paused) || + (clipTimeline !== undefined && !clipTimeline.paused) || document .getAnimations() .some( @@ -2793,7 +3288,9 @@ export function stageRuntime(port: MessagePort): void { await runSources(message.libraries); await runScripts([...document.body.querySelectorAll("script")]); arrows.forEach(route); - buildTimeline(); + + if (message.mode === "clip") built = true; + else buildTimeline(); buildPaintTimeline(); // Not painting in: the paint timeline lands at its end before the first @@ -2808,6 +3305,23 @@ export function stageRuntime(port: MessagePort): void { }); measuring = true; wake(); + + if (message.mode === "clip") { + activateClip(message.scenario); + firstShown = true; + + if (!message.paint) runPaintHandlers(true); + else if (paintAsked) await paintIn(); + + // A play that came before the timeline was built. + if (playAfterPaint && !message.paint) { + playAfterPaint = false; + playClip(); + } + + return; + } + const requestedKey = message.frames[message.frame]?.key; const firstActive = activeSequence?.[0]; @@ -2857,6 +3371,24 @@ export function stageRuntime(port: MessagePort): void { // The stage's own timeout may have painted it already. if (paintDone) post({ type: "painted" }); else askPaint(false); + break; + case "play": + playClip(); + break; + case "pause": + pauseClip(); + break; + case "seek": + if (init) seekClip(message.marker); + break; + case "scenario": + if (init?.mode === "clip") + try { + activateClip(message.key); + } catch (error) { + fail(String(error)); + } + break; case "sequence": if (!init) break; diff --git a/packages/review/app/src/block-document-derivations.ts b/packages/review/app/src/block-document-derivations.ts index 8087bc9e11..1ec74ad876 100644 --- a/packages/review/app/src/block-document-derivations.ts +++ b/packages/review/app/src/block-document-derivations.ts @@ -22,6 +22,7 @@ export function blockSectionSummary(children: Block[]): ReviewSectionSummary { case "flow_diagram": case "software_map": case "animation": + case "clip": summary.diagrams += 1; break; case "code_peek": diff --git a/packages/review/app/src/blocks.browser.test.tsx b/packages/review/app/src/blocks.browser.test.tsx index a11fc10f59..36aac2cf81 100644 --- a/packages/review/app/src/blocks.browser.test.tsx +++ b/packages/review/app/src/blocks.browser.test.tsx @@ -173,6 +173,12 @@ const rendered: Record< c, "[data-animation-slot='code'] .fixture-inline-editor[data-path='order.ts']", ) && text(c).includes("An order arrives"), + // A clip boots the same stage, and lists its markers under it. + clip: (c) => + has( + c, + "[data-animation-slot='code'] .fixture-inline-editor[data-path='order.ts']", + ) && text(c).includes("Status queued"), }; let canvas: ReturnType | undefined; diff --git a/packages/review/app/src/blocks.tsx b/packages/review/app/src/blocks.tsx index e734e6ab38..f747841781 100644 --- a/packages/review/app/src/blocks.tsx +++ b/packages/review/app/src/blocks.tsx @@ -287,19 +287,21 @@ function SoftwareMapBlock({ node, data }: BlockProps<"software_map">) { ); } -function AnimationDiagramBlock(props: BlockProps<"animation">) { - const { height, title, html, frames, scenarios, code } = props.node; +function AnimationDiagramBlock(props: BlockProps<"animation" | "clip">) { + const { node } = props; + const { height, title, html, scenarios, code } = node; + const steps = node.type === "clip" ? node.markers : node.frames; - // An edit to the animation draws it again from a fresh block, at its first - // frame, rather than updating a stage whose frames may have gone. + // An edit to the animation or clip draws it again from a fresh block, at + // its start, rather than updating a stage whose frames may have gone. const revision = useMemo( - () => JSON.stringify([html, frames, scenarios, code, height]), - [html, frames, scenarios, code, height], + () => JSON.stringify([html, steps, scenarios, code, height]), + [html, steps, scenarios, code, height], ); return ( @@ -336,6 +338,7 @@ export const blockComponents = { flow_diagram: FlowDiagramBlock, software_map: SoftwareMapBlock, animation: AnimationDiagramBlock, + clip: AnimationDiagramBlock, section: SectionBlock, callout: CalloutBlock, } satisfies Components; diff --git a/packages/review/app/src/draw-queue.test.ts b/packages/review/app/src/draw-queue.test.ts index 69314b128d..b8891110dc 100644 --- a/packages/review/app/src/draw-queue.test.ts +++ b/packages/review/app/src/draw-queue.test.ts @@ -38,11 +38,14 @@ const block = (id: string): AuthoringCursor => ({ seq: ++seq, }); -const animation = (id: string): AuthoringCursor => ({ +const animation = ( + id: string, + kind: "animation" | "clip" = "animation", +): AuthoringCursor => ({ targetId: id, blockId: id, source: "edit", - edit: { type: "insert", targetId: id, blockId: id, kind: "animation" }, + edit: { type: "insert", targetId: id, blockId: id, kind }, seq: ++seq, }); @@ -379,6 +382,15 @@ describe("draw queue", () => { expect(standingCursor(capped)?.targetId).toBe("anim-3"); }); + it("holds a new clip while it paints, as an animation", () => { + let state = arrive(EMPTY_QUEUE, animation("clip-1", "clip"), 0); + expect(phases(state)).toEqual(new Map([["clip-1", "painting"]])); + expect(nextDue(state)).toBe(PAINT_CAP); + + state = painted(state, "clip-1", 400); + expect(state.head).toBeNull(); + }); + it("ignores a painted report for another block, or when the head is not painting", () => { let state = arrive(EMPTY_QUEUE, animation("anim-1"), 0); expect(painted(state, "anim-2", 100)).toBe(state); diff --git a/packages/review/app/src/draw-queue.ts b/packages/review/app/src/draw-queue.ts index e80fefbb42..5803f49eeb 100644 --- a/packages/review/app/src/draw-queue.ts +++ b/packages/review/app/src/draw-queue.ts @@ -118,8 +118,9 @@ export function stepsFor(cursor: AuthoringCursor): DrawStep[] { { phase: "fill", ms: 250 }, ]; - // An animation's stage paints its own first frame; painted() ends it. - if (edit.kind === "animation") + // An animation's or clip's stage paints its own first frame; painted() + // ends it. + if (edit.kind === "animation" || edit.kind === "clip") return [{ phase: "painting", ms: PAINT_CAP }]; return [{ phase: "landing", ms: 680 }]; diff --git a/packages/review/app/src/ui/progress-line.tsx b/packages/review/app/src/ui/progress-line.tsx new file mode 100644 index 0000000000..0e8a040d38 --- /dev/null +++ b/packages/review/app/src/ui/progress-line.tsx @@ -0,0 +1,67 @@ +import { motion, radius } from "@canvas/scale.stylex"; +import { tokens } from "@canvas/tokens.stylex"; +import * as stylex from "@stylexjs/stylex"; + +/** A thin track filled to `value` (0 to 1), with a tick at each of `ticks`. + * It only shows progress: nothing on it is interactive. */ +export function ProgressLine({ + value, + ticks = [], +}: { + value: number; + ticks?: readonly number[]; +}) { + const clamp = (fraction: number) => Math.max(0, Math.min(1, fraction)); + + return ( +