@dev.fast/whiteboard 0.4.0 → 0.4.1-preview.20261026.138

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/{account-alias-2yTAxoBx.js → account-alias-BPlPnzQM.js} +7 -7
  2. package/dist/{agent-cli-CHNDd4Ga.js → agent-cli-BbCf9uid.js} +11 -11
  3. package/dist/agent-cli-ipN_JmzG.js +2 -0
  4. package/dist/{agent-client-Dc0vHFJt.js → agent-client-DfpM8F5N.js} +3 -3
  5. package/dist/{authoring-tools-D8EkIDjm.js → authoring-tools-bm39DRoV.js} +23 -10
  6. package/dist/{background-server-DyWnPJyZ.js → background-server--0EeCCeV.js} +1 -1
  7. package/dist/{background-server-DHWHzpo5.js → background-server-B--5p72o.js} +6 -6
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-qZomwJmK.js → cli-DBTQIroM.js} +2 -2
  10. package/dist/{cli-runner-DFBaVGij.js → cli-runner-CRFCdb7G.js} +25 -25
  11. package/dist/cli.js +3 -3
  12. package/dist/{fuzzy-match-CfSxqzBF.js → fuzzy-match-l3mJZ1Ge.js} +5 -5
  13. package/dist/{headless-host-dwlqhqMY.js → headless-host-5ei3nHS8.js} +1 -1
  14. package/dist/{headless-host-DJhcab6r.js → headless-host-BkJ9OUQY.js} +131 -299
  15. package/dist/{client-p9WB8Sj0.js → import-Bwvf9Wos.js} +505 -390
  16. package/dist/{lens-selection-9byzEgvU.js → lens-selection-EuZdag4Q.js} +1 -1
  17. package/dist/{local-data-DHedfhxw.js → local-data-BCSeTyic.js} +98 -154
  18. package/dist/{mcp-Br-r_wwR.js → mcp-_Xg1gQuA.js} +17 -8
  19. package/dist/{package-paths-BeYHYjeR.js → package-paths-Bg731FHS.js} +2 -2
  20. package/dist/profile-C8zL4u3L.js +2 -0
  21. package/dist/{profile-D9wdlPgw.js → profile-xzrUFgbT.js} +4 -4
  22. package/dist/{remote-attach-DSGpTOJ4.js → remote-attach-DF25Epoa.js} +4 -4
  23. package/dist/{remote-extensions-WtJO7pkb.js → remote-extensions-BJamgsCe.js} +1 -1
  24. package/dist/{remote-extensions-CsI_BJ_f.js → remote-extensions-CeywZL-Z.js} +4 -4
  25. package/dist/{remote-language-server-Dx13Mg2m.js → remote-language-server-CH8sG_CU.js} +6 -6
  26. package/dist/{remote-uninstall-Dp2o3qz0.js → remote-uninstall-Cq-lkBY7.js} +7 -7
  27. package/dist/{request-origin-CNSLVn7x.js → request-origin-CargJFvH.js} +14 -3
  28. package/dist/{review-app-launcher-BZAnR8Vp.js → review-app-launcher-D8ApNDUV.js} +3 -3
  29. package/dist/{review-home-paths-CG-sb9Cw.js → review-home-paths-DcB4xLD-.js} +2 -2
  30. package/dist/{review-telemetry-BM-_KdLi.js → review-telemetry-BW7gJrSo.js} +7 -7
  31. package/dist/{runtime-C6_1K-3Z.js → runtime-DY6Ctyog.js} +1 -1
  32. package/dist/runtime.js +1 -1
  33. package/dist/{s3-CsG2EWED.js → s3-BIWv4h1J.js} +2 -2
  34. package/dist/{s3-config-zz4GWh2T.js → s3-config-Du3RBIvv.js} +1 -1
  35. package/dist/server/desktop-host.js +51 -17
  36. package/dist/{server-discovery-DKLIOgg3.js → server-discovery-aHK_1r3U.js} +5 -5
  37. package/dist/sharing/index.d.ts +209 -98
  38. package/dist/sharing/index.js +1 -1
  39. package/dist/{src-DYHVzX3b.js → src-DEOc8bvC.js} +63 -2
  40. package/dist/{src-Z1x4-Qmb.js → src-DjRaCctk.js} +6 -6
  41. package/dist/{stored-document-migration-DEt8iwem.js → stored-document-migration-CwmTwTb8.js} +139 -13
  42. package/dist/{thread-state-CO97FUqK.js → thread-state-CbLB0jUA.js} +1 -1
  43. package/dist/{tool-failure-C1L1vJyz.js → tool-failure-DZaQzi4f.js} +2 -2
  44. package/instructions/animation.md +345 -0
  45. package/instructions/authoring.md +2 -2
  46. package/package.json +6 -6
  47. package/src/fixtures/blocks/animation.json +19 -0
  48. package/src/review-api/authoring-tools.ts +17 -6
  49. package/src/review-api/blocks/animation.ts +98 -0
  50. package/src/review-api/blocks/index.ts +3 -0
  51. package/src/review-api/blocks/sequence.ts +3 -1
  52. package/src/review-api/comparison-coverage.ts +120 -89
  53. package/src/review-api/component-reference.ts +16 -0
  54. package/src/review-api/document-text.ts +11 -0
  55. package/src/review-api/document.ts +26 -5
  56. package/src/review-api/http.ts +31 -2
  57. package/src/review-api/instructions.ts +11 -2
  58. package/src/review-api/local-data.ts +21 -4
  59. package/src/review-api/mcp.ts +22 -14
  60. package/src/review-api/onboarding.ts +100 -26
  61. package/src/review-preferences.ts +43 -0
  62. package/src/server/desktop-server.ts +24 -0
  63. package/src/server/json-review-reporting.ts +1 -1
  64. package/src/server/review-server-core.ts +2 -0
  65. package/src/server/structural-comparisons.ts +5 -11
  66. package/src/server/structural-diff.ts +8 -2
  67. package/src/sharing/cli.ts +1 -1
  68. package/src/sharing/client.ts +54 -222
  69. package/src/sharing/export.ts +172 -114
  70. package/src/sharing/host.ts +32 -36
  71. package/src/sharing/import.ts +93 -147
  72. package/src/sharing/index.ts +1 -6
  73. package/src/sharing/routes.ts +18 -17
  74. package/src/ui-telemetry-events.ts +11 -0
  75. package/dist/agent-cli-Cn4-16XA.js +0 -2
  76. package/dist/profile-BopGMZUh.js +0 -2
  77. package/dist/{s3-config-CjcLyFV5.js → s3-config-D7Yt0VhS.js} +1 -1
  78. package/dist/{src-X9ZebeOD.js → src-DXnn7RrI.js} +1 -1
@@ -0,0 +1,345 @@
1
+ An `animation` component plays HTML you write, frame by frame, to show how a change behaves over time: a request moving through layers, state before and after, a cache filling. Use it when motion explains something a static diagram cannot. Do not use it for content a `sequence`, `flow_diagram` or `code_peek` already shows.
2
+
3
+ ## Choose how to build it
4
+
5
+ Choose where to start by what changes between the frames:
6
+
7
+ | Build with | When the frames show |
8
+ | --- | --- |
9
+ | The scene (`whiteboard.scene`) | Up to about 8 cards whose notes, text and tones change, with chips moving along their edges. This is the default. |
10
+ | Mermaid (`whiteboard.mermaid`) | A larger diagram, or one with branches or subgroups, whose nodes and edges appear, leave or light up. Its labels stay fixed: use the scene if they must change. |
11
+ | The toolkit, style kit and raw HTML | Anything else: a chart, a timeline, a queue, a custom shape, or motion the scene cannot express (`arrow`, `token`, `travel`, `timeline`). |
12
+
13
+ If no part of the drawing changes between frames, it is not an animation: use a `sequence` or `flow_diagram` block, whose steps and nodes link to code. The three mix: a stage can hold a scene, a Mermaid diagram and your own HTML together.
14
+
15
+ ## The component
16
+
17
+ ```json
18
+ {
19
+ "type": "animation",
20
+ "title": "Retry with backoff",
21
+ "frames": [
22
+ {
23
+ "key": "send",
24
+ "title": "The client sends",
25
+ "label": "SEND",
26
+ "caption": "The first request times out."
27
+ },
28
+ {
29
+ "key": "wait",
30
+ "title": "It backs off",
31
+ "label": "WAIT",
32
+ "duration": 4000
33
+ },
34
+ { "key": "retry", "title": "The retry succeeds", "label": "RETRY" }
35
+ ],
36
+ "code": { "retry": "head/src/http/retry.ts#L12-L40" },
37
+ "pins": { "repositoryId": "…", "head": "…" },
38
+ "html": "…"
39
+ }
40
+ ```
41
+
42
+ - `frames`: every possible step. `key` names the frame for your markup and scripts; `title` and `caption` show under the stage. `label` is a short step name (e.g. `SEND`); give every frame one, or none, and the reader gets a named step strip instead of dots. `duration` (ms, default 3000) is how long the frame holds while playing. For a flow with alternate branches, declare every stable frame here and select the active ordered path with `whiteboard.setSequence(...)`.
43
+ - `code`: the source ranges the stage shows, by name. The server checks each anchor like any other reference, so verify paths and lines. Use `head/` for the new code and `base/` for the old.
44
+ - `pins`: as on any block that quotes code. A review with a target lends its own; a board without one needs them on the block.
45
+ - `html`: the stage's body. It runs in a sandboxed frame with no network: inline `<style>` and `<script>` only, no external URLs. Images must be `data:` URLs.
46
+ - `height`: defaults to `"auto"`, which fits the stage to its content, including host code peeks and blocks. Omit it for most animations. Set a number (120 to 1200 CSS pixels) only when the animation intentionally needs a fixed viewport; fixed-height stages clip overflow, so verify every frame at narrow widths. The width is the document column.
47
+
48
+ ## Build with the scene
49
+
50
+ `whiteboard.scene` is a convenient starting point for cards and message flows. It provides grid layout, bound edges, and moving chips. You can combine it with custom HTML, SVG, CSS, or animation code whenever that better fits the explanation; the scene is optional. A complete example:
51
+
52
+ ```html
53
+ <script>
54
+ const s = whiteboard.scene({ columns: 4 });
55
+
56
+ // Cards sit on a 1-based grid: [column, row].
57
+ const agent = s.actor({
58
+ id: "agent",
59
+ at: [1, 1],
60
+ title: "Agent",
61
+ note: "session_edit",
62
+ });
63
+ const api = s.actor({
64
+ id: "api",
65
+ at: [2, 1],
66
+ title: "Review API",
67
+ note: "POST /commands",
68
+ });
69
+ const store = s.store({
70
+ id: "store",
71
+ at: [3, 1],
72
+ title: "Store",
73
+ note: "SQLite",
74
+ });
75
+ const canvas = s.actor({ id: "canvas", at: [4, 1], title: "Canvas" });
76
+
77
+ // Edges follow their cards. They draw in on the frame you name.
78
+ s.link(agent, api, { label: "call", enter: "write" });
79
+ s.link(api, store, { label: "edit op", enter: "write" });
80
+ s.link(store, canvas, {
81
+ label: "snapshot",
82
+ tone: "added",
83
+ dashed: true,
84
+ enter: "watch",
85
+ });
86
+
87
+ // A chip rides the edge during a frame, then fades out on arrival.
88
+ s.send(agent, api, "edit", "write");
89
+ s.send(api, store, "v9", "write", { stay: true, only: ["write"] });
90
+
91
+ // Per-frame changes. Each holds until the next frame that changes it.
92
+ s.on("write", store, { note: "version 9", tone: "modified" });
93
+ s.on("watch", canvas, { note: "redrawn", tone: "added" });
94
+ store.item("reviews", { enter: "write", tone: "added" });
95
+
96
+ // A code peek in a cell. It spans all four columns of row 2 so the
97
+ // source has room: [column, row, rowSpan, colSpan].
98
+ s.peek("handler", { at: [1, 2, 1, 4], enter: "write" });
99
+
100
+ const etag = s.badge({ text: "etag e0" });
101
+ s.on("watch", etag, { text: "etag e1" });
102
+ </script>
103
+ ```
104
+
105
+ A scene appends to the stage body by default. To compose it with your own headings, text or controls, pass a container selector or element: `whiteboard.scene({ columns: 3, container: "#diagram" })`. Badges stay with that container too.
106
+
107
+ When the container is too narrow for readable cards, the scene stacks its cells in row/column order. Widening restores the declared grid. Tables wrap their content. Hidden cards, store items and table rows take no space, so auto height follows visible content. Use `responsive: false` for an intentional fixed grid with your own responsive or scrolling plan.
108
+
109
+ The scene keeps three things consistent for you:
110
+
111
+ - **Spacing.** Cards sit in grid cells with a fixed gap. You choose the cell; the scene sets the gap and the padding. Four cards fit a sufficiently wide row; narrower containers stack them. Past four, prefer a second row.
112
+ - **Edges.** `link` draws one straight line from the center of one card's nearest side to the other's, with one weight and one arrowhead. `dashed: true` means async (a reply, a stream, a background send). A second edge between the same two cards offsets beside the first.
113
+ - **Moving blocks.** `send` is the only way to move a block along an edge. The chip rides the edge's line at one fixed speed, so a longer edge takes longer, and every chip moves at the same pace. It fades out on arrival unless you pass `stay: true`.
114
+
115
+ ### The scene API
116
+
117
+ ```ts
118
+ whiteboard.scene(options?: { columns?: number; container?: string | Element; responsive?: boolean }): Scene; // default 3 columns
119
+
120
+ interface Scene {
121
+ actor(options: CardOptions): Card; // a labeled card
122
+ store(options: CardOptions): Store; // a card that holds items
123
+ table(options: CardOptions & { columns: string[] }): Table;
124
+ peek(name: string, options: { at: Cell; enter?; exit?; only? }): HTMLElement;
125
+ badge(options: { text?: string; tone?: Tone }): HTMLElement; // a pill above the scene
126
+ link(from: Ref, to: Ref, options?: { label?; tone?; dashed?; arrowhead?: boolean; enter?; exit?; only? }): SVGGElement;
127
+ send(from: Ref, to: Ref, label: string, frame: string, options?: { tone?; stay?: boolean; enter?; exit?; only? }): void;
128
+ on(frame: string, part: Ref, change: { text?: string; tone?: Tone | "none" }): void;
129
+ }
130
+
131
+ type Cell = [column: number, row: number, rowSpan?: number, colSpan?: number]; // 1-based
132
+ type Ref = Card | string /* its id */ | Element;
133
+ type Tone = "accent" | "added" | "removed" | "modified" | "muted";
134
+
135
+ interface CardOptions {
136
+ id: string; // names the card for link, send and on
137
+ at: Cell;
138
+ title?: string; // the first line; defaults to the id
139
+ note?: string; // the second line
140
+ tone?: Tone;
141
+ enter?: string; exit?: string; only?: string[]; // which frames it shows in
142
+ highlight?: string[]; // frames during which it is ringed
143
+ }
144
+
145
+ store.item(label, { enter?; exit?; only?; tone? }): HTMLElement; // a chip inside the store
146
+ table.row(cells: string[], { enter?; exit?; only?; tone? }): HTMLTableRowElement;
147
+ ```
148
+
149
+ - `on(frame, part, { text, tone })` changes a card, item, or badge from that frame on. `text` sets a card's note line, or an item's or badge's text — `note` works too, since that is the field cards declare. The change holds until the next frame that changes the same part, so a jump lands right. An unknown field throws rather than being ignored.
150
+ - `send` draws its edge for you if no `link` joins the two cards yet. Keep edge and chip labels short (about 12 characters): a label sits at the middle of its edge, in the space between the two cards. `stay: true` retains the chip at its destination; use `enter`, `exit`, or `only` separately to scope when that retained chip is visible. For example, `s.send(a, b, "PACK", "upload", { stay: true, only: ["upload"] })` keeps the arrived packet visible only in `upload`.
151
+ - Use `tone` `added` / `removed` / `modified` for what the change adds, deletes or alters. `accent` draws the eye; `muted` recedes. In `on`, `tone: "none"` clears a part back to the neutral surface, e.g. to un-highlight the previous step.
152
+ - Links are directed by default. Pass `arrowhead: false` to `link` for an undirected guide, boundary, or relationship while retaining endpoint binding, routing, labels, tones, dashed styling, lifecycle, and travel support.
153
+ - Edges route straight, but bow clear of any card that sits on the line between their ends. Labels use their rendered bounds to avoid endpoint cards, intervening cards, and other visible edge labels, and are reconsidered as the stage resizes. Prefer a layout where the cells between two linked cards are empty; automatic routing is a fallback, not the look to design for.
154
+
155
+ ### Frames
156
+
157
+ The stage sets `data-frame="<key>"` on `<html>`, and `enter` / `exit` / `only` on any scene part decide the frames it shows in. They are states, not events, so they land right when the reader jumps to a frame or steps back. With `setSequence`, `enter` / `exit` and held `scene.on` changes follow active-sequence order, not declaration order: a change whose key is outside the active path does not apply, an inactive `enter` never occurs, and an inactive `exit` does not end an already-entered part. `only` and highlight lists always match exact stable keys. Visible cards keep their declared wide-grid cells. Hidden cards, store items and table rows collapse from layout; put replacing cards in the same cell. For an intentionally stable footprint, give the container a min-height or choose a fixed stage height with an appropriate scrolling plan.
158
+
159
+ ## Draw with Mermaid
160
+
161
+ `whiteboard.mermaid(text)` draws a Mermaid diagram in the stage's theme, lays it out for you, and shows its parts frame by frame. Use it when the frames change a diagram too large to place card by card: a pipeline whose stages light up in turn, a state machine stepping through its states, a graph that grows as a request spreads. Use the scene when a few cards carry chips along their edges; Mermaid has no `send`.
162
+
163
+ Mermaid nodes do not link to code. A diagram that stays the same in every frame belongs in a `flow_diagram` or `sequence` block, not in an animation.
164
+
165
+ ```html
166
+ <script>
167
+ const d = whiteboard.mermaid(`flowchart LR
168
+ agent[Agent] e1@-->|call| api(Review API)
169
+ api e2@-->|edit op| store[(Store)]
170
+ store e3@-.->|snapshot| canvas[Canvas]`);
171
+
172
+ d.highlight("call", "agent", "e1")
173
+ .enter("write", "store", "e2")
174
+ .on("write", "store", { tone: "modified" })
175
+ .enter("watch", "canvas", "e3")
176
+ .on("watch", "canvas", { tone: "added" });
177
+ </script>
178
+ ```
179
+
180
+ Name a part by its Mermaid id:
181
+
182
+ - A node by its id: `api` above.
183
+ - An edge by the id you give it with `@`: `a e1@--> b`. Name every edge you will reveal or light up. An unnamed flowchart edge is `L_<from>_<to>_<n>`, counting from 0.
184
+ - In a sequence diagram, a participant by its name, and a message as `i0`, `i1`, … in the order they appear.
185
+
186
+ `d.ids` lists every part in the drawing, and a call naming a part the drawing lacks throws with that list.
187
+
188
+ ```ts
189
+ whiteboard.mermaid(text: string, options?: { container?: string | Element }): MermaidDiagram;
190
+
191
+ interface MermaidDiagram {
192
+ readonly element: HTMLElement; // the diagram's box
193
+ readonly ids: readonly string[];
194
+ enter(frame: string, ...ids: string[]): MermaidDiagram; // show from this frame on
195
+ exit(frame: string, ...ids: string[]): MermaidDiagram; // hide from this frame on
196
+ only(frames: string | string[], ...ids: string[]): MermaidDiagram;
197
+ highlight(frames: string | string[], ...ids: string[]): MermaidDiagram; // ring during these frames
198
+ on(frame: string, ids: string | string[], change: { tone: Tone | "none" }): MermaidDiagram;
199
+ }
200
+ ```
201
+
202
+ - The whole diagram is laid out once, from its full text, and parts that have not entered keep their place. Nothing moves as parts appear, so write the text for the last frame and reveal it frame by frame.
203
+ - `enter`, `exit`, `only` and `highlight` follow the same rules as on scene parts, active sequences included. A solid edge draws in along its line as it enters; a dashed one fades in. An edge's label shows and hides with it.
204
+ - `on` gives nodes and edges a tone from that frame on, as `scene.on` does; `"none"` clears it.
205
+ - The diagram follows the stage's colors, faces and theme. Leave out Mermaid's `style`, `classDef` and `%%{init}%%` theming: they fix colors that do not follow light and dark. Use tones instead. Links and `click` actions do nothing in the stage.
206
+ - The diagram fits its box's width and draws again when that width changes. A diagram that runs across and does not fit a narrow box is drawn running down instead of shrunk, so a `flowchart LR` may show top to bottom in the document column. Your frame changes carry over to the new drawing, but an element you hold from `d.element` does not: look parts up again inside a frame handler.
207
+ - Any Mermaid diagram type draws. Highlights and tones color nodes and edges. Flowchart and sequence diagrams name their parts as above. State, class and ER diagrams name nodes by their ids too, but make up their own edge ids, such as `edge0`; read them from `d.ids`.
208
+ - Text Mermaid cannot parse shows its error under the stage.
209
+
210
+ ## Going further: the toolkit
211
+
212
+ When the scene cannot express a layout, drop to the toolkit. It is the same drawing underneath, so it mixes with a scene: pass the card returned by `scene.actor`, `scene.store`, or `scene.table` directly to `arrow`, or use its public `id` or `element` fields where another API needs one.
213
+
214
+ - Lay out by hand with the style kit's layout classes (below): `.wb-row`, `.wb-stack`, `.wb-grid`, `.wb-layers`, `.wb-lane`, with `.wb-node` or `.wb-card` boxes.
215
+ - `whiteboard.arrow(from, to, { label, tone, dashed, arrowhead, bend, enter, exit, only, highlight })` draws an endpoint-bound connector that follows both ends. It is directed by default; pass `arrowhead: false` for a headless connector without changing its route, label, styling, lifecycle, or use with `travel`. It does not draw while either endpoint is detached from the document, and resumes when both endpoints reconnect. A default straight arrow reduces its endpoint clearance when necessary to preserve a directed segment in a narrow positive gap; overlapping endpoints still need more authored space. Labels avoid visible form controls and host-overlay slots as well as endpoint cards, other edge labels, and the swept bounds of packets travelling along their route. If both ends share an ancestor with clipped or scrollable overflow, the edge and its label are clipped to that viewport too. `bend` is a CSS-pixel distance: positive bows left relative to the arrow's direction and negative bows right. Reciprocal arrows need the same nonzero sign to bow onto opposite sides (for example, `bend: 32` on both); opposite signs put them on the same path. Use it only for a return path the scene cannot route.
216
+ - `whiteboard.token({ label, tone, visible, enter, exit, only })` makes a dot or pill; labelled tokens automatically choose a readable foreground for their resolved tone color in each theme. Tokens start hidden for `travel`; pass `visible: true` for a static snapshot at its authored position. Lifecycle fields work like they do on scene parts. The token already centers itself with CSS `translate`, so position it with `left` and `top` and do not add a centering `transform` or `translate`. For example:
217
+
218
+ ```js
219
+ const packet = whiteboard.token({
220
+ label: "PACK",
221
+ tone: "accent",
222
+ visible: true,
223
+ only: ["upload"],
224
+ });
225
+ lane.append(packet); // lane has position: relative
226
+ Object.assign(packet.style, { left: "45%", top: "50%" });
227
+ ```
228
+
229
+ `whiteboard.travel(token, arrow, frame, { duration, ease, position, stay })` moves a token along an arrow. Its path coordinates are stage-relative, so leave an SDK token in its default stage overlay for travel; do not reparent the positioned snapshot from the preceding example and then travel it. You can make that SDK token square by changing its shape styles without changing its positioning. For a caller-owned travelling element, use an explicit document origin such as `position: absolute; left: 0; top: 0`, with any deliberate centering in CSS; travel preserves that authored origin and existing transforms. Static lane snapshots and travelling packets are separate layout choices, and `stay` controls arrival retention rather than frame visibility—use `enter`, `exit`, or `only` for lifecycle scope.
230
+
231
+ Trips append to the frame by default. Pass `position: 0` to start parallel trips together, or a millisecond position to sequence a reply after its request. Equivalent wide/narrow layout trips should use the same positions, so a hidden layout does not add a second wait. `position` uses the same syntax as `whiteboard.timeline` below.
232
+
233
+ The default token remains hidden until travel starts. Travel progress stays bound to the live arrow geometry, so an active or retained token follows its edge when responsive layout or code-peek sizing moves the endpoints. You may reuse a token for later trips without reparenting it; the trip active at the current timeline position owns its placement, including after jumps, backward seeks, and resizes.
234
+
235
+ - `whiteboard.timeline(frame)` is that frame's part of one anime.js timeline: `.add(targets, params, position)`, `.set(...)`, `.call(...)`. A tween starts from where the frames before it left the element, so write only where it goes: `{ translateX: 120 }`. A numeric `position` counts milliseconds from the frame's start; `"<"` starts at the previous call's end, while `"<<"` starts at its beginning.
236
+ - `data-enter` / `data-exit` / `data-only` / `data-highlight` work on any element you write, arrows included.
237
+
238
+ ## Raw HTML
239
+
240
+ For custom layouts, charts, shapes, or interactions, write HTML, SVG and CSS. Build them from the style kit first, so a hand-made stage reads like the rest of the whiteboard, and drive them with the frame API after it.
241
+
242
+ ### The style kit
243
+
244
+ Every stage loads these classes. They use the whiteboard's faces, colors and spacing in light and dark, so prefer them to your own CSS; add a `<style>` only for what the kit does not cover, and build it from the variables under "Theme and motion".
245
+
246
+ | Class | Use it for |
247
+ | --- | --- |
248
+ | `.wb-row`, `.wb-stack` | A centered row or column of parts, with the scene's spacing. |
249
+ | `.wb-grid` | A grid of equal columns; set `style="--wb-columns: 4"` (default 3). |
250
+ | `.wb-layers` | Children stacked in one place, for states that replace each other. |
251
+ | `.wb-lane` | A `position: relative` track with a dashed center line, for a static `token` snapshot. |
252
+ | `.wb-card` | A box with a title and note: put `.wb-card-title` and `.wb-card-note` inside. The scene's cards are these. |
253
+ | `.wb-node` | A centered box for a hand-made layout; a `<small>` inside is its second line. |
254
+ | `.wb-well` | An inset area that groups parts, such as a process or a machine boundary. |
255
+ | `.wb-rule` | A divider (`<hr class="wb-rule">`). |
256
+ | `.wb-badge` | A pill for a status or version, e.g. `etag e1`. |
257
+ | `.wb-chip` | A chip in a list, such as a queued job or a cached key; `.wb-items` lays a list of them out. |
258
+ | `.wb-table` | A compact table of rows, such as a schema or a result set. |
259
+ | `.wb-title` | A heading inside the stage. |
260
+ | `.wb-caps` | An uppercase micro label, like the step strip's. |
261
+ | `.wb-prose` | A sentence of explanation, in the board's reading face. |
262
+ | `.wb-mono`, `.wb-code` | Ids, paths and values in the mono face; `.wb-code` is inline code on a well. |
263
+ | `.wb-muted`, `.wb-faint` | Text that recedes. |
264
+ | `.wb-shape`, `.wb-shape-text` | An SVG shape drawn like a card, and the text inside it. |
265
+
266
+ `data-tone="accent|added|removed|modified|muted"` colors any card, node, chip, badge, shape or table row, like the scene's tones. `data-highlight="frameKey …"` rings a card, node or shape during those frames.
267
+
268
+ ```html
269
+ <div class="wb-row">
270
+ <div class="wb-node" data-highlight="read">Reader<small>GET /v1</small></div>
271
+ <div class="wb-well wb-stack">
272
+ <span class="wb-caps">Cache</span>
273
+ <div class="wb-items">
274
+ <span class="wb-chip" data-tone="added" data-enter="fill">user:42</span>
275
+ </div>
276
+ </div>
277
+ </div>
278
+ ```
279
+
280
+ ### The frame API
281
+
282
+ - `whiteboard.on(key, handler)` runs when a frame shows (`"*"` on every frame), including a same-frame refresh when the reduced-motion preference changes. Shared `"*"` handlers run before frame-specific handlers, so they can render content before a frame handler animates it. Connection labels hide immediately when their frame ends, while the connecting stroke can fade. The handler receives `{ frame, previous, direction, instant }`; `frame` and `previous` are frame objects, so use `frame.key` when selecting keyed state. Return the animations it starts, or a finite promise for a simulation cycle, so playback waits for the represented work. Cancel earlier work when another frame shows. Use `instant` and the live `whiteboard.reducedMotion` value to draw a consistent skipped-motion pose; normal playback still needs to advance the model.
283
+ - `whiteboard.animate(target, keyframes, options)` is `element.animate` that holds its end state and honors the current frame jump. After that frame settles, later button or slider callbacks animate normally. The current reduced-motion preference always skips motion, including animations started by later interactions.
284
+ - `whiteboard.anime` is anime.js v4 (also the global `anime`): easings, stagger, utils.
285
+
286
+ Prefer `link` / `send` (or `arrow` / `travel`) for ordinary message flows: they handle endpoint geometry, labels, and navigation. Custom connectors and motion are also supported; keep them readable, responsive, and coherent when the reader jumps frames or requests reduced motion.
287
+
288
+ ## The rest of the `whiteboard` shim
289
+
290
+ ```ts
291
+ code(name: string): HTMLElement; // a placeholder the whiteboard fills with that code peek
292
+ block(id: string): HTMLElement; // a placeholder for another component of this whiteboard
293
+ setSequence(keys: string[]): void; // select the ordered path shown by host controls
294
+ goto(key: string): void; next(): void; prev(): void;
295
+ reveal(name: string): void; // open the named code beside the whiteboard
296
+ readonly frames: readonly { key: string; title: string; caption?: string; index: number }[];
297
+ readonly frame: { key: string; title: string; caption?: string; index: number };
298
+ readonly theme; readonly colorScheme: "light" | "dark"; readonly reducedMotion;
299
+ ```
300
+
301
+ `whiteboard.frame` is the current frame object, not its key. For keyed state, read `whiteboard.frame.key` or use the handler argument: `whiteboard.on("*", ({ frame }) => render(frame.key))`.
302
+
303
+ For alternate paths, call `whiteboard.setSequence(["start", "branch-a", "done"])`. Every key must be a unique frame declared in the component. Repeating the same sequence is a no-op, so it is safe to call from a render path. The selected order controls the host step labels, titles, count, Previous/Next, keyboard navigation, Play/Replay, and `whiteboard.next()` / `prev()`; changing it stops current playback and clears its hold timer. Changing the sequence preserves the current frame when it remains in the new path; otherwise it lands instantly on the new path's first frame. `whiteboard.frames` contains the active ordered path and its `index` values, and `whiteboard.frame.index` is the current active-path position; keys remain the stable identity. `goto(key)` accepts only a frame in the active sequence and is a direct jump, while adjacent forward steps still play the destination frame's motion even when declaration indexes move backward. Call `setSequence` before `goto` when a control switches branches:
304
+
305
+ ```js
306
+ const delivered = ["push", "gossip", "replicate", "serve"];
307
+ const dropped = ["push", "gossip-dropped", "catch-up", "serve-recovered"];
308
+
309
+ function selectPath(drop) {
310
+ const path = drop ? dropped : delivered;
311
+ whiteboard.setSequence(path);
312
+ whiteboard.goto(path[1]);
313
+ }
314
+ ```
315
+
316
+ - `scene.peek(name, ...)`, `whiteboard.code(name)` and `<div data-wb-code="name">` all draw the real code peek over a placeholder. Give `scene.peek` a cell, and span columns (the cell's `colSpan`) when the code needs width — a one-column peek clips long lines; a raw `data-wb-code` div takes a CSS width instead. Its height follows the code, up to about 18 lines. A peek is always on top of your drawing, so do not draw or travel over it.
317
+ - A code peek is about 40px of header plus 18px per line, up to about 400px. Rows of cards take about 90px each. With the default `height: "auto"`, the stage grows to fit them. If you intentionally use a numeric fixed height, leave enough room because overflow clips at that height.
318
+
319
+ ## Text inside shapes
320
+
321
+ Scene cards supply their own padding. For custom HTML or SVG, keep the full rendered text bounds at least 12 CSS pixels inside the container boundary at the displayed size. Check every text-box corner against a curved boundary, not only the shape’s rectangular bounding box. Wrap or shorten the label, or enlarge the shape, while keeping the font readable. Verify this inset in each frame and supported viewport.
322
+
323
+ ## Theme and motion
324
+
325
+ Custom CSS should use the whiteboard's variables, never literal colors, sizes or font names, so the stage matches light and dark and the rest of the board:
326
+
327
+ - Colors: `var(--wb-ink)`, `--wb-ink-muted`, `--wb-ink-faint`, `--wb-accent`, `--wb-accent-soft`, `--wb-on-accent`, `--wb-surface`, `--wb-surface-raised`, `--wb-well`, `--wb-rule`, `--wb-rule-soft`, `--wb-added`, `--wb-removed`, `--wb-modified`, `--wb-added-bg`, `--wb-removed-bg`, `--wb-modified-bg`.
328
+ - Faces, by role: `--wb-font-sans` for labels and controls, `--wb-font-mono` for ids, paths and values, `--wb-font-serif` for prose, `--wb-font-code` for source text. The stage loads the whiteboard's own faces, so name no font yourself.
329
+ - Spacing: `--wb-space-2` to `--wb-space-20` in 2px steps, then `24`, `28`, `32`, `36`, `40`, `48`, `56`, `72`, `96`, `120`, `160`.
330
+ - Type: sizes `--wb-text-micro` (10px), `small`, `body` (12px), `ui`, `title`, `reading`, `heading`, `display`; line heights `--wb-leading-tight`, `normal`, `relaxed`, `prose`; weights `--wb-weight-regular`, `medium`, `semibold`, `bold`; `--wb-tracking-caps` and `--wb-tracking-chrome` for uppercase labels.
331
+ - Radii: `--wb-radius-small`, `control`, `surface`, `pill`, `round`.
332
+ - Motion: `--wb-motion-fast`, `medium`, `slow` and the easings `--wb-motion-ease`, `enter`, `settle`. `--wb-duration` (450ms) is the default fade.
333
+
334
+ `<html>` carries `data-wb-theme="light|dark"` and the matching `color-scheme`, and `whiteboard.colorScheme` reads it, for the rare drawing that needs a different value per theme.
335
+
336
+ `whiteboard.reducedMotion` reads the stage's current `prefers-reduced-motion` setting. Use it when custom rendering has motion that `whiteboard.animate` and the scene helpers do not manage.
337
+
338
+ ## Checklist
339
+
340
+ - Each frame shows one idea; its title says what changed. 3 to 8 frames is usual.
341
+ - Omit `height` unless the animation intentionally needs a fixed viewport; verify any numeric height in every frame and narrow layout.
342
+ - Every frame reads correctly when reached by a jump, not only by playing.
343
+ - Build as "Choose how to build it" says: a scene by default, Mermaid for a larger or branching diagram, and the toolkit or raw HTML for anything else.
344
+ - In raw HTML, build from the style kit and the `--wb-*` variables; write no literal color, size or font name.
345
+ - Script errors show under the stage. After writing, read the whiteboard back and fix any.
@@ -20,7 +20,7 @@ For each board, follow these first five steps in order, with no tool calls beyon
20
20
  - create the whiteboard with a `target` naming the checkout by its `repositoryPath`: `{kind:"commits", head, base?}` for commits, `{kind:"pullRequest", pullRequest}` for a PR by URL or number, or `{kind:"project"}` for a plan with no change yet. if they name none, use a `worktree` target with no `base`: it reviews the checkout, uncommitted work included, against the default branch. for uncommitted work only, pass `base: "HEAD"`. untracked files are left out: run `git add -N <file>` on new files you want reviewed
21
21
  - for a change board only, trigger a subagent with this exact instruction: "Call `session_get_instructions({topic:\"file-lenses\"})` and follow it for whiteboard <sessionId>, whose change is `<base>..<head>` in `<repositoryPath>`."
22
22
  - call `session_activity_begin` and pass its `activityId` on every `session_edit`
23
- - for a project-target plan or overview, read the relevant source or the stack's combined diff; no diff is expected on the project target itself. For a change board, read the diff with git (or jj) in `repositoryPath`, at the commits `session_create` returned: `git diff <base> <head>`, or for a worktree target `git diff <base>` plus `git status` for untracked files. if it shows no changes, the target is wrong: fix it with `session_set_target` before writing
23
+ - for a project-target plan or overview, read the relevant source or the stack's combined diff; no diff is expected on the project target itself. For a change board, read the diff with git (or jj) in `repositoryPath`, at the commits `session_create` returned: `git diff <base> <head>`, or for a worktree target `git diff <base>` plus `git status` for untracked files. if it shows no changes, the target is wrong: fix it with `session_set_target` before writing. for a PR, retarget with `{kind:"pullRequest", pullRequest}`, never its branch names: those pin the branch tips, not the PR's merge base, and widen the diff
24
24
  - immediately after reading the diff (and the PR description) - put down a first pass at the what/why section.
25
25
 
26
26
  - whiteboard structure - each of these should be written as a top-level `section`, in this order:
@@ -41,7 +41,7 @@ For each board, follow these first five steps in order, with no tool calls beyon
41
41
 
42
42
  **updating existing whiteboard**
43
43
 
44
- - move the whiteboard to the new commits with `session_set_target`
44
+ - move the whiteboard to the new commits with `session_set_target`; for a PR, pass the `pullRequest` target again so it re-pins at the PR's head and merge base
45
45
  - read the existing whiteboard (if you haven't already,) read the diff since last whiteboard, make any necessary updates to the whiteboard.
46
46
 
47
47
  **guidelines**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev.fast/whiteboard",
3
- "version": "0.4.0",
3
+ "version": "0.4.1-preview.20261026.138",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -60,8 +60,8 @@
60
60
  "smol-toml": "1.9.0",
61
61
  "undici": "7.29.1",
62
62
  "yauzl": "3.4.0",
63
- "zod": "4.6.5",
64
- "@dev.fast/diffr": "0.1.20"
63
+ "zod": "4.6.6",
64
+ "@dev.fast/diffr": "0.1.21"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@dev.fast/local-vcs": "^0.1.0",
@@ -76,9 +76,9 @@
76
76
  "vitest": "4.1.11",
77
77
  "@dev.fast/json": "0.0.0",
78
78
  "@dev.fast/review-protocol": "0.0.0",
79
+ "@dev.fast/review-share-protocol": "0.3.0",
79
80
  "@dev.fast/trace-core": "0.0.0",
80
- "@dev.fast/trace-protocol": "0.5.0",
81
- "@dev.fast/review-share-protocol": "0.1.2"
81
+ "@dev.fast/trace-protocol": "0.5.0"
82
82
  },
83
83
  "engines": {
84
84
  "node": ">=24 <25"
@@ -88,7 +88,7 @@
88
88
  "repo": "devdotfast/review",
89
89
  "docs": "packages/review/instructions/authoring.md"
90
90
  },
91
- "gitHead": "90564c926cee5f245d498cb56585759cca9b312b",
91
+ "gitHead": "50bc2452b6dc5496460b10e223e4348b0802452f",
92
92
  "scripts": {
93
93
  "prebuild": "pnpm run build:workspace-deps",
94
94
  "build": "node --max-old-space-size=8192 node_modules/tsdown/dist/run.mjs",
@@ -0,0 +1,19 @@
1
+ [
2
+ {
3
+ "id": "block-1",
4
+ "type": "animation",
5
+ "title": "Order status",
6
+ "height": 300,
7
+ "frames": [
8
+ { "key": "before", "title": "An order arrives", "label": "ARRIVE" },
9
+ {
10
+ "key": "change",
11
+ "title": "Its status is queued",
12
+ "label": "QUEUE",
13
+ "caption": "The status line sets it."
14
+ }
15
+ ],
16
+ "code": { "status": "head/order.ts#L1" },
17
+ "html": "<script>const s = whiteboard.scene({ columns: 2 });\nconst client = s.actor({ id: \"client\", at: [1, 1], title: \"Client\", note: \"createOrder()\" });\nconst queue = s.store({ id: \"queue\", at: [2, 1], title: \"Queue\" });\ns.link(client, queue, { label: \"enqueue\", enter: \"change\" });\ns.send(client, queue, \"order\", \"change\");\ns.on(\"change\", queue, { note: \"1 pending\", tone: \"added\" });\nqueue.item(\"order\", { enter: \"change\", tone: \"added\" });\ns.peek(\"status\", { at: [1, 2, 1, 2], enter: \"change\" });</script>"
18
+ }
19
+ ]
@@ -6,7 +6,7 @@ import {
6
6
  activityEndSchema,
7
7
  activityUpdateSchema,
8
8
  } from "./activity.js";
9
- import { pathTargetSchema, publishedEditSchema } from "./document.js";
9
+ import { pathTargetSchema, publishedEdits } from "./document.js";
10
10
  import { instructionsQuerySchema } from "./instructions.js";
11
11
  import { uploadSchema } from "./local-data.js";
12
12
  import { inspectQuerySchema } from "./read-schemas.js";
@@ -14,9 +14,12 @@ import { REVIEW_STATUS_TOOL } from "./status-tool.js";
14
14
  import { commandSchema } from "./store.js";
15
15
 
16
16
  /** The host publishes its input schemas, except where agents write less than
17
- * the host accepts (publishedEditSchema, publishedLensEditSchema) or name a
17
+ * the host accepts (publishedEdits, publishedLensEditSchema) or name a
18
18
  * checkout by path (pathTargetSchema); adapters validate nothing. */
19
- export function authoringTools(traceEnabled = false) {
19
+ export function authoringTools(
20
+ traceEnabled = false,
21
+ animationsEnabled = false,
22
+ ) {
20
23
  const id = z.string().min(1);
21
24
  const review = { reviewId: id };
22
25
 
@@ -38,7 +41,7 @@ export function authoringTools(traceEnabled = false) {
38
41
  create:
39
42
  'Create a whiteboard. target names its repository by the checkout\'s path, and what it shows: {kind:"project"} for a plan in that repository\'s project with no change; {kind:"worktree", base?} for the saved files in the checkout, uncommitted ones included and untracked ones left out (git add -N a new file to include it), against base, by default the repository\'s default branch, from the merge base of base and HEAD, which follows rebases; {kind:"commits", head, base?} for immutable commits (omitted base means source at head with no diff; supply the parent to review introduced changes); {kind:"pullRequest", pullRequest} for a GitHub PR by URL or number, fetched into that checkout and pinned at the PR head and GitHub diff base. For a branch stacked on another, pass the branch below it as base. Group plan implementations or a PR stack under one project-target overview with parentId, and order its children with dependsOn; Git bases do not set these relationships. Revisions are resolved on acceptance. With a PR and no title the PR\'s title is used. When the existing review for that PR is in Trash, creation reports its ID; restore it with review_lifecycle state:"active" before continuing. When an active review for that PR exists, it is returned instead, reporting whether its head moved and whether an agent is working on it; update it in place, move its target with review_set_target, or create a separate review with reuseExisting:false. The result carries review, as review_list shows it, with its target and repositoryPath, so no follow-up read is needed before diffing. When Desktop is available the review opens there and the result reports opened, softwareMapEnabled and environmentIssues, as review_open does; set open:false to author in the background without taking over Desktop. parentId and dependsOn set its place among the project\'s reviews, as review_set_parent and review_set_dependencies do; projectId files it in another project, by project.id from review_list.',
40
43
  set_target:
41
- "Move the review to a new target (the same targets review_create takes), such as the commits after a rebase or amend, or commits or a PR for a board that had none. The document and component IDs are kept. Revisions are resolved on acceptance. Returns warnings for retained source ranges in files the new commits changed, to verify, and resources that no longer match; fix them with review_edit. Earlier versions keep their source. A PR target names the PR; other targets keep it within the same repository and clear it across repositories.",
44
+ "Move the review to a new target (the same targets review_create takes), such as the commits after a rebase or amend, or commits or a PR for a board that had none. The document and component IDs are kept. Revisions are resolved on acceptance. Returns warnings for retained source ranges in files the new commits changed, to verify, and resources that no longer match; fix them with review_edit. Earlier versions keep their source. A PR target names the PR; other targets keep it within the same repository and clear it across repositories. To refresh a PR review, pass its PR target again: a commits target with its branch names pins their tips, not the PR's diff base.",
42
45
  edit: [
43
46
  "Edit one document component. content is one component, named by its type; each type's fields are in the Components section of session_get_instructions.",
44
47
  "Anchors are head/path#L10-L20 or base/path#L7, or diff/path#L84-R90 for a range across sides, with repository-relative paths and 1-based inclusive lines. pins {head, base?} say which commits of the whiteboard's repository anchors quote: a step, frame, attachment or operation without pins uses its block's, and a block without them the review's.",
@@ -97,7 +100,15 @@ export function authoringTools(traceEnabled = false) {
97
100
  (traceEnabled
98
101
  ? ' Call review_get_instructions({topic:"trace-archaeology"}) for why code exists, what an agent was thinking, or whether an agent solved this before.'
99
102
  : ""),
100
- instructionsQuerySchema.partial(),
103
+ instructionsQuerySchema
104
+ .extend({
105
+ topic: animationsEnabled
106
+ ? instructionsQuerySchema.shape.topic
107
+ : z
108
+ .enum(["authoring", "file-lenses", "trace-archaeology"])
109
+ .default("authoring"),
110
+ })
111
+ .partial(),
101
112
  "GET",
102
113
  "/instructions",
103
114
  ),
@@ -144,7 +155,7 @@ export function authoringTools(traceEnabled = false) {
144
155
  open: z.boolean().optional(),
145
156
  }),
146
157
  ...(type === "set_target" && { target: pathTargetSchema }),
147
- ...(type === "edit" && { edit: publishedEditSchema }),
158
+ ...(type === "edit" && { edit: publishedEdits(animationsEnabled) }),
148
159
  }),
149
160
  "POST",
150
161
  "/commands",
@@ -0,0 +1,98 @@
1
+ import { anchorSchema } from "@review/lens-selection.js";
2
+ import { ReviewInputError } from "@review/review-api/input-error.js";
3
+ import { z } from "zod";
4
+
5
+ import {
6
+ type BlockDefinition,
7
+ defineBlock,
8
+ elementPins,
9
+ label,
10
+ text,
11
+ } from "./definition.js";
12
+
13
+ /** A name the stage's script passes to the `whiteboard` shim. */
14
+ const handle = z
15
+ .string()
16
+ .regex(
17
+ /^[A-Za-z][\w-]{0,63}$/,
18
+ "Use a short name: a letter, then letters, digits, _ or -.",
19
+ );
20
+
21
+ export const animationFrameSchema = z.strictObject({
22
+ key: handle.describe(
23
+ "Names the frame for the stage: data-frame on <html>, data-enter/data-exit, and whiteboard.on(key, ...).",
24
+ ),
25
+ title: label,
26
+ label: z
27
+ .string()
28
+ .min(1)
29
+ .max(24)
30
+ .optional()
31
+ .describe(
32
+ "A short name for the step strip under the stage, e.g. PUSH. Give every frame one, or none.",
33
+ ),
34
+ caption: text
35
+ .optional()
36
+ .describe("One or two short sentences shown under the stage."),
37
+ duration: z
38
+ .number()
39
+ .int()
40
+ .min(300)
41
+ .max(30_000)
42
+ .optional()
43
+ .describe("Milliseconds this frame stays up while playing. Default 3000."),
44
+ });
45
+
46
+ export type AnimationFrame = z.infer<typeof animationFrameSchema>;
47
+
48
+ export const animationSchema = defineBlock("animation", {
49
+ title: label,
50
+ html: text
51
+ .min(1)
52
+ .max(200_000)
53
+ .describe(
54
+ 'The stage\'s body markup: elements, <style> and <script>. It runs in a sandboxed frame with a global `whiteboard` shim; see session_get_instructions({topic:"animation"}).',
55
+ ),
56
+ frames: z
57
+ .array(animationFrameSchema)
58
+ .min(1)
59
+ .max(60)
60
+ .describe(
61
+ "Every possible frame. Stage scripts may select an ordered branch with whiteboard.setSequence(keys); keys stay unique and stable.",
62
+ ),
63
+ code: z
64
+ .record(handle, anchorSchema)
65
+ .default({})
66
+ .describe(
67
+ "Source ranges the stage shows as code peeks, by name: whiteboard.code(name).",
68
+ ),
69
+ height: z
70
+ .union([z.literal("auto"), z.number().int().min(120).max(1200)])
71
+ .default("auto")
72
+ .describe(
73
+ "Fit the stage to its content by default, or set a fixed height in CSS pixels.",
74
+ ),
75
+ pins: elementPins,
76
+ });
77
+
78
+ export type AnimationBlock = z.infer<typeof animationSchema>;
79
+
80
+ export const animation = {
81
+ type: "animation",
82
+ schema: animationSchema,
83
+ check(block: AnimationBlock) {
84
+ const keys = new Set(block.frames.map((frame) => frame.key));
85
+
86
+ if (keys.size !== block.frames.length)
87
+ throw new ReviewInputError(
88
+ "Animation frame keys must be unique within the animation.",
89
+ );
90
+
91
+ const labelled = block.frames.filter((frame) => frame.label).length;
92
+
93
+ if (labelled && labelled !== block.frames.length)
94
+ throw new ReviewInputError(
95
+ "Give every animation frame a label, or none: the step strip needs one per frame.",
96
+ );
97
+ },
98
+ } satisfies BlockDefinition<AnimationBlock>;
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
 
3
+ import { animation } from "./animation.js";
3
4
  import { call_stack_diff } from "./call_stack_diff.js";
4
5
  import { type CalloutBlock, callout, calloutSchema } from "./callout.js";
5
6
  import { code } from "./code.js";
@@ -28,6 +29,7 @@ export const leafSchema = z.discriminatedUnion("type", [
28
29
  trace_quote.schema,
29
30
  software_map.schema,
30
31
  flow_diagram.schema,
32
+ animation.schema,
31
33
  ]);
32
34
 
33
35
  export type LeafBlock = z.infer<typeof leafSchema>;
@@ -53,6 +55,7 @@ export const blocks = {
53
55
  trace_quote,
54
56
  software_map,
55
57
  flow_diagram,
58
+ animation,
56
59
  section,
57
60
  callout,
58
61
  } satisfies Definitions;
@@ -32,7 +32,9 @@ export const stepSchema = z
32
32
  /** The call site this arrow stands for, then any ranges directly about
33
33
  * that call, each shown as its own card. */
34
34
  source: z
35
- .union([anchorSchema, z.array(anchorSchema).min(1).max(4)])
35
+ .union([anchorSchema, z.array(anchorSchema).min(1).max(4)], {
36
+ error: "source must be one range or a list of 1 to 4 ranges",
37
+ })
36
38
  .optional(),
37
39
  pins: elementPins,
38
40
  explanation: label.optional(),