@dev.fast/whiteboard 0.5.1 → 0.5.2-preview.20261104.146

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 (117) hide show
  1. package/dist/{account-alias-Bw3PT3Qo.js → account-alias-BlYamXAG.js} +5 -5
  2. package/dist/{agent-cli-kHRrwO4n.js → agent-cli-D7fF5xmm.js} +9 -9
  3. package/dist/agent-cli-DLibAL4u.js +2 -0
  4. package/dist/{agent-client-CMacGbMp.js → agent-client-BCEbSPWU.js} +4 -4
  5. package/dist/{authoring-tools-CiLy46kh.js → authoring-tools-CkfxTVJm.js} +12 -8
  6. package/dist/{background-server-eZWXoVcI.js → background-server-BraP8pJ9.js} +4 -4
  7. package/dist/{background-server-BLNHbksB.js → background-server-CwToVN1g.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-Bxmiqutp.js → cli-BpadBaGU.js} +1 -1
  10. package/dist/{cli-runner-DQPuu7kJ.js → cli-runner-BebYF50a.js} +22 -22
  11. package/dist/cli.js +2 -2
  12. package/dist/{fuzzy-match-DZjeOaPY.js → fuzzy-match-CxcsLQGx.js} +3 -3
  13. package/dist/{headless-host-Dl2KZ9IT.js → headless-host-FAlbekFD.js} +561 -415
  14. package/dist/{headless-host-B4r0s1ks.js → headless-host-cLipIufH.js} +1 -1
  15. package/dist/{import-YAIU6rB0.js → import-CZ1958L4.js} +234 -62
  16. package/dist/{local-data-C58vzLZR.js → local-data-DdFkxFK_.js} +41 -16
  17. package/dist/{mcp-COYWiGVn.js → mcp-Claiy9xE.js} +9 -6
  18. package/dist/{package-paths-Cg6fnaGp.js → package-paths-Dk7MOXxN.js} +1 -1
  19. package/dist/{profile--BYIjPkc.js → profile-Bsgzi9PA.js} +2 -2
  20. package/dist/profile-BxdzBxHW.js +2 -0
  21. package/dist/{remote-attach-C1prLjmj.js → remote-attach-LGTpwpUv.js} +4 -4
  22. package/dist/{remote-extensions-Bw029iIS.js → remote-extensions-BvV29_gk.js} +1 -1
  23. package/dist/{remote-extensions-MUUgbLJn.js → remote-extensions-L95HNwIH.js} +2 -2
  24. package/dist/{remote-language-server-z3rdHcLL.js → remote-language-server-BAznkpeu.js} +4 -4
  25. package/dist/{remote-uninstall-B3Slwor1.js → remote-uninstall-CLP79Cuk.js} +5 -5
  26. package/dist/{request-origin-B3_XI2VA.js → request-origin-D6B-zrL8.js} +27 -5
  27. package/dist/{review-app-launcher-BZEbDKuK.js → review-app-launcher-tTe8bTwg.js} +2 -2
  28. package/dist/{review-home-paths-LdGS4SpR.js → review-home-paths-BmZX2qiz.js} +1 -1
  29. package/dist/{review-telemetry-x8u-9EZb.js → review-telemetry-DACDMfqq.js} +4 -4
  30. package/dist/server/desktop-host.js +26 -38
  31. package/dist/{server-discovery-WmuFl2S3.js → server-discovery-CV5qf1J8.js} +2 -2
  32. package/dist/sharing/index.d.ts +137 -14
  33. package/dist/sharing/index.js +1 -1
  34. package/dist/{src-B2b8cCoI.js → src-BQDlJXkG.js} +44 -5
  35. package/dist/{src-DLWol7GZ.js → src-CrrW7lcY.js} +16 -1
  36. package/dist/{stored-document-migration-iwIQ4l5G.js → stored-document-migration-D1wK4yID.js} +3 -3
  37. package/dist/{thread-state-B7L1mpN_.js → thread-state-WTqSborE.js} +1 -1
  38. package/dist/{tool-failure-RLR8m9hq.js → tool-failure-YOFGHJCZ.js} +319 -55
  39. package/instructions/animation.md +69 -5
  40. package/instructions/animations/before-after-structure/README.md +27 -0
  41. package/instructions/animations/before-after-structure/animation.html +89 -0
  42. package/instructions/animations/before-after-structure/block.json +10 -0
  43. package/instructions/animations/before-after-structure/frames.json +313 -0
  44. package/instructions/animations/cache-fill-evict/README.md +24 -0
  45. package/instructions/animations/cache-fill-evict/animation.html +81 -0
  46. package/instructions/animations/cache-fill-evict/block.json +13 -0
  47. package/instructions/animations/cache-fill-evict/frames.json +79 -0
  48. package/instructions/animations/cache-fill-evict/scenarios.json +17 -0
  49. package/instructions/animations/log-replay/README.md +25 -0
  50. package/instructions/animations/log-replay/animation.html +86 -0
  51. package/instructions/animations/log-replay/block.json +10 -0
  52. package/instructions/animations/log-replay/frames.json +143 -0
  53. package/instructions/animations/log-replay/scenarios.json +18 -0
  54. package/instructions/animations/queue-workers/README.md +23 -0
  55. package/instructions/animations/queue-workers/animation.html +107 -0
  56. package/instructions/animations/queue-workers/block.json +13 -0
  57. package/instructions/animations/queue-workers/frames.json +119 -0
  58. package/instructions/animations/queue-workers/scenarios.json +18 -0
  59. package/instructions/animations/race-two-lanes/README.md +22 -0
  60. package/instructions/animations/race-two-lanes/animation.html +97 -0
  61. package/instructions/animations/race-two-lanes/block.json +1 -0
  62. package/instructions/animations/race-two-lanes/frames.json +234 -0
  63. package/instructions/animations/race-two-lanes/scenarios.json +19 -0
  64. package/instructions/animations/request-across-layers/README.md +24 -0
  65. package/instructions/animations/request-across-layers/animation.html +85 -0
  66. package/instructions/animations/request-across-layers/block.json +17 -0
  67. package/instructions/animations/request-across-layers/frames.json +58 -0
  68. package/instructions/animations/request-across-layers/scenarios.json +12 -0
  69. package/instructions/animations/request-timing/README.md +22 -0
  70. package/instructions/animations/request-timing/animation.html +120 -0
  71. package/instructions/animations/request-timing/block.json +21 -0
  72. package/instructions/animations/request-timing/markers.json +32 -0
  73. package/instructions/animations/request-timing/scenarios.json +4 -0
  74. package/instructions/animations/retry-backoff/README.md +22 -0
  75. package/instructions/animations/retry-backoff/animation.html +122 -0
  76. package/instructions/animations/retry-backoff/block.json +10 -0
  77. package/instructions/animations/retry-backoff/frames.json +65 -0
  78. package/instructions/animations/retry-backoff/scenarios.json +12 -0
  79. package/instructions/animations/state-machine/README.md +24 -0
  80. package/instructions/animations/state-machine/animation.html +95 -0
  81. package/instructions/animations/state-machine/block.json +9 -0
  82. package/instructions/animations/state-machine/frames.json +79 -0
  83. package/instructions/animations/state-machine/scenarios.json +12 -0
  84. package/instructions/animations/video-platform-architecture/README.md +24 -0
  85. package/instructions/animations/video-platform-architecture/animation.html +76 -0
  86. package/instructions/animations/video-platform-architecture/block.json +17 -0
  87. package/instructions/animations/video-platform-architecture/frames.json +97 -0
  88. package/instructions/animations/video-platform-architecture/scenarios.json +28 -0
  89. package/instructions/whiteboard.d.ts +168 -14
  90. package/package.json +4 -4
  91. package/src/animation-stage-kit.ts +150 -10
  92. package/src/cli-runner.ts +4 -1
  93. package/src/fixtures/blocks/clip.json +19 -0
  94. package/src/review-api/README.md +1 -0
  95. package/src/review-api/agent-cli.ts +3 -2
  96. package/src/review-api/animation-drafts.ts +151 -47
  97. package/src/review-api/animation-references.ts +92 -0
  98. package/src/review-api/animation-render.ts +106 -0
  99. package/src/review-api/authoring-tools.ts +10 -3
  100. package/src/review-api/document-text.ts +27 -12
  101. package/src/review-api/http.ts +52 -6
  102. package/src/review-api/instructions.ts +1 -1
  103. package/src/review-api/mcp.ts +10 -5
  104. package/src/review-api/public-tools.ts +71 -2
  105. package/src/review-api/store.ts +89 -12
  106. package/src/server/desktop-server.ts +17 -5
  107. package/src/server/headless-host.ts +4 -0
  108. package/src/server/review-gateway.ts +1 -0
  109. package/src/server/review-server-core.ts +71 -12
  110. package/src/sharing/client.ts +129 -32
  111. package/src/sharing/export.ts +42 -5
  112. package/src/sharing/file.ts +8 -0
  113. package/src/sharing/host.ts +202 -73
  114. package/src/sharing/import.ts +125 -33
  115. package/src/ui-telemetry-events.ts +25 -1
  116. package/dist/agent-cli-B8KBHuh_.js +0 -2
  117. package/dist/profile-BjcXi-oH.js +0 -2
@@ -21,6 +21,10 @@ export const STAGE_THEME = {
21
21
  "added-bg": "--diff-added-bg",
22
22
  "removed-bg": "--diff-removed-bg",
23
23
  "modified-bg": "--diff-modified-bg",
24
+ // Two more outline colours for telling kinds of card apart: the software
25
+ // map's storage colour, and the merged purple.
26
+ teal: "--map-storage-border",
27
+ violet: "--merged",
24
28
  // Each face by its role: the chrome's for labels, mono for what a
25
29
  // terminal would print, the reading face for prose, and the code font.
26
30
  "font-sans": "--chrome-font",
@@ -52,6 +56,8 @@ export const STAGE_VARIABLES: [note: string, names: string[]][] = [
52
56
  "added-bg",
53
57
  "removed-bg",
54
58
  "modified-bg",
59
+ "teal",
60
+ "violet",
55
61
  ],
56
62
  ],
57
63
  [
@@ -149,7 +155,7 @@ const CLASSES: [selector: string, use: string][] = [
149
155
  ],
150
156
  [
151
157
  ".wb-card",
152
- "A box with a title and note: put .wb-card-title and .wb-card-note inside. The scene's cards are these.",
158
+ "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.",
153
159
  ],
154
160
  [
155
161
  ".wb-node",
@@ -178,6 +184,18 @@ const CLASSES: [selector: string, use: string][] = [
178
184
  ".wb-shape, .wb-shape-text",
179
185
  "An SVG shape drawn like a card, and the text inside it.",
180
186
  ],
187
+ [
188
+ ".wb-group",
189
+ "A washed box behind a scene's cards with a .wb-group-title; scene.group draws one.",
190
+ ],
191
+ [
192
+ ".wb-legend",
193
+ "A row of .wb-legend-entry glyphs and labels under a scene; scene.legend draws one.",
194
+ ],
195
+ [
196
+ "[data-wb-paint]",
197
+ 'When an agent writes the block, its parts paint in: data-wb-paint="3" orders a part (numbers first, then document order), "none" shows it and its subtree at once; data-wb-paint-style="fade|draw|wipe|none" picks the effect.',
198
+ ],
181
199
  ];
182
200
 
183
201
  /** The CSS every stage loads before its own markup. */
@@ -186,6 +204,7 @@ export const STAGE_KIT_CSS = `:root { --wb-duration: 450ms; color-scheme: light
186
204
  button { color: inherit; font: inherit; }
187
205
  html, body { margin: 0; height: 100%; }
188
206
  html[data-wb-auto-size] body { height: auto; display: flow-root; }
207
+ html[data-wb-unpainted] body { visibility: hidden; }
189
208
  body {
190
209
  overflow: hidden;
191
210
  background: transparent;
@@ -240,9 +259,76 @@ body {
240
259
  .wb-node { min-width: 88px; text-align: center; }
241
260
  .wb-node small { display: block; color: var(--wb-ink-faint); font-size: var(--wb-text-micro); }
242
261
  .wb-card-head { display: flex; align-items: center; gap: var(--wb-space-6); }
243
- .wb-card-dot { flex: none; width: 7px; height: 7px; border-radius: var(--wb-radius-round); background: var(--wb-ink-faint); }
262
+ /* The dot says what state a card is in: it shows only with a tone or a
263
+ change mark, in that colour, so a plain card carries no unexplained mark. */
264
+ .wb-card-dot { display: none; flex: none; width: 7px; height: 7px; border-radius: var(--wb-radius-round); background: var(--wb-ink-faint); }
265
+ .wb-card:is([data-tone], [data-change]) .wb-card-dot { display: block; }
244
266
  .wb-card-title { min-width: 0; overflow-wrap: anywhere; font-weight: var(--wb-weight-semibold); }
245
267
  .wb-card-note { color: var(--wb-ink-muted); overflow-wrap: anywhere; font-size: var(--wb-text-micro); min-height: 12px; }
268
+
269
+ /* A store's text sits in the middle of its drum or cylinder, as a label on
270
+ a tank does; a pill's too. */
271
+ :where(.wb-card, .wb-node):is([data-form="cylinder"], [data-form="drum"], [data-form="pill"]) { align-items: center; text-align: center; }
272
+ .wb-card:is([data-form="cylinder"], [data-form="drum"], [data-form="pill"]) :is(.wb-card-head, .wb-items) { justify-content: center; }
273
+ /* Shapes: a card's or node's silhouette says what it stands for before
274
+ its title does. data-form is box (the default), pill, cut (a branch),
275
+ cylinder (a database), drum (a cache), stack (a queue), window (a
276
+ client) or bars (a service of this codebase). data-outline is solid,
277
+ dashed (outside the system) or dotted. data-color is the resting border
278
+ colour: ink, accent, teal or violet; a tone takes over while set. The
279
+ scene's cards take these as options. These rules and the change marks
280
+ weigh one class, as the card rules do: a hand-made layout's own rule for
281
+ its nodes, say position: absolute, comes later and wins. A tone or a
282
+ highlight still takes over. */
283
+ :where(.wb-card, .wb-node)[data-form] { position: relative; }
284
+ :where(.wb-card, .wb-node)[data-form]::before, :where(.wb-card, .wb-node)[data-form]::after { pointer-events: none; }
285
+ :where(.wb-card, .wb-node)[data-form="pill"] { padding-inline: var(--wb-space-16); border-radius: var(--wb-radius-pill); }
286
+ :where(.wb-card, .wb-node)[data-form="cut"] { border-radius: var(--wb-space-12); corner-shape: bevel; }
287
+ :where(.wb-card, .wb-node)[data-form="cylinder"] { padding-top: var(--wb-space-28); padding-bottom: var(--wb-space-12); border-radius: 50% / var(--wb-space-10); }
288
+ :where(.wb-card, .wb-node)[data-form="cylinder"]::before {
289
+ content: ""; position: absolute; inset: -1px -1px auto; height: var(--wb-space-20);
290
+ border: 1px solid; border-color: inherit; border-radius: 50%; background: inherit;
291
+ }
292
+ :where(.wb-card, .wb-node)[data-form="drum"] { padding-left: var(--wb-space-28); padding-right: var(--wb-space-16); border-radius: var(--wb-space-12) / 50%; }
293
+ :where(.wb-card, .wb-node)[data-form="drum"]::before {
294
+ content: ""; position: absolute; inset: -1px auto -1px -1px; width: var(--wb-space-24);
295
+ border: 1px solid; border-color: inherit; border-radius: 50%; background: inherit;
296
+ }
297
+ :where(.wb-card, .wb-node)[data-form="stack"] {
298
+ margin-right: var(--wb-space-8); margin-bottom: var(--wb-space-8);
299
+ box-shadow: 4px 4px 0 -1px var(--wb-surface), 4px 4px 0 0 var(--wb-rule), 8px 8px 0 -1px var(--wb-surface), 8px 8px 0 0 var(--wb-rule);
300
+ }
301
+ :where(.wb-card, .wb-node)[data-form="window"] { padding-top: var(--wb-space-16); }
302
+ :where(.wb-card, .wb-node)[data-form="window"]::before { content: ""; position: absolute; top: var(--wb-space-8); right: 0; left: 0; border-top: 1px solid; border-color: inherit; }
303
+ :where(.wb-card, .wb-node)[data-form="bars"] { padding-inline: var(--wb-space-18); }
304
+ :where(.wb-card, .wb-node)[data-form="bars"]::before, :where(.wb-card, .wb-node)[data-form="bars"]::after { content: ""; position: absolute; top: 0; bottom: 0; border-left: 1px solid; border-color: inherit; }
305
+ :where(.wb-card, .wb-node)[data-form="bars"]::before { left: var(--wb-space-6); }
306
+ :where(.wb-card, .wb-node)[data-form="bars"]::after { right: var(--wb-space-6); }
307
+ /* What the change under review did to a part: its dot and a stripe down its
308
+ left edge in the change colour, at rest; a removed part's title is struck.
309
+ A frame's tone still colours the whole card over it. */
310
+ :where(.wb-card, .wb-node)[data-change] { border-left-width: 3px; }
311
+ :where(.wb-card, .wb-node)[data-change="added"] { border-left-color: var(--wb-added); }
312
+ :where(.wb-card, .wb-node)[data-change="modified"] { border-left-color: var(--wb-modified); }
313
+ :where(.wb-card, .wb-node)[data-change="removed"] { border-left-color: var(--wb-removed); }
314
+ .wb-card[data-change="added"] .wb-card-dot { background: var(--wb-added); }
315
+ .wb-card[data-change="modified"] .wb-card-dot { background: var(--wb-modified); }
316
+ .wb-card[data-change="removed"] .wb-card-dot { background: var(--wb-removed); }
317
+ .wb-card[data-change="removed"] .wb-card-title { color: var(--wb-ink-muted); text-decoration: line-through; }
318
+ :where(.wb-card, .wb-node)[data-outline="dashed"] { border-style: dashed; }
319
+ :where(.wb-card, .wb-node)[data-outline="dotted"] { border-style: dotted; }
320
+ :where(.wb-card, .wb-node)[data-color="ink"] { border-color: var(--wb-ink); }
321
+ :where(.wb-card, .wb-node)[data-color="accent"] { border-color: var(--wb-accent); }
322
+ :where(.wb-card, .wb-node)[data-color="teal"] { border-color: var(--wb-teal); }
323
+ :where(.wb-card, .wb-node)[data-color="violet"] { border-color: var(--wb-violet); }
324
+ .wb-card[data-color="ink"] .wb-card-dot { background: var(--wb-ink); }
325
+ .wb-card[data-color="accent"] .wb-card-dot { background: var(--wb-accent); }
326
+ .wb-card[data-color="teal"] .wb-card-dot { background: var(--wb-teal); }
327
+ .wb-card[data-color="violet"] .wb-card-dot { background: var(--wb-violet); }
328
+ .wb-shape[data-color="ink"] { stroke: var(--wb-ink); }
329
+ .wb-shape[data-color="accent"] { stroke: var(--wb-accent); }
330
+ .wb-shape[data-color="teal"] { stroke: var(--wb-teal); }
331
+ .wb-shape[data-color="violet"] { stroke: var(--wb-violet); }
246
332
  .wb-well { padding: var(--wb-space-12); border: 1px solid var(--wb-rule-soft); border-radius: var(--wb-radius-surface); background: var(--wb-well); }
247
333
  .wb-well :is(.wb-item, .wb-chip, .wb-badge, .wb-code) { background: var(--wb-surface); }
248
334
  .wb-rule { margin: var(--wb-space-12) 0; border: 0; border-top: 1px solid var(--wb-rule); }
@@ -306,10 +392,15 @@ body {
306
392
  .wb-shape[data-tone="modified"] { stroke: var(--wb-modified); fill: var(--wb-modified-bg); }
307
393
  .wb-shape[data-tone="muted"] { stroke-dasharray: 4 3; }
308
394
  :is(.wb-card, .wb-node)[data-wb-highlit] { border-color: var(--wb-accent); box-shadow: 0 0 0 3px var(--wb-accent-soft); }
395
+ :is(.wb-card, .wb-node)[data-form="stack"][data-wb-highlit] {
396
+ box-shadow: 0 0 0 3px var(--wb-accent-soft), 4px 4px 0 -1px var(--wb-surface), 4px 4px 0 0 var(--wb-rule), 8px 8px 0 -1px var(--wb-surface), 8px 8px 0 0 var(--wb-rule);
397
+ }
309
398
  .wb-shape[data-wb-highlit] { stroke: var(--wb-accent); stroke-width: 2; }
310
399
 
311
400
  /* Connectors and tokens. */
312
- .wb-arrows, .wb-tokens { position: fixed; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
401
+ /* Above the cards, which sit above their groups: an edge's head meets a
402
+ card's border, and a chip stays readable as it passes one. */
403
+ .wb-arrows, .wb-tokens { position: fixed; inset: 0; z-index: 2; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
313
404
  .wb-line {
314
405
  fill: none;
315
406
  stroke: var(--wb-ink-faint);
@@ -328,6 +419,17 @@ body {
328
419
  .wb-arrow[data-tone="removed"] .wb-line { stroke: var(--wb-removed); marker-end: url(#wb-head-removed); }
329
420
  .wb-arrow[data-tone="modified"] .wb-line { stroke: var(--wb-modified); marker-end: url(#wb-head-modified); }
330
421
  .wb-arrow[data-wb-headless] .wb-line { marker-end: none; }
422
+ /* An edge carrying one of the frame's messages takes the marker, label too. */
423
+ .wb-arrow[data-wb-sent] .wb-line { stroke: var(--wb-accent); marker-end: url(#wb-head-accent); }
424
+ .wb-arrow[data-wb-sent] .wb-label { fill: var(--wb-accent); }
425
+ .wb-arrow[data-wb-sent="added"] .wb-line { stroke: var(--wb-added); marker-end: url(#wb-head-added); }
426
+ .wb-arrow[data-wb-sent="added"] .wb-label { fill: var(--wb-added); }
427
+ .wb-arrow[data-wb-sent="removed"] .wb-line { stroke: var(--wb-removed); marker-end: url(#wb-head-removed); }
428
+ .wb-arrow[data-wb-sent="removed"] .wb-label { fill: var(--wb-removed); }
429
+ .wb-arrow[data-wb-sent="modified"] .wb-line { stroke: var(--wb-modified); marker-end: url(#wb-head-modified); }
430
+ .wb-arrow[data-wb-sent="modified"] .wb-label { fill: var(--wb-modified); }
431
+ .wb-arrow[data-wb-sent="muted"] .wb-line { stroke: var(--wb-ink-faint); marker-end: url(#wb-head); }
432
+ .wb-arrow[data-wb-sent="muted"] .wb-label { fill: var(--wb-ink-muted); }
331
433
  .wb-label {
332
434
  fill: var(--wb-ink-muted);
333
435
  font: var(--wb-text-micro) var(--wb-font-mono);
@@ -362,16 +464,53 @@ body {
362
464
  .wb-token[data-labelled][data-tone="muted"] { color: var(--wb-token-muted-ink); }
363
465
 
364
466
  /* The scene. */
365
- .wb-scene { display: grid; gap: var(--wb-space-28) var(--wb-space-48); padding: var(--wb-space-24); align-items: start; justify-items: stretch; min-width: 0; }
366
- .wb-scene-compact { grid-template-columns: minmax(0, 1fr) !important; row-gap: var(--wb-space-48); }
367
- .wb-scene-compact > * { grid-column: 1 / -1 !important; grid-row: auto !important; }
368
- .wb-scene-fold { grid-template-columns: repeat(var(--wb-fold), minmax(0, 1fr)) !important; }
369
- .wb-scene-fold > * { grid-column: auto !important; grid-row: auto !important; }
370
- .wb-scene-fold > [data-wb-wide] { grid-column: 1 / -1 !important; }
467
+ .wb-scene { display: grid; gap: var(--wb-space-28) var(--wb-space-48); padding: var(--wb-space-24); align-items: center; justify-items: stretch; min-width: 0; }
468
+ /* A card is as wide as what it says, within bounds, and sits mid-column, so
469
+ a wide column does not stretch it into a slab. A table keeps its column;
470
+ a scene's own CSS may set any width. The scene sets its own min-width from
471
+ the columns its cards make, and the block scrolls it sideways when
472
+ narrower. */
473
+ .wb-scene > .wb-card:not([data-kind="table"]) { justify-self: center; width: fit-content; min-width: 140px; max-width: 300px; }
371
474
  .wb-scene > [data-wb-state="before"], .wb-scene > [data-wb-state="after"],
372
475
  .wb-items > [data-wb-state="before"], .wb-items > [data-wb-state="after"],
373
476
  .wb-table tr[data-wb-state="before"], .wb-table tr[data-wb-state="after"] { display: none; }
374
477
  .wb-scene-top { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--wb-space-8); margin-bottom: var(--wb-space-4); }
478
+ /* A group is a washed box behind the cards of its cells, bleeding into the
479
+ gaps around them, titled in its top-left. Cards paint over it. */
480
+ .wb-scene > .wb-card { position: relative; z-index: 1; }
481
+ /* Groups bleed into the gaps around their cells, so a grouped scene opens
482
+ its rows and columns to keep the boxes apart and give edges between
483
+ layers room to be read. The top bleed holds the title clear of the cards
484
+ below it, ring and all. */
485
+ .wb-scene:has(> .wb-group) { row-gap: var(--wb-space-72); column-gap: var(--wb-space-72); padding: var(--wb-space-32) var(--wb-space-24); }
486
+ .wb-group {
487
+ position: relative; z-index: 0; align-self: stretch; justify-self: stretch;
488
+ margin: calc(-1 * var(--wb-space-28)) calc(-1 * var(--wb-space-16)) calc(-1 * var(--wb-space-10));
489
+ border: 1px solid var(--wb-rule-soft); border-radius: var(--wb-radius-surface); background: var(--wb-well);
490
+ transition: opacity var(--wb-duration) ease;
491
+ }
492
+ .wb-group-title { position: absolute; top: var(--wb-space-4); left: var(--wb-space-12); color: var(--wb-ink-muted); font: var(--wb-text-micro)/var(--wb-leading-normal) var(--wb-font-mono); letter-spacing: var(--wb-tracking-chrome); text-transform: uppercase; white-space: nowrap; }
493
+ .wb-group[data-color="accent"] { border-color: color-mix(in srgb, var(--wb-accent) 30%, transparent); background: color-mix(in srgb, var(--wb-accent) 7%, transparent); }
494
+ .wb-group[data-color="accent"] .wb-group-title { color: var(--wb-accent); }
495
+ .wb-group[data-color="teal"] { border-color: color-mix(in srgb, var(--wb-teal) 36%, transparent); background: color-mix(in srgb, var(--wb-teal) 8%, transparent); }
496
+ .wb-group[data-color="teal"] .wb-group-title { color: var(--wb-teal); }
497
+ .wb-group[data-color="violet"] { border-color: color-mix(in srgb, var(--wb-violet) 36%, transparent); background: color-mix(in srgb, var(--wb-violet) 8%, transparent); }
498
+ .wb-group[data-color="violet"] .wb-group-title { color: var(--wb-violet); }
499
+ .wb-group[data-color="ink"] { border-color: var(--wb-rule); }
500
+ .wb-group[data-color="ink"] .wb-group-title { color: var(--wb-ink); }
501
+ /* The legend: a card-like glyph per entry, drawn with the card rules and
502
+ scaled to 0.4 inside a box of the scaled size, and what it stands for.
503
+ A transform, not zoom: Electron's Chromium reports a zoomed element's
504
+ rect in unzoomed units, which made the stage measure itself 2.5x tall. */
505
+ .wb-legend { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--wb-space-6) var(--wb-space-16); padding: 0 var(--wb-space-24) var(--wb-space-12); color: var(--wb-ink-muted); font: var(--wb-text-micro)/var(--wb-leading-normal) var(--wb-font-mono); letter-spacing: var(--wb-tracking-chrome); text-transform: uppercase; }
506
+ .wb-legend-entry { display: inline-flex; align-items: center; gap: var(--wb-space-6); white-space: nowrap; }
507
+ .wb-legend-box { position: relative; flex: none; width: 20px; height: 13px; }
508
+ .wb-card.wb-legend-glyph { position: absolute; top: 0; left: 0; display: block; width: 50px; min-width: 0; height: 32px; padding: 0; transform: scale(0.4); transform-origin: 0 0; }
509
+ .wb-card.wb-legend-glyph[data-form="cylinder"] { height: 44px; }
510
+ /* Lines that scale to a pixel: the glyph's borders, its lid and rule marks,
511
+ and its change stripe are drawn 2.5x heavier before the 0.4 scale. */
512
+ .wb-card.wb-legend-glyph, .wb-card.wb-legend-glyph::before, .wb-card.wb-legend-glyph::after { border-width: 2.5px; }
513
+ .wb-card.wb-legend-glyph[data-change] { border-left-width: 7px; }
375
514
  :root[data-wb-instant] *, :root[data-wb-instant] *::before, :root[data-wb-instant] *::after {
376
515
  transition: none !important;
377
516
  animation-duration: 0s !important;
@@ -397,7 +536,8 @@ export function stageStyleReference(): string {
397
536
  * animation.html only for what they do not cover, written with the variables:
398
537
  * no literal colors, sizes or font names. data-tone="accent|added|removed|
399
538
  * modified|muted" colors any card, node, chip, badge, shape or table row;
400
- * data-highlight="frameKey ..." rings a card, node or shape in those frames.
539
+ * data-highlight="frameKey ..." rings a card, node or shape in those frames
540
+ * (in a clip, those markers).
401
541
  *
402
542
  * Classes:
403
543
  ${classes}
package/src/cli-runner.ts CHANGED
@@ -1226,7 +1226,10 @@ export async function runReviewCli(input: ReviewCliInput): Promise<number> {
1226
1226
  program.command("login").description("Log in to Whiteboard with GitHub"),
1227
1227
  "plain",
1228
1228
  )
1229
- .option("--origin <url>", "Whiteboard service origin", DEFAULT_STORE_ORIGIN)
1229
+ .option(
1230
+ "--origin <url>",
1231
+ `Whiteboard service origin (default: $DEV_REVIEW_SERVICE_ORIGIN or ${DEFAULT_STORE_ORIGIN})`,
1232
+ )
1230
1233
  .option("--traces", "Also authorize GitHub repositories for hosted traces")
1231
1234
  .option("--no-browser", "Print the URL instead of opening a browser")
1232
1235
  .action(
@@ -0,0 +1,19 @@
1
+ [
2
+ {
3
+ "id": "block-1",
4
+ "type": "clip",
5
+ "title": "Order status",
6
+ "height": 300,
7
+ "markers": [
8
+ { "key": "sent", "title": "Order sent" },
9
+ {
10
+ "key": "queued",
11
+ "title": "Status queued",
12
+ "caption": "The status line sets it.",
13
+ "code": ["status"]
14
+ }
15
+ ],
16
+ "code": { "status": "head/order.ts#L1" },
17
+ "html": "<div class=\"wb-row\"><div class=\"wb-node\" id=\"order\">order</div></div><div data-wb-code=\"status\"></div><script>whiteboard.clip().marker(\"sent\").add(\"#order\", { translateX: [0, 120], duration: 800 }).marker(\"queued\");</script>"
18
+ }
19
+ ]
@@ -55,6 +55,7 @@ All paths below are relative to `/reviews-api`.
55
55
  | `GET /:id/history` | Saved versions with titles, timestamps and `author`: `"you"` for a person's save, else `"agent"` with the Ask `threadId` when one made it |
56
56
  | `GET /:id/inspect` | Agent reading view: nested text outline with IDs; `targetId` reads one component completely, `full=true` includes all content, `version` selects history. `format=json` returns raw data instead. |
57
57
  | `POST /:id/open` | Open the review in the attached Desktop; report an error when none is attached |
58
+ | `GET /:id/render?targetId=anim-1&frame=a2` | Show that frame (or a clip at that marker) of the review open in the attached Desktop and return it: `type`, `frame` (key, title, position, count), `scenario`, `clipped`, `error` (what the stage's script threw, or null) and `png` (base64). 409 when no Desktop shows the review; 400 for a frame the block does not have |
58
59
  | `POST /commands` | Apply one command; return review ID, version, and for an edit the target ID, its `type`, and — after an insert or replace — `children`: its first-level children as `{id,type}` (a container's blocks; a diagram's steps, or nodes then edges), so new components are addressable without a read. A `create` with `pullRequestUrl` returns the newest existing review for that PR instead (owner/repository matched case-insensitively) unless `operation.reuseExisting` is `false`: `created:false`, a `note`, its stored `target`, `headMoved`, and `working`/`otherReviewIds` when they apply; its target is never moved. A new review reports `created:true`. Either way the result carries `review`, the review's `GET /` catalog entry (target, origin, repository name and path). An interactive `create` also opens the new review in an attached Desktop unless `operation.open` is `false`, and reports `opened` with the open result or an `openError`; the review is saved either way |
59
60
  | `POST /commands {operation:{type:"lens_edit",reviewId,edit}}` | Write one file lens, credited to `activityId` when given: `insert {title,targets,afterId?}` (host id `lens-N`), `update {targetId,title?,targets?}` or `remove {targetId}`. Returns `{targetId, type:"lens", uncategorized}`, where `uncategorized` lists changed files and ranges no lens covers yet (at most 50 files) |
60
61
  | `POST /:id/person-edit {baseVersion,title,document}` | Save a person's whole board as their version; return `{reviewId,version,ids}`. See Person edits |
@@ -18,7 +18,7 @@ import {
18
18
  } from "./agent-client.js";
19
19
  import { type ReviewApiClient, ReviewApiError } from "./client.js";
20
20
  import { exitOnInstallUpdate } from "./install-update.js";
21
- import { callPublicTool, publicTool } from "./public-tools.js";
21
+ import { callPublicTool, publicTool, renderedImage } from "./public-tools.js";
22
22
  import { RECOVERY } from "./recovery.js";
23
23
  import {
24
24
  ASK_THREAD_ENV,
@@ -212,7 +212,8 @@ export async function runReviewAgentCli(input: AgentCliInput): Promise<number> {
212
212
  durationMs: Date.now() - startedAt,
213
213
  });
214
214
 
215
- const text = toolResultText(tool, result);
215
+ // A rendered frame is read from its file; its bytes stay out of stdout.
216
+ const text = toolResultText(tool, renderedImage(tool, result).result);
216
217
  input.stdout.write(text.endsWith("\n") ? text : text + "\n");
217
218
 
218
219
  return 0;
@@ -1,9 +1,18 @@
1
- // An animation checked out as files, so an agent edits it with its own file
2
- // tools and publishes the folder: the bytes travel by path, not through the
3
- // agent's output.
1
+ // An animation or clip checked out as files, so an agent edits it with its
2
+ // own file tools and publishes the folder: the bytes travel by path, not
3
+ // through the agent's output. A clip's folder holds markers.json where an
4
+ // animation's holds frames.json.
4
5
 
5
6
  import { createHash } from "node:crypto";
6
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
7
+ import {
8
+ chmod,
9
+ cp,
10
+ mkdir,
11
+ readFile,
12
+ readdir,
13
+ rm,
14
+ writeFile,
15
+ } from "node:fs/promises";
7
16
  import path from "node:path";
8
17
 
9
18
  import {
@@ -24,9 +33,15 @@ import { isMissingFileError } from "@review/fs-utils.js";
24
33
  import { findReviewPackageRoot } from "@review/package-paths.js";
25
34
  import { z } from "zod";
26
35
 
36
+ import {
37
+ type AnimationReference,
38
+ readAnimationReferences,
39
+ } from "./animation-references.js";
40
+
27
41
  const FILES = {
28
42
  html: "animation.html",
29
43
  frames: "frames.json",
44
+ markers: "markers.json",
30
45
  scenarios: "scenarios.json",
31
46
  block: "block.json",
32
47
  checkout: "checkout.json",
@@ -35,8 +50,11 @@ const FILES = {
35
50
  /** The fields block.json holds: everything but the ones with files of their own. */
36
51
  const BLOCK_FIELDS = ["title", "code", "height", "pins"];
37
52
 
38
- /** Every field an animation's files can set. */
39
- const FIELDS = ["html", "frames", "scenarios", ...BLOCK_FIELDS];
53
+ /** Every field an animation's or clip's files can set. */
54
+ const FIELDS = ["html", "frames", "markers", "scenarios", ...BLOCK_FIELDS];
55
+
56
+ /** The block types that check out as files. */
57
+ type DraftType = "animation" | "clip";
40
58
 
41
59
  /** What checkout.json records: the block the folder came from, as it was. */
42
60
  const checkoutSchema = z.strictObject({
@@ -57,10 +75,12 @@ const handle = /^[\w-]{1,128}$/;
57
75
 
58
76
  /** The animation references, read-only beside the drafts so they always match
59
77
  * the Whiteboard that wrote them: whiteboard.css, the style kit every
60
- * animation loads, and whiteboard.d.ts, the API its scripts call. */
78
+ * animation loads, whiteboard.d.ts, the API its scripts call, and the
79
+ * reference animations an agent copies from. */
61
80
  export interface StageReference {
62
81
  styles: string;
63
82
  api: string;
83
+ references: AnimationReference[];
64
84
  }
65
85
 
66
86
  async function writeReadOnly(file: string, content: string): Promise<void> {
@@ -69,27 +89,83 @@ async function writeReadOnly(file: string, content: string): Promise<void> {
69
89
  await writeFile(file, content, { mode: 0o444 });
70
90
  }
71
91
 
72
- export async function writeStageReference(): Promise<StageReference> {
92
+ /** Copies `source` to `target` with every file read-only, replacing what was there. */
93
+ async function copyReadOnly(source: string, target: string): Promise<void> {
94
+ await rm(target, { recursive: true, force: true });
95
+ await cp(source, target, { recursive: true });
96
+
97
+ for (const entry of await readdir(target, {
98
+ recursive: true,
99
+ withFileTypes: true,
100
+ }))
101
+ if (entry.isFile())
102
+ await chmod(path.join(entry.parentPath, entry.name), 0o444);
103
+ }
104
+
105
+ export async function writeStageReference(
106
+ root = findReviewPackageRoot(import.meta.url),
107
+ ): Promise<StageReference> {
73
108
  const directory = path.join(devReviewHome(), "animation-drafts");
74
109
  const styles = path.join(directory, "whiteboard.css");
75
110
  const api = path.join(directory, "whiteboard.d.ts");
111
+ const copies = path.join(directory, "references");
112
+ const sources = await readAnimationReferences(root, { strict: false });
76
113
 
77
- const source = path.join(
78
- findReviewPackageRoot(import.meta.url),
79
- "instructions",
80
- "whiteboard.d.ts",
114
+ await mkdir(copies, { recursive: true });
115
+ await writeReadOnly(styles, stageStyleReference());
116
+ await writeReadOnly(
117
+ api,
118
+ await readFile(path.join(root, "instructions", "whiteboard.d.ts"), "utf8"),
81
119
  );
82
120
 
83
- await mkdir(directory, { recursive: true });
84
- await writeReadOnly(styles, stageStyleReference());
85
- await writeReadOnly(api, await readFile(source, "utf8"));
121
+ const names = new Set(sources.map(({ name }) => name));
122
+
123
+ for (const stale of await readdir(copies))
124
+ if (!names.has(stale))
125
+ await rm(path.join(copies, stale), { recursive: true, force: true });
126
+
127
+ const references = await Promise.all(
128
+ sources.map(async (reference) => {
129
+ const copy = path.join(copies, reference.name);
86
130
 
87
- return { styles, api };
131
+ await copyReadOnly(reference.directory, copy);
132
+
133
+ return { ...reference, directory: copy };
134
+ }),
135
+ );
136
+
137
+ return { styles, api, references };
88
138
  }
89
139
 
90
140
  /** The line that sends an agent to the references before it writes an animation. */
91
- export const stageReferenceHint = ({ styles, api }: StageReference) =>
92
- `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.`;
141
+ export const stageReferenceHint = ({
142
+ styles,
143
+ api,
144
+ references,
145
+ }: StageReference) =>
146
+ `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.`;
147
+
148
+ /** Lists the reference animations where the agent can read them. */
149
+ function referencesSection({ references }: StageReference): string[] {
150
+ if (!references.length) return [];
151
+
152
+ return [
153
+ [
154
+ "## References",
155
+ "",
156
+ "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.",
157
+ "",
158
+ ...references.map(
159
+ ({ name, summary, directory }) =>
160
+ `- \`${name}\`: ${summary} ${directory}`,
161
+ ),
162
+ ].join("\n"),
163
+ ];
164
+ }
165
+
166
+ /** What follows the animation instructions: the hint, then the references. */
167
+ export const stageReferenceText = (reference: StageReference) =>
168
+ [stageReferenceHint(reference), ...referencesSection(reference)].join("\n\n");
93
169
 
94
170
  export function draftDirectory(
95
171
  sessionId: string,
@@ -110,9 +186,12 @@ const fingerprint = (block: JsonValue) =>
110
186
  const json = (value: JsonValue) => `${JSON.stringify(value, null, 2)}\n`;
111
187
 
112
188
  function animationBlock(block: JsonValue, targetId: string): JsonObject {
113
- if (!isJsonObject(block) || block.type !== "animation")
189
+ if (
190
+ !isJsonObject(block) ||
191
+ (block.type !== "animation" && block.type !== "clip")
192
+ )
114
193
  throw new ReviewInputError(
115
- `${targetId} is ${isJsonObject(block) ? `a ${String(block.type)}` : "not a component"}; only animations check out as files.`,
194
+ `${targetId} is ${isJsonObject(block) ? `a ${String(block.type)}` : "not a component"}; only animations and clips check out as files.`,
116
195
  );
117
196
 
118
197
  return block;
@@ -143,7 +222,9 @@ export async function checkoutAnimation(
143
222
  };
144
223
 
145
224
  await put("html", String(block.html));
146
- await put("frames", json(block.frames ?? []));
225
+
226
+ if (block.type === "clip") await put("markers", json(block.markers ?? []));
227
+ else await put("frames", json(block.frames ?? []));
147
228
 
148
229
  if (block.scenarios !== undefined)
149
230
  await put("scenarios", json(block.scenarios));
@@ -167,7 +248,8 @@ export async function checkoutAnimation(
167
248
  return {
168
249
  directory,
169
250
  files,
170
- ...reference,
251
+ styles: reference.styles,
252
+ api: reference.api,
171
253
  next: `Edit these files with your file tools, then publish the folder: session_edit {sessionId, edit: {type: "update", targetId: "${targetId}", fromFiles: "${directory}"}}. ${stageReferenceHint(reference)}`,
172
254
  };
173
255
  }
@@ -184,7 +266,7 @@ async function readText(directory: string, name: string, required: boolean) {
184
266
 
185
267
  if (required)
186
268
  throw new ReviewInputError(
187
- `${path.join(directory, name)} does not exist. Check the animation out with session_checkout, or write the file.`,
269
+ `${path.join(directory, name)} does not exist. Check the animation or clip out with session_checkout, or write the file.`,
188
270
  );
189
271
 
190
272
  return undefined;
@@ -205,16 +287,30 @@ async function readJson(directory: string, name: string, required: boolean) {
205
287
  }
206
288
  }
207
289
 
208
- /** The animation a folder holds, as a block's fields, and where it came from. */
209
- async function readDraft(directory: string) {
210
- const [html, frames, scenarios, block, checkout] = await Promise.all([
290
+ /** The animation or clip a folder holds, as a block's fields with its type,
291
+ * checked as a publish would be. frames.json makes it an animation and
292
+ * markers.json a clip. */
293
+ export async function readAnimationFolder(
294
+ directory: string,
295
+ ): Promise<JsonObject & { type: DraftType }> {
296
+ const [html, frames, markers, scenarios, block] = await Promise.all([
211
297
  readText(directory, FILES.html, true),
212
- readJson(directory, FILES.frames, true),
298
+ readJson(directory, FILES.frames, false),
299
+ readJson(directory, FILES.markers, false),
213
300
  readJson(directory, FILES.scenarios, false),
214
301
  readJson(directory, FILES.block, true),
215
- readJson(directory, FILES.checkout, false),
216
302
  ]);
217
303
 
304
+ if (frames !== undefined && markers !== undefined)
305
+ throw new ReviewInputError(
306
+ `${directory} holds both ${FILES.frames} and ${FILES.markers}. Keep ${FILES.frames} for an animation or ${FILES.markers} for a clip, not both.`,
307
+ );
308
+
309
+ if (frames === undefined && markers === undefined)
310
+ throw new ReviewInputError(
311
+ `${path.join(directory, FILES.frames)} does not exist. Write ${FILES.frames} for an animation or ${FILES.markers} for a clip, or check one out with session_checkout.`,
312
+ );
313
+
218
314
  if (!isJsonObject(block))
219
315
  throw new ReviewInputError(`${FILES.block} must hold a JSON object.`);
220
316
 
@@ -222,15 +318,28 @@ async function readDraft(directory: string) {
222
318
 
223
319
  if (stray.length)
224
320
  throw new ReviewInputError(
225
- `${FILES.block} holds only ${BLOCK_FIELDS.join(", ")}; move ${stray.join(", ")} out (html is ${FILES.html}, frames ${FILES.frames}, scenarios ${FILES.scenarios}).`,
321
+ `${FILES.block} holds only ${BLOCK_FIELDS.join(", ")}; move ${stray.join(", ")} out (html is ${FILES.html}, frames ${FILES.frames} or markers ${FILES.markers}, scenarios ${FILES.scenarios}).`,
226
322
  );
227
323
 
228
- const fields: JsonObject = { ...block, html: html!, frames: frames! };
324
+ const fields: JsonObject & { type: DraftType } =
325
+ markers === undefined
326
+ ? { ...block, type: "animation", html: html!, frames: frames! }
327
+ : { ...block, type: "clip", html: html!, markers };
229
328
 
230
329
  if (scenarios !== undefined) fields.scenarios = scenarios;
231
330
 
232
331
  validate(fields);
233
332
 
333
+ return fields;
334
+ }
335
+
336
+ /** The animation a folder holds, as a block's fields, and where it came from. */
337
+ async function readDraft(directory: string) {
338
+ const [fields, checkout] = await Promise.all([
339
+ readAnimationFolder(directory),
340
+ readJson(directory, FILES.checkout, false),
341
+ ]);
342
+
234
343
  const parsed = checkoutSchema.safeParse(checkout);
235
344
 
236
345
  return { fields, checkout: parsed.success ? parsed.data : undefined };
@@ -246,7 +355,7 @@ function located(issuePath: PropertyKey[]): string {
246
355
 
247
356
  if (field === "html") return FILES.html;
248
357
 
249
- if (field === "frames" || field === "scenarios")
358
+ if (field === "frames" || field === "markers" || field === "scenarios")
250
359
  return `${FILES[field]} ${at.replace(/^\./, "") || "(top level)"}`;
251
360
 
252
361
  return `${FILES.block} ${field ?? ""}${at}`;
@@ -254,13 +363,9 @@ function located(issuePath: PropertyKey[]): string {
254
363
 
255
364
  /** Checks the files before anything is sent, so an issue names its file. The
256
365
  * host checks pins: agents write them without the repository it fills in. */
257
- function validate(fields: JsonObject) {
366
+ function validate(fields: JsonObject & { type: DraftType }) {
258
367
  const { pins: _pins, ...rest } = fields;
259
-
260
- const parsed = blocks.animation.schema.safeParse({
261
- ...rest,
262
- type: "animation",
263
- });
368
+ const parsed = blocks[fields.type].schema.safeParse(rest);
264
369
 
265
370
  if (!parsed.success)
266
371
  throw new ReviewInputError(
@@ -274,8 +379,8 @@ function validate(fields: JsonObject) {
274
379
 
275
380
  /**
276
381
  * Turns an edit that names a draft folder into the edit the host takes: the
277
- * folder's animation as an insert's or replace's content, or as an update's
278
- * changed fields. An update from a checkout is refused once the block has
382
+ * folder's animation or clip as an insert's or replace's content, or as an
383
+ * update's changed fields. An update from a checkout is refused once the block has
279
384
  * changed since. Returns `unchanged` when the files match the block.
280
385
  */
281
386
  export async function resolveDraftEdit(
@@ -295,10 +400,7 @@ export async function resolveDraftEdit(
295
400
  const { fields, checkout } = await readDraft(directory);
296
401
 
297
402
  if (rest.type === "insert")
298
- return {
299
- directory,
300
- edit: { ...rest, content: { type: "animation", ...fields } },
301
- };
403
+ return { directory, edit: { ...rest, content: fields } };
302
404
 
303
405
  const targetId = String(rest.targetId);
304
406
  const current = animationBlock(await read(sessionId, targetId), targetId);
@@ -315,16 +417,18 @@ export async function resolveDraftEdit(
315
417
  );
316
418
 
317
419
  if (rest.type === "replace")
318
- return {
319
- directory,
320
- edit: { ...rest, content: { type: "animation", ...fields } },
321
- };
420
+ return { directory, edit: { ...rest, content: fields } };
322
421
 
323
422
  if (rest.type !== "update")
324
423
  throw new ReviewInputError(
325
424
  "fromFiles publishes an insert, replace or update.",
326
425
  );
327
426
 
427
+ if (current.type !== fields.type)
428
+ throw new ReviewInputError(
429
+ `${directory} holds ${fields.type === "clip" ? "a clip" : "an animation"} but ${targetId} is ${current.type === "clip" ? "a clip" : "an animation"}. Publish it with type "replace" to change its kind.`,
430
+ );
431
+
328
432
  const changes: JsonObject = {};
329
433
 
330
434
  for (const field of FIELDS)