@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
@@ -1,14 +1,14 @@
1
1
  import { m as isStringValue, n as isJsonObject, u as parseJsonText } from "./src-X9phtB2j.js";
2
- import { M as checkReferences, j as blocks, ot as ReviewInputError } from "./src-B2b8cCoI.js";
3
- import { l as boardTitle } from "./src-DLWol7GZ.js";
4
- import "./review-home-paths-LdGS4SpR.js";
5
- import { n as callAuthoringTool, s as ReviewApiError, t as ToolText } from "./agent-client-CMacGbMp.js";
2
+ import { M as checkReferences, j as blocks, ot as ReviewInputError } from "./src-BQDlJXkG.js";
3
+ import { l as boardTitle } from "./src-CrrW7lcY.js";
4
+ import "./review-home-paths-BmZX2qiz.js";
5
+ import { n as callAuthoringTool, s as ReviewApiError, t as ToolText } from "./agent-client-BCEbSPWU.js";
6
6
  import { N as devReviewHome } from "./s3-config-CjcLyFV5.js";
7
- import { t as findReviewPackageRoot } from "./package-paths-Cg6fnaGp.js";
8
- import { m as isMissingFileError } from "./thread-state-B7L1mpN_.js";
7
+ import { t as findReviewPackageRoot } from "./package-paths-Dk7MOXxN.js";
8
+ import { m as isMissingFileError } from "./thread-state-WTqSborE.js";
9
9
  import { createHash } from "node:crypto";
10
10
  import { z } from "zod";
11
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
11
+ import { chmod, cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
12
12
  import path from "node:path";
13
13
  //#region src/animation-stage-kit.ts
14
14
  /** Every --wb-<name> the stage sets, by group, with what it is for. The
@@ -31,7 +31,9 @@ const STAGE_VARIABLES = [
31
31
  "modified",
32
32
  "added-bg",
33
33
  "removed-bg",
34
- "modified-bg"
34
+ "modified-bg",
35
+ "teal",
36
+ "violet"
35
37
  ]],
36
38
  ["Faces, by role: sans for labels and controls, mono for ids, paths and values, serif for prose, code for source text. Name no font yourself", [
37
39
  "font-sans",
@@ -126,7 +128,7 @@ const CLASSES = [
126
128
  [".wb-grid", "A grid of equal columns; set style=\"--wb-columns: 4\" (default 3)."],
127
129
  [".wb-layers", "Children stacked in one place, for states that replace each other."],
128
130
  [".wb-lane", "A position: relative track with a dashed center line, for a static token snapshot."],
129
- [".wb-card", "A box with a title and note: put .wb-card-title and .wb-card-note inside. The scene's cards are these."],
131
+ [".wb-card", "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."],
130
132
  [".wb-node", "A centered box for a hand-made layout; a <small> inside is its second line."],
131
133
  [".wb-well", "An inset area that groups parts, such as a process or a machine boundary."],
132
134
  [".wb-rule", "A divider (<hr class=\"wb-rule\">)."],
@@ -138,7 +140,10 @@ const CLASSES = [
138
140
  [".wb-prose", "A sentence of explanation, in the board's reading face."],
139
141
  [".wb-mono, .wb-code", "Ids, paths and values in the mono face; .wb-code is inline code on a well."],
140
142
  [".wb-muted, .wb-faint", "Text that recedes."],
141
- [".wb-shape, .wb-shape-text", "An SVG shape drawn like a card, and the text inside it."]
143
+ [".wb-shape, .wb-shape-text", "An SVG shape drawn like a card, and the text inside it."],
144
+ [".wb-group", "A washed box behind a scene's cards with a .wb-group-title; scene.group draws one."],
145
+ [".wb-legend", "A row of .wb-legend-entry glyphs and labels under a scene; scene.legend draws one."],
146
+ ["[data-wb-paint]", "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."]
142
147
  ];
143
148
  /** The CSS every stage loads before its own markup. */
144
149
  const STAGE_KIT_CSS = `:root { --wb-duration: 450ms; color-scheme: light dark; }
@@ -146,6 +151,7 @@ const STAGE_KIT_CSS = `:root { --wb-duration: 450ms; color-scheme: light dark; }
146
151
  button { color: inherit; font: inherit; }
147
152
  html, body { margin: 0; height: 100%; }
148
153
  html[data-wb-auto-size] body { height: auto; display: flow-root; }
154
+ html[data-wb-unpainted] body { visibility: hidden; }
149
155
  body {
150
156
  overflow: hidden;
151
157
  background: transparent;
@@ -200,9 +206,76 @@ body {
200
206
  .wb-node { min-width: 88px; text-align: center; }
201
207
  .wb-node small { display: block; color: var(--wb-ink-faint); font-size: var(--wb-text-micro); }
202
208
  .wb-card-head { display: flex; align-items: center; gap: var(--wb-space-6); }
203
- .wb-card-dot { flex: none; width: 7px; height: 7px; border-radius: var(--wb-radius-round); background: var(--wb-ink-faint); }
209
+ /* The dot says what state a card is in: it shows only with a tone or a
210
+ change mark, in that colour, so a plain card carries no unexplained mark. */
211
+ .wb-card-dot { display: none; flex: none; width: 7px; height: 7px; border-radius: var(--wb-radius-round); background: var(--wb-ink-faint); }
212
+ .wb-card:is([data-tone], [data-change]) .wb-card-dot { display: block; }
204
213
  .wb-card-title { min-width: 0; overflow-wrap: anywhere; font-weight: var(--wb-weight-semibold); }
205
214
  .wb-card-note { color: var(--wb-ink-muted); overflow-wrap: anywhere; font-size: var(--wb-text-micro); min-height: 12px; }
215
+
216
+ /* A store's text sits in the middle of its drum or cylinder, as a label on
217
+ a tank does; a pill's too. */
218
+ :where(.wb-card, .wb-node):is([data-form="cylinder"], [data-form="drum"], [data-form="pill"]) { align-items: center; text-align: center; }
219
+ .wb-card:is([data-form="cylinder"], [data-form="drum"], [data-form="pill"]) :is(.wb-card-head, .wb-items) { justify-content: center; }
220
+ /* Shapes: a card's or node's silhouette says what it stands for before
221
+ its title does. data-form is box (the default), pill, cut (a branch),
222
+ cylinder (a database), drum (a cache), stack (a queue), window (a
223
+ client) or bars (a service of this codebase). data-outline is solid,
224
+ dashed (outside the system) or dotted. data-color is the resting border
225
+ colour: ink, accent, teal or violet; a tone takes over while set. The
226
+ scene's cards take these as options. These rules and the change marks
227
+ weigh one class, as the card rules do: a hand-made layout's own rule for
228
+ its nodes, say position: absolute, comes later and wins. A tone or a
229
+ highlight still takes over. */
230
+ :where(.wb-card, .wb-node)[data-form] { position: relative; }
231
+ :where(.wb-card, .wb-node)[data-form]::before, :where(.wb-card, .wb-node)[data-form]::after { pointer-events: none; }
232
+ :where(.wb-card, .wb-node)[data-form="pill"] { padding-inline: var(--wb-space-16); border-radius: var(--wb-radius-pill); }
233
+ :where(.wb-card, .wb-node)[data-form="cut"] { border-radius: var(--wb-space-12); corner-shape: bevel; }
234
+ :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); }
235
+ :where(.wb-card, .wb-node)[data-form="cylinder"]::before {
236
+ content: ""; position: absolute; inset: -1px -1px auto; height: var(--wb-space-20);
237
+ border: 1px solid; border-color: inherit; border-radius: 50%; background: inherit;
238
+ }
239
+ :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%; }
240
+ :where(.wb-card, .wb-node)[data-form="drum"]::before {
241
+ content: ""; position: absolute; inset: -1px auto -1px -1px; width: var(--wb-space-24);
242
+ border: 1px solid; border-color: inherit; border-radius: 50%; background: inherit;
243
+ }
244
+ :where(.wb-card, .wb-node)[data-form="stack"] {
245
+ margin-right: var(--wb-space-8); margin-bottom: var(--wb-space-8);
246
+ 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);
247
+ }
248
+ :where(.wb-card, .wb-node)[data-form="window"] { padding-top: var(--wb-space-16); }
249
+ :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; }
250
+ :where(.wb-card, .wb-node)[data-form="bars"] { padding-inline: var(--wb-space-18); }
251
+ :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; }
252
+ :where(.wb-card, .wb-node)[data-form="bars"]::before { left: var(--wb-space-6); }
253
+ :where(.wb-card, .wb-node)[data-form="bars"]::after { right: var(--wb-space-6); }
254
+ /* What the change under review did to a part: its dot and a stripe down its
255
+ left edge in the change colour, at rest; a removed part's title is struck.
256
+ A frame's tone still colours the whole card over it. */
257
+ :where(.wb-card, .wb-node)[data-change] { border-left-width: 3px; }
258
+ :where(.wb-card, .wb-node)[data-change="added"] { border-left-color: var(--wb-added); }
259
+ :where(.wb-card, .wb-node)[data-change="modified"] { border-left-color: var(--wb-modified); }
260
+ :where(.wb-card, .wb-node)[data-change="removed"] { border-left-color: var(--wb-removed); }
261
+ .wb-card[data-change="added"] .wb-card-dot { background: var(--wb-added); }
262
+ .wb-card[data-change="modified"] .wb-card-dot { background: var(--wb-modified); }
263
+ .wb-card[data-change="removed"] .wb-card-dot { background: var(--wb-removed); }
264
+ .wb-card[data-change="removed"] .wb-card-title { color: var(--wb-ink-muted); text-decoration: line-through; }
265
+ :where(.wb-card, .wb-node)[data-outline="dashed"] { border-style: dashed; }
266
+ :where(.wb-card, .wb-node)[data-outline="dotted"] { border-style: dotted; }
267
+ :where(.wb-card, .wb-node)[data-color="ink"] { border-color: var(--wb-ink); }
268
+ :where(.wb-card, .wb-node)[data-color="accent"] { border-color: var(--wb-accent); }
269
+ :where(.wb-card, .wb-node)[data-color="teal"] { border-color: var(--wb-teal); }
270
+ :where(.wb-card, .wb-node)[data-color="violet"] { border-color: var(--wb-violet); }
271
+ .wb-card[data-color="ink"] .wb-card-dot { background: var(--wb-ink); }
272
+ .wb-card[data-color="accent"] .wb-card-dot { background: var(--wb-accent); }
273
+ .wb-card[data-color="teal"] .wb-card-dot { background: var(--wb-teal); }
274
+ .wb-card[data-color="violet"] .wb-card-dot { background: var(--wb-violet); }
275
+ .wb-shape[data-color="ink"] { stroke: var(--wb-ink); }
276
+ .wb-shape[data-color="accent"] { stroke: var(--wb-accent); }
277
+ .wb-shape[data-color="teal"] { stroke: var(--wb-teal); }
278
+ .wb-shape[data-color="violet"] { stroke: var(--wb-violet); }
206
279
  .wb-well { padding: var(--wb-space-12); border: 1px solid var(--wb-rule-soft); border-radius: var(--wb-radius-surface); background: var(--wb-well); }
207
280
  .wb-well :is(.wb-item, .wb-chip, .wb-badge, .wb-code) { background: var(--wb-surface); }
208
281
  .wb-rule { margin: var(--wb-space-12) 0; border: 0; border-top: 1px solid var(--wb-rule); }
@@ -266,10 +339,15 @@ body {
266
339
  .wb-shape[data-tone="modified"] { stroke: var(--wb-modified); fill: var(--wb-modified-bg); }
267
340
  .wb-shape[data-tone="muted"] { stroke-dasharray: 4 3; }
268
341
  :is(.wb-card, .wb-node)[data-wb-highlit] { border-color: var(--wb-accent); box-shadow: 0 0 0 3px var(--wb-accent-soft); }
342
+ :is(.wb-card, .wb-node)[data-form="stack"][data-wb-highlit] {
343
+ 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);
344
+ }
269
345
  .wb-shape[data-wb-highlit] { stroke: var(--wb-accent); stroke-width: 2; }
270
346
 
271
347
  /* Connectors and tokens. */
272
- .wb-arrows, .wb-tokens { position: fixed; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
348
+ /* Above the cards, which sit above their groups: an edge's head meets a
349
+ card's border, and a chip stays readable as it passes one. */
350
+ .wb-arrows, .wb-tokens { position: fixed; inset: 0; z-index: 2; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
273
351
  .wb-line {
274
352
  fill: none;
275
353
  stroke: var(--wb-ink-faint);
@@ -288,6 +366,17 @@ body {
288
366
  .wb-arrow[data-tone="removed"] .wb-line { stroke: var(--wb-removed); marker-end: url(#wb-head-removed); }
289
367
  .wb-arrow[data-tone="modified"] .wb-line { stroke: var(--wb-modified); marker-end: url(#wb-head-modified); }
290
368
  .wb-arrow[data-wb-headless] .wb-line { marker-end: none; }
369
+ /* An edge carrying one of the frame's messages takes the marker, label too. */
370
+ .wb-arrow[data-wb-sent] .wb-line { stroke: var(--wb-accent); marker-end: url(#wb-head-accent); }
371
+ .wb-arrow[data-wb-sent] .wb-label { fill: var(--wb-accent); }
372
+ .wb-arrow[data-wb-sent="added"] .wb-line { stroke: var(--wb-added); marker-end: url(#wb-head-added); }
373
+ .wb-arrow[data-wb-sent="added"] .wb-label { fill: var(--wb-added); }
374
+ .wb-arrow[data-wb-sent="removed"] .wb-line { stroke: var(--wb-removed); marker-end: url(#wb-head-removed); }
375
+ .wb-arrow[data-wb-sent="removed"] .wb-label { fill: var(--wb-removed); }
376
+ .wb-arrow[data-wb-sent="modified"] .wb-line { stroke: var(--wb-modified); marker-end: url(#wb-head-modified); }
377
+ .wb-arrow[data-wb-sent="modified"] .wb-label { fill: var(--wb-modified); }
378
+ .wb-arrow[data-wb-sent="muted"] .wb-line { stroke: var(--wb-ink-faint); marker-end: url(#wb-head); }
379
+ .wb-arrow[data-wb-sent="muted"] .wb-label { fill: var(--wb-ink-muted); }
291
380
  .wb-label {
292
381
  fill: var(--wb-ink-muted);
293
382
  font: var(--wb-text-micro) var(--wb-font-mono);
@@ -322,16 +411,53 @@ body {
322
411
  .wb-token[data-labelled][data-tone="muted"] { color: var(--wb-token-muted-ink); }
323
412
 
324
413
  /* The scene. */
325
- .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; }
326
- .wb-scene-compact { grid-template-columns: minmax(0, 1fr) !important; row-gap: var(--wb-space-48); }
327
- .wb-scene-compact > * { grid-column: 1 / -1 !important; grid-row: auto !important; }
328
- .wb-scene-fold { grid-template-columns: repeat(var(--wb-fold), minmax(0, 1fr)) !important; }
329
- .wb-scene-fold > * { grid-column: auto !important; grid-row: auto !important; }
330
- .wb-scene-fold > [data-wb-wide] { grid-column: 1 / -1 !important; }
414
+ .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; }
415
+ /* A card is as wide as what it says, within bounds, and sits mid-column, so
416
+ a wide column does not stretch it into a slab. A table keeps its column;
417
+ a scene's own CSS may set any width. The scene sets its own min-width from
418
+ the columns its cards make, and the block scrolls it sideways when
419
+ narrower. */
420
+ .wb-scene > .wb-card:not([data-kind="table"]) { justify-self: center; width: fit-content; min-width: 140px; max-width: 300px; }
331
421
  .wb-scene > [data-wb-state="before"], .wb-scene > [data-wb-state="after"],
332
422
  .wb-items > [data-wb-state="before"], .wb-items > [data-wb-state="after"],
333
423
  .wb-table tr[data-wb-state="before"], .wb-table tr[data-wb-state="after"] { display: none; }
334
424
  .wb-scene-top { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--wb-space-8); margin-bottom: var(--wb-space-4); }
425
+ /* A group is a washed box behind the cards of its cells, bleeding into the
426
+ gaps around them, titled in its top-left. Cards paint over it. */
427
+ .wb-scene > .wb-card { position: relative; z-index: 1; }
428
+ /* Groups bleed into the gaps around their cells, so a grouped scene opens
429
+ its rows and columns to keep the boxes apart and give edges between
430
+ layers room to be read. The top bleed holds the title clear of the cards
431
+ below it, ring and all. */
432
+ .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); }
433
+ .wb-group {
434
+ position: relative; z-index: 0; align-self: stretch; justify-self: stretch;
435
+ margin: calc(-1 * var(--wb-space-28)) calc(-1 * var(--wb-space-16)) calc(-1 * var(--wb-space-10));
436
+ border: 1px solid var(--wb-rule-soft); border-radius: var(--wb-radius-surface); background: var(--wb-well);
437
+ transition: opacity var(--wb-duration) ease;
438
+ }
439
+ .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; }
440
+ .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); }
441
+ .wb-group[data-color="accent"] .wb-group-title { color: var(--wb-accent); }
442
+ .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); }
443
+ .wb-group[data-color="teal"] .wb-group-title { color: var(--wb-teal); }
444
+ .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); }
445
+ .wb-group[data-color="violet"] .wb-group-title { color: var(--wb-violet); }
446
+ .wb-group[data-color="ink"] { border-color: var(--wb-rule); }
447
+ .wb-group[data-color="ink"] .wb-group-title { color: var(--wb-ink); }
448
+ /* The legend: a card-like glyph per entry, drawn with the card rules and
449
+ scaled to 0.4 inside a box of the scaled size, and what it stands for.
450
+ A transform, not zoom: Electron's Chromium reports a zoomed element's
451
+ rect in unzoomed units, which made the stage measure itself 2.5x tall. */
452
+ .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; }
453
+ .wb-legend-entry { display: inline-flex; align-items: center; gap: var(--wb-space-6); white-space: nowrap; }
454
+ .wb-legend-box { position: relative; flex: none; width: 20px; height: 13px; }
455
+ .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; }
456
+ .wb-card.wb-legend-glyph[data-form="cylinder"] { height: 44px; }
457
+ /* Lines that scale to a pixel: the glyph's borders, its lid and rule marks,
458
+ and its change stripe are drawn 2.5x heavier before the 0.4 scale. */
459
+ .wb-card.wb-legend-glyph, .wb-card.wb-legend-glyph::before, .wb-card.wb-legend-glyph::after { border-width: 2.5px; }
460
+ .wb-card.wb-legend-glyph[data-change] { border-left-width: 7px; }
335
461
  :root[data-wb-instant] *, :root[data-wb-instant] *::before, :root[data-wb-instant] *::after {
336
462
  transition: none !important;
337
463
  animation-duration: 0s !important;
@@ -348,7 +474,8 @@ function stageStyleReference() {
348
474
  * animation.html only for what they do not cover, written with the variables:
349
475
  * no literal colors, sizes or font names. data-tone="accent|added|removed|
350
476
  * modified|muted" colors any card, node, chip, badge, shape or table row;
351
- * data-highlight="frameKey ..." rings a card, node or shape in those frames.
477
+ * data-highlight="frameKey ..." rings a card, node or shape in those frames
478
+ * (in a clip, those markers).
352
479
  *
353
480
  * Classes:
354
481
  ${CLASSES.map(([selector, use]) => ` * ${selector.padEnd(26)} ${use}`).join("\n")}
@@ -363,10 +490,62 @@ ${STAGE_KIT_CSS}
363
490
  `;
364
491
  }
365
492
  //#endregion
493
+ //#region src/review-api/animation-references.ts
494
+ /** The README's H1 and the paragraph right after it. */
495
+ function readmeHeading(file, text) {
496
+ const lines = text.split(/\r?\n/);
497
+ const start = lines.findIndex((line) => line.trim());
498
+ const title = lines[start]?.match(/^# (.+)$/)?.[1]?.trim();
499
+ const rest = lines.slice(start + 1);
500
+ const from = rest.findIndex((line) => line.trim());
501
+ const paragraph = [];
502
+ for (const line of from < 0 ? [] : rest.slice(from)) {
503
+ if (!line.trim() || line.startsWith("#")) break;
504
+ paragraph.push(line.trim());
505
+ }
506
+ const summary = paragraph.join(" ");
507
+ if (!title || !summary) throw new ReviewInputError(`${file} must open with "# Title" followed by a one-sentence summary paragraph.`);
508
+ return {
509
+ title,
510
+ summary
511
+ };
512
+ }
513
+ /** Every reference animation, sorted by name, each checked as a publish would
514
+ * be. Strict (the default) throws on a broken reference, for the tests that
515
+ * keep them valid; otherwise a broken one is left out, so an agent's checkout
516
+ * and instructions never fail on a reference. */
517
+ async function readAnimationReferences(root = findReviewPackageRoot(import.meta.url), { strict = true } = {}) {
518
+ const parent = path.join(root, "instructions", "animations");
519
+ let entries;
520
+ try {
521
+ entries = await readdir(parent, { withFileTypes: true });
522
+ } catch (error) {
523
+ if (isMissingFileError(error)) return [];
524
+ throw error;
525
+ }
526
+ const names = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
527
+ return (await Promise.all(names.map(async (name) => {
528
+ const directory = path.join(parent, name);
529
+ const readme = path.join(directory, "README.md");
530
+ try {
531
+ await readAnimationFolder(directory);
532
+ return {
533
+ name,
534
+ directory,
535
+ ...readmeHeading(readme, await readFile(readme, "utf8"))
536
+ };
537
+ } catch (error) {
538
+ if (!strict) return void 0;
539
+ throw new ReviewInputError(`Reference animation ${name}: ${error instanceof Error ? error.message : String(error)}`);
540
+ }
541
+ }))).filter((reference) => reference !== void 0);
542
+ }
543
+ //#endregion
366
544
  //#region src/review-api/animation-drafts.ts
367
545
  const FILES = {
368
546
  html: "animation.html",
369
547
  frames: "frames.json",
548
+ markers: "markers.json",
370
549
  scenarios: "scenarios.json",
371
550
  block: "block.json",
372
551
  checkout: "checkout.json"
@@ -378,10 +557,11 @@ const BLOCK_FIELDS = [
378
557
  "height",
379
558
  "pins"
380
559
  ];
381
- /** Every field an animation's files can set. */
560
+ /** Every field an animation's or clip's files can set. */
382
561
  const FIELDS = [
383
562
  "html",
384
563
  "frames",
564
+ "markers",
385
565
  "scenarios",
386
566
  ...BLOCK_FIELDS
387
567
  ];
@@ -396,21 +576,60 @@ async function writeReadOnly(file, content) {
396
576
  await rm(file, { force: true });
397
577
  await writeFile(file, content, { mode: 292 });
398
578
  }
399
- async function writeStageReference() {
579
+ /** Copies `source` to `target` with every file read-only, replacing what was there. */
580
+ async function copyReadOnly(source, target) {
581
+ await rm(target, {
582
+ recursive: true,
583
+ force: true
584
+ });
585
+ await cp(source, target, { recursive: true });
586
+ for (const entry of await readdir(target, {
587
+ recursive: true,
588
+ withFileTypes: true
589
+ })) if (entry.isFile()) await chmod(path.join(entry.parentPath, entry.name), 292);
590
+ }
591
+ async function writeStageReference(root = findReviewPackageRoot(import.meta.url)) {
400
592
  const directory = path.join(devReviewHome(), "animation-drafts");
401
593
  const styles = path.join(directory, "whiteboard.css");
402
594
  const api = path.join(directory, "whiteboard.d.ts");
403
- const source = path.join(findReviewPackageRoot(import.meta.url), "instructions", "whiteboard.d.ts");
404
- await mkdir(directory, { recursive: true });
595
+ const copies = path.join(directory, "references");
596
+ const sources = await readAnimationReferences(root, { strict: false });
597
+ await mkdir(copies, { recursive: true });
405
598
  await writeReadOnly(styles, stageStyleReference());
406
- await writeReadOnly(api, await readFile(source, "utf8"));
599
+ await writeReadOnly(api, await readFile(path.join(root, "instructions", "whiteboard.d.ts"), "utf8"));
600
+ const names = new Set(sources.map(({ name }) => name));
601
+ for (const stale of await readdir(copies)) if (!names.has(stale)) await rm(path.join(copies, stale), {
602
+ recursive: true,
603
+ force: true
604
+ });
407
605
  return {
408
606
  styles,
409
- api
607
+ api,
608
+ references: await Promise.all(sources.map(async (reference) => {
609
+ const copy = path.join(copies, reference.name);
610
+ await copyReadOnly(reference.directory, copy);
611
+ return {
612
+ ...reference,
613
+ directory: copy
614
+ };
615
+ }))
410
616
  };
411
617
  }
412
618
  /** The line that sends an agent to the references before it writes an animation. */
413
- const stageReferenceHint = ({ styles, api }) => `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.`;
619
+ const stageReferenceHint = ({ styles, api, references }) => `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.`;
620
+ /** Lists the reference animations where the agent can read them. */
621
+ function referencesSection({ references }) {
622
+ if (!references.length) return [];
623
+ return [[
624
+ "## References",
625
+ "",
626
+ "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.",
627
+ "",
628
+ ...references.map(({ name, summary, directory }) => `- \`${name}\`: ${summary} ${directory}`)
629
+ ].join("\n")];
630
+ }
631
+ /** What follows the animation instructions: the hint, then the references. */
632
+ const stageReferenceText = (reference) => [stageReferenceHint(reference), ...referencesSection(reference)].join("\n\n");
414
633
  function draftDirectory(sessionId, targetId, dir) {
415
634
  if (dir) return path.resolve(dir);
416
635
  if (!handle.test(sessionId) || !handle.test(targetId)) throw new ReviewInputError("sessionId and targetId must be IDs.");
@@ -419,7 +638,7 @@ function draftDirectory(sessionId, targetId, dir) {
419
638
  const fingerprint = (block) => createHash("sha256").update(JSON.stringify(block)).digest("hex");
420
639
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
421
640
  function animationBlock(block, targetId) {
422
- if (!isJsonObject(block) || block.type !== "animation") throw new ReviewInputError(`${targetId} is ${isJsonObject(block) ? `a ${String(block.type)}` : "not a component"}; only animations check out as files.`);
641
+ if (!isJsonObject(block) || block.type !== "animation" && block.type !== "clip") throw new ReviewInputError(`${targetId} is ${isJsonObject(block) ? `a ${String(block.type)}` : "not a component"}; only animations and clips check out as files.`);
423
642
  return block;
424
643
  }
425
644
  /** Writes the block's files, replacing any earlier draft in the folder. */
@@ -434,7 +653,8 @@ async function checkoutAnimation(read, sessionId, targetId, dir) {
434
653
  await writeFile(files[key], text);
435
654
  };
436
655
  await put("html", String(block.html));
437
- await put("frames", json(block.frames ?? []));
656
+ if (block.type === "clip") await put("markers", json(block.markers ?? []));
657
+ else await put("frames", json(block.frames ?? []));
438
658
  if (block.scenarios !== void 0) await put("scenarios", json(block.scenarios));
439
659
  await put("block", json(Object.fromEntries(Object.entries(block).filter(([key]) => BLOCK_FIELDS.includes(key)))));
440
660
  await writeCheckout(directory, {
@@ -446,7 +666,8 @@ async function checkoutAnimation(read, sessionId, targetId, dir) {
446
666
  return {
447
667
  directory,
448
668
  files,
449
- ...reference,
669
+ styles: reference.styles,
670
+ api: reference.api,
450
671
  next: `Edit these files with your file tools, then publish the folder: session_edit {sessionId, edit: {type: "update", targetId: "${targetId}", fromFiles: "${directory}"}}. ${stageReferenceHint(reference)}`
451
672
  };
452
673
  }
@@ -458,7 +679,7 @@ async function readText(directory, name, required) {
458
679
  return await readFile(path.join(directory, name), "utf8");
459
680
  } catch (error) {
460
681
  if (!isMissingFileError(error)) throw error;
461
- if (required) throw new ReviewInputError(`${path.join(directory, name)} does not exist. Check the animation out with session_checkout, or write the file.`);
682
+ if (required) throw new ReviewInputError(`${path.join(directory, name)} does not exist. Check the animation or clip out with session_checkout, or write the file.`);
462
683
  return;
463
684
  }
464
685
  }
@@ -471,25 +692,40 @@ async function readJson(directory, name, required) {
471
692
  throw new ReviewInputError(`${name}: ${error instanceof Error ? error.message : String(error)}`);
472
693
  }
473
694
  }
474
- /** The animation a folder holds, as a block's fields, and where it came from. */
475
- async function readDraft(directory) {
476
- const [html, frames, scenarios, block, checkout] = await Promise.all([
695
+ /** The animation or clip a folder holds, as a block's fields with its type,
696
+ * checked as a publish would be. frames.json makes it an animation and
697
+ * markers.json a clip. */
698
+ async function readAnimationFolder(directory) {
699
+ const [html, frames, markers, scenarios, block] = await Promise.all([
477
700
  readText(directory, FILES.html, true),
478
- readJson(directory, FILES.frames, true),
701
+ readJson(directory, FILES.frames, false),
702
+ readJson(directory, FILES.markers, false),
479
703
  readJson(directory, FILES.scenarios, false),
480
- readJson(directory, FILES.block, true),
481
- readJson(directory, FILES.checkout, false)
704
+ readJson(directory, FILES.block, true)
482
705
  ]);
706
+ if (frames !== void 0 && markers !== void 0) throw new ReviewInputError(`${directory} holds both ${FILES.frames} and ${FILES.markers}. Keep ${FILES.frames} for an animation or ${FILES.markers} for a clip, not both.`);
707
+ if (frames === void 0 && markers === void 0) throw new ReviewInputError(`${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.`);
483
708
  if (!isJsonObject(block)) throw new ReviewInputError(`${FILES.block} must hold a JSON object.`);
484
709
  const stray = Object.keys(block).filter((key) => !BLOCK_FIELDS.includes(key));
485
- if (stray.length) throw new ReviewInputError(`${FILES.block} holds only ${BLOCK_FIELDS.join(", ")}; move ${stray.join(", ")} out (html is ${FILES.html}, frames ${FILES.frames}, scenarios ${FILES.scenarios}).`);
486
- const fields = {
710
+ if (stray.length) throw new ReviewInputError(`${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}).`);
711
+ const fields = markers === void 0 ? {
487
712
  ...block,
713
+ type: "animation",
488
714
  html,
489
715
  frames
716
+ } : {
717
+ ...block,
718
+ type: "clip",
719
+ html,
720
+ markers
490
721
  };
491
722
  if (scenarios !== void 0) fields.scenarios = scenarios;
492
723
  validate(fields);
724
+ return fields;
725
+ }
726
+ /** The animation a folder holds, as a block's fields, and where it came from. */
727
+ async function readDraft(directory) {
728
+ const [fields, checkout] = await Promise.all([readAnimationFolder(directory), readJson(directory, FILES.checkout, false)]);
493
729
  const parsed = checkoutSchema.safeParse(checkout);
494
730
  return {
495
731
  fields,
@@ -501,24 +737,21 @@ function located(issuePath) {
501
737
  const [field, ...rest] = issuePath.map(String);
502
738
  const at = rest.map((part) => /^\d+$/.test(part) ? `[${part}]` : `.${part}`).join("");
503
739
  if (field === "html") return FILES.html;
504
- if (field === "frames" || field === "scenarios") return `${FILES[field]} ${at.replace(/^\./, "") || "(top level)"}`;
740
+ if (field === "frames" || field === "markers" || field === "scenarios") return `${FILES[field]} ${at.replace(/^\./, "") || "(top level)"}`;
505
741
  return `${FILES.block} ${field ?? ""}${at}`;
506
742
  }
507
743
  /** Checks the files before anything is sent, so an issue names its file. The
508
744
  * host checks pins: agents write them without the repository it fills in. */
509
745
  function validate(fields) {
510
746
  const { pins: _pins, ...rest } = fields;
511
- const parsed = blocks.animation.schema.safeParse({
512
- ...rest,
513
- type: "animation"
514
- });
747
+ const parsed = blocks[fields.type].schema.safeParse(rest);
515
748
  if (!parsed.success) throw new ReviewInputError(parsed.error.issues.map((issue) => `${located(issue.path)}: ${issue.message}`).join("\n"));
516
749
  checkReferences([parsed.data]);
517
750
  }
518
751
  /**
519
752
  * Turns an edit that names a draft folder into the edit the host takes: the
520
- * folder's animation as an insert's or replace's content, or as an update's
521
- * changed fields. An update from a checkout is refused once the block has
753
+ * folder's animation or clip as an insert's or replace's content, or as an
754
+ * update's changed fields. An update from a checkout is refused once the block has
522
755
  * changed since. Returns `unchanged` when the files match the block.
523
756
  */
524
757
  async function resolveDraftEdit(read, sessionId, edit) {
@@ -531,10 +764,7 @@ async function resolveDraftEdit(read, sessionId, edit) {
531
764
  directory,
532
765
  edit: {
533
766
  ...rest,
534
- content: {
535
- type: "animation",
536
- ...fields
537
- }
767
+ content: fields
538
768
  }
539
769
  };
540
770
  const targetId = String(rest.targetId);
@@ -544,13 +774,11 @@ async function resolveDraftEdit(read, sessionId, edit) {
544
774
  directory,
545
775
  edit: {
546
776
  ...rest,
547
- content: {
548
- type: "animation",
549
- ...fields
550
- }
777
+ content: fields
551
778
  }
552
779
  };
553
780
  if (rest.type !== "update") throw new ReviewInputError("fromFiles publishes an insert, replace or update.");
781
+ if (current.type !== fields.type) throw new ReviewInputError(`${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.`);
554
782
  const changes = {};
555
783
  for (const field of FIELDS) if (JSON.stringify(fields[field]) !== JSON.stringify(current[field])) changes[field] = fields[field] ?? null;
556
784
  return Object.keys(changes).length ? {
@@ -634,7 +862,7 @@ async function callPublicTool(client, tool, input, signal) {
634
862
  };
635
863
  if (tool.name === "session_get_instructions" && rest.topic === "animation") {
636
864
  const instructions = await callAuthoringTool(client, tool, fields, signal);
637
- const hint = stageReferenceHint(await writeStageReference());
865
+ const hint = stageReferenceText(await writeStageReference());
638
866
  if (instructions instanceof ToolText) return new ToolText(`${instructions.text}\n\n${hint}`);
639
867
  return isStringValue(instructions) ? `${instructions}\n\n${hint}` : instructions;
640
868
  }
@@ -649,9 +877,45 @@ async function callPublicTool(client, tool, input, signal) {
649
877
  const result = await callAuthoringTool(client, tool, fields, signal);
650
878
  if (result instanceof ToolText) return result;
651
879
  const published = withoutRepositoryIds(publicResult(result));
880
+ if (tool.name === "session_render" && isStringValue(sessionId) && isStringValue(rest.targetId)) return saveRenderedFrame(published, sessionId, rest.targetId);
652
881
  if (draft && isStringValue(sessionId) && isJsonObject(published) && isStringValue(published.targetId)) await recordPublish(read, sessionId, published.targetId, draft.directory).catch(() => {});
653
882
  return published;
654
883
  }
884
+ /** A rendered frame's PNG, for an agent that reads images from a tool
885
+ * result, and the rest of the result for its text. */
886
+ function renderedImage(tool, result) {
887
+ if (tool.name !== "session_render" || result instanceof ToolText || !isJsonObject(result) || !isStringValue(result.png)) return { result };
888
+ const { png, ...rest } = result;
889
+ return {
890
+ image: {
891
+ data: png,
892
+ mimeType: "image/png"
893
+ },
894
+ result: rest
895
+ };
896
+ }
897
+ /** Writes the frame's PNG where the agent can open it with its own tools,
898
+ * beside the animation drafts, and names the file in the result. A folder
899
+ * that cannot be written leaves the image in the result alone. */
900
+ async function saveRenderedFrame(result, sessionId, targetId) {
901
+ if (!isJsonObject(result) || !isStringValue(result.png)) return result;
902
+ const key = isJsonObject(result.frame) && isStringValue(result.frame.key) ? result.frame.key : "frame";
903
+ const file = path.join(devReviewHome(), "animation-renders", ...[
904
+ sessionId,
905
+ targetId,
906
+ `${key}.png`
907
+ ].map((part) => part.replace(/[^\w.-]/g, "_")));
908
+ try {
909
+ await mkdir(path.dirname(file), { recursive: true });
910
+ await writeFile(file, Buffer.from(result.png, "base64"));
911
+ } catch {
912
+ return result;
913
+ }
914
+ return {
915
+ ...result,
916
+ file
917
+ };
918
+ }
655
919
  /** The read a draft compares and records against: session_get's JSON. */
656
920
  const INSPECT = {
657
921
  name: "review_get",
@@ -712,4 +976,4 @@ function toolFailure(error, phase) {
712
976
  };
713
977
  }
714
978
  //#endregion
715
- export { publicTool as i, RECOVERY as n, callPublicTool as r, toolFailure as t };
979
+ export { renderedImage as a, publicTool as i, RECOVERY as n, callPublicTool as r, toolFailure as t };