@1agh/maude 0.58.2 → 0.59.0

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 (140) hide show
  1. package/apps/studio/annotations-bindings.ts +83 -4
  2. package/apps/studio/annotations-layer.tsx +49 -15
  3. package/apps/studio/api.ts +6 -1
  4. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  5. package/apps/studio/bin/_import-asset.mjs +90 -0
  6. package/apps/studio/bin/_import-figma.mjs +1775 -0
  7. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  8. package/apps/studio/bin/_perf-probe.mjs +228 -0
  9. package/apps/studio/bin/_perf-shared.mjs +345 -0
  10. package/apps/studio/bin/_video-playwright.mjs +103 -7
  11. package/apps/studio/bin/import-figma.sh +47 -0
  12. package/apps/studio/bin/perf.sh +228 -0
  13. package/apps/studio/bin/read-annotations.mjs +11 -1
  14. package/apps/studio/bin/smoke.sh +49 -5
  15. package/apps/studio/bun.lock +16 -22
  16. package/apps/studio/canvas-edit.ts +29 -5
  17. package/apps/studio/canvas-lib.tsx +148 -6
  18. package/apps/studio/client/app.jsx +196 -38
  19. package/apps/studio/client/export-center.jsx +42 -4
  20. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  21. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  22. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  23. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  24. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  25. package/apps/studio/client/panels/SyncPanel.jsx +229 -0
  26. package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
  27. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  28. package/apps/studio/client/panels/timeline-parse.js +3 -3
  29. package/apps/studio/client/styles/3-shell-maude.css +37 -0
  30. package/apps/studio/client/styles/4-components.css +134 -0
  31. package/apps/studio/clip-ops.ts +93 -17
  32. package/apps/studio/cloud/endpoints.ts +78 -10
  33. package/apps/studio/cloud/renew.ts +183 -0
  34. package/apps/studio/context.ts +2 -1
  35. package/apps/studio/dist/client.bundle.js +1231 -1231
  36. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  37. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  38. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  39. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  40. package/apps/studio/dist/runtime/remotion.js +12 -12
  41. package/apps/studio/dist/styles.css +1 -1
  42. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  43. package/apps/studio/exporters/_runtime.ts +19 -0
  44. package/apps/studio/exporters/degraded.ts +92 -0
  45. package/apps/studio/exporters/index.ts +5 -0
  46. package/apps/studio/exporters/jobs.ts +19 -0
  47. package/apps/studio/exporters/unsupported-media.ts +170 -0
  48. package/apps/studio/exporters/video-encode-lib.ts +35 -6
  49. package/apps/studio/exporters/video-render-lib.ts +6 -0
  50. package/apps/studio/exporters/video.ts +72 -1
  51. package/apps/studio/figma/assets.test.ts +464 -0
  52. package/apps/studio/figma/assets.ts +452 -0
  53. package/apps/studio/figma/client.test.ts +395 -0
  54. package/apps/studio/figma/client.ts +513 -0
  55. package/apps/studio/figma/codegen-client.test.ts +276 -0
  56. package/apps/studio/figma/codegen-client.ts +509 -0
  57. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  58. package/apps/studio/figma/codegen-fonts.ts +195 -0
  59. package/apps/studio/figma/codegen-values.test.ts +179 -0
  60. package/apps/studio/figma/codegen-values.ts +270 -0
  61. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  62. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  63. package/apps/studio/figma/endpoints.ts +273 -0
  64. package/apps/studio/figma/fig-decode.test.ts +702 -0
  65. package/apps/studio/figma/fig-decode.ts +617 -0
  66. package/apps/studio/figma/fig-kiwi.ts +410 -0
  67. package/apps/studio/figma/fig-zip.ts +270 -0
  68. package/apps/studio/figma/from-codegen.test.ts +408 -0
  69. package/apps/studio/figma/from-codegen.ts +1103 -0
  70. package/apps/studio/figma/sanitize.test.ts +325 -0
  71. package/apps/studio/figma/sanitize.ts +407 -0
  72. package/apps/studio/figma/style-map.ts +352 -0
  73. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  74. package/apps/studio/figma/tailwind-map.ts +545 -0
  75. package/apps/studio/figma/to-artboard.test.ts +808 -0
  76. package/apps/studio/figma/to-artboard.ts +701 -0
  77. package/apps/studio/figma/to-render.test.ts +180 -0
  78. package/apps/studio/figma/to-render.ts +328 -0
  79. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  80. package/apps/studio/figma/to-strokes.test.ts +705 -0
  81. package/apps/studio/figma/to-strokes.ts +749 -0
  82. package/apps/studio/figma/to-tokens.test.ts +321 -0
  83. package/apps/studio/figma/to-tokens.ts +305 -0
  84. package/apps/studio/figma/types.ts +544 -0
  85. package/apps/studio/figma/url.test.ts +167 -0
  86. package/apps/studio/figma/url.ts +160 -0
  87. package/apps/studio/http.ts +176 -0
  88. package/apps/studio/sync/asset-push.ts +432 -0
  89. package/apps/studio/sync/connection-state.ts +82 -3
  90. package/apps/studio/sync/hub-link.ts +63 -7
  91. package/apps/studio/sync/hubs-config.ts +31 -3
  92. package/apps/studio/sync/index.ts +286 -27
  93. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  94. package/apps/studio/sync/presentation.ts +45 -1
  95. package/apps/studio/sync/status.ts +18 -0
  96. package/apps/studio/sync/supervisor.ts +5 -1
  97. package/apps/studio/sync/workspace-signin.ts +7 -3
  98. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  99. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  100. package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
  101. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  102. package/apps/studio/test/clip-addressing.test.ts +6 -1
  103. package/apps/studio/test/clip-ops.test.ts +5 -1
  104. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  105. package/apps/studio/test/cloud-renew.test.ts +205 -0
  106. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  107. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  108. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  109. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  110. package/apps/studio/test/figma-explode.test.ts +438 -0
  111. package/apps/studio/test/figma-provenance.test.ts +108 -0
  112. package/apps/studio/test/figma-routes.test.ts +294 -0
  113. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  114. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  115. package/apps/studio/test/hub-link.test.ts +11 -0
  116. package/apps/studio/test/import-figma.test.ts +667 -0
  117. package/apps/studio/test/sync-asset-push.test.ts +567 -0
  118. package/apps/studio/test/sync-connection-state.test.ts +79 -0
  119. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  120. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  121. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  122. package/apps/studio/test/sync-path-pull.test.ts +63 -0
  123. package/apps/studio/test/sync-presentation.test.ts +77 -0
  124. package/apps/studio/test/sync-runtime.test.ts +316 -1
  125. package/apps/studio/test/sync-status.test.ts +28 -0
  126. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  127. package/apps/studio/test/video-comp.test.ts +104 -2
  128. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  129. package/apps/studio/test/workspace-containment.test.ts +1 -0
  130. package/apps/studio/use-artboard-drag.tsx +37 -3
  131. package/apps/studio/video-comp.tsx +121 -6
  132. package/apps/studio/whats-new.json +98 -0
  133. package/apps/studio/workspace-mode.ts +4 -0
  134. package/cli/commands/design.mjs +15 -0
  135. package/cli/commands/kg.mjs +8 -1
  136. package/cli/commands/kg.test.mjs +24 -0
  137. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  138. package/cli/lib/figma-import-controls.test.mjs +70 -0
  139. package/package.json +8 -8
  140. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -0,0 +1,63 @@
1
+ // video-encode-lib — frame-clearing contract (feature-fast-video-export Task 6e).
2
+ //
3
+ // `addVideoFrame`/`addGifFrame` run INSIDE the capture Chromium (real
4
+ // OffscreenCanvas + WebCodecs), so the actual pixel behavior needs a live
5
+ // browser — not a `bun:test` concern, mirroring video-render-bridge.test.ts's
6
+ // split between pure routing (tested here at the source level) and live
7
+ // canvas/codec work (manual/agent-browser verification).
8
+ //
9
+ // What IS worth pinning: the DECISION itself. `drawImage` is called with no
10
+ // preceding `clearRect`/`fillRect` reset, so a transparent frame draws OVER
11
+ // whatever the encoder canvas already held — a transparent artboard would
12
+ // ghost the previous frame's pixels through. This was flagged as a "decide
13
+ // deliberately, don't let it drift" item rather than a bug: every capture
14
+ // path today feeds `page.screenshot()` output, which Chromium always
15
+ // composites onto an opaque background (no `omitBackground` capture exists in
16
+ // this codebase), so the ghost case cannot currently occur in practice. If
17
+ // that stops being true — a future capture path starts passing genuinely
18
+ // transparent frames — this test must be touched, which is the point: an
19
+ // accidental `clearRect` add/removal should force a conscious look at this
20
+ // comment, not slip through unnoticed.
21
+
22
+ import { describe, expect, test } from 'bun:test';
23
+ import { readFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+
26
+ const SRC = readFileSync(join(import.meta.dir, '..', 'exporters', 'video-encode-lib.ts'), 'utf8');
27
+
28
+ function body(fnName: string): string {
29
+ const start = SRC.indexOf(`async ${fnName}(`);
30
+ const openBrace = SRC.indexOf('{', start);
31
+ // Cheap brace match — good enough for this file's flat function bodies.
32
+ let depth = 0;
33
+ let end = openBrace;
34
+ for (let i = openBrace; i < SRC.length; i += 1) {
35
+ if (SRC[i] === '{') depth += 1;
36
+ else if (SRC[i] === '}') {
37
+ depth -= 1;
38
+ if (depth === 0) {
39
+ end = i;
40
+ break;
41
+ }
42
+ }
43
+ }
44
+ return SRC.slice(start, end + 1);
45
+ }
46
+
47
+ describe('video-encode-lib — frame-clearing decision (pinned, not a bug)', () => {
48
+ test('addVideoFrame draws without clearing the encoder canvas first', () => {
49
+ const fn = body('addVideoFrame');
50
+ expect(fn).toMatch(
51
+ /ctx\.drawImage\(bmp, 0, 0, vstate\.canvas\.width, vstate\.canvas\.height\)/
52
+ );
53
+ expect(fn).not.toMatch(/clearRect|fillRect/);
54
+ });
55
+
56
+ test('addGifFrame draws without clearing the gif canvas first', () => {
57
+ const fn = body('addGifFrame');
58
+ expect(fn).toMatch(
59
+ /ctx\.drawImage\(bmp, 0, 0, gstate\.canvas\.width, gstate\.canvas\.height\)/
60
+ );
61
+ expect(fn).not.toMatch(/clearRect|fillRect/);
62
+ });
63
+ });
@@ -233,6 +233,7 @@ describe('the vocabulary itself', () => {
233
233
  '/_api/debug-bundle',
234
234
  '/_api/design',
235
235
  '/_api/export',
236
+ '/_api/figma',
236
237
  '/_api/generate',
237
238
  '/_api/github',
238
239
  '/_api/hub',
@@ -268,8 +268,16 @@ export interface UseArtboardDragOptions {
268
268
  rectFor: (id: string) => Rect | null;
269
269
  /** Every artboard's rect in render order. */
270
270
  allRects: Rect[];
271
- /** Live viewport — `null` before first layout. */
271
+ /** Published viewport — `null` before first layout. Settle-cadence. */
272
272
  viewport: { zoom: number } | null;
273
+ /**
274
+ * Current zoom, read imperatively at event time. Supplied by the canvas so
275
+ * this hook never imports canvas-lib (which imports it — that would be a
276
+ * cycle). Load-bearing: `viewport` above only updates when a gesture settles,
277
+ * so a drag started inside the settle window would divide its deltas by the
278
+ * pre-gesture zoom and track the cursor at the wrong speed.
279
+ */
280
+ liveZoom?: () => number;
273
281
  /** False short-circuits all event handling (e.g. activeTool !== "move"). */
274
282
  enabled: boolean;
275
283
  /** Called on drop with final positions for leader + followers. */
@@ -293,6 +301,7 @@ export function useArtboardDrag(opts: UseArtboardDragOptions): UseArtboardDragHa
293
301
  rectFor,
294
302
  allRects,
295
303
  viewport,
304
+ liveZoom,
296
305
  enabled,
297
306
  onCommit,
298
307
  gridSize = DEFAULT_GRID_SIZE,
@@ -304,8 +313,28 @@ export function useArtboardDrag(opts: UseArtboardDragOptions): UseArtboardDragHa
304
313
  // Keep stable refs for callbacks that close over fast-changing inputs.
305
314
  const stateRef = useRef<DragState>(dragState);
306
315
  stateRef.current = dragState;
307
- const zoomRef = useRef<number>(viewport?.zoom ?? 1);
308
- zoomRef.current = viewport?.zoom ?? 1;
316
+ // The zoom a drag divides by must be the CURRENT one. `viewport` here is the
317
+ // published (settle-cadence) value, so mirroring it alone would leave this
318
+ // ref a gesture behind: pan, then grab an artboard within the settle window,
319
+ // and every delta would be divided by the pre-pan zoom — the board would
320
+ // track the cursor at the wrong speed. getLiveViewport() is the imperative
321
+ // mirror the viewport controller writes every frame; the prop is the fallback
322
+ // for mounts that have no controller (bare DS specimens).
323
+ // Stable identity across renders (useMemo, empty deps): recreating the object
324
+ // every render would make it look like a changing dependency to anything that
325
+ // closed over it. It reads through refs, so it never goes stale.
326
+ const liveZoomRef = useRef(liveZoom);
327
+ liveZoomRef.current = liveZoom;
328
+ const viewportRef = useRef(viewport);
329
+ viewportRef.current = viewport;
330
+ const zoomRef = useMemo(
331
+ () => ({
332
+ get current(): number {
333
+ return liveZoomRef.current?.() ?? viewportRef.current?.zoom ?? 1;
334
+ },
335
+ }),
336
+ []
337
+ );
309
338
  const onCommitRef = useRef(onCommit);
310
339
  onCommitRef.current = onCommit;
311
340
  const enabledRef = useRef(enabled);
@@ -392,6 +421,11 @@ export function useArtboardDrag(opts: UseArtboardDragOptions): UseArtboardDragHa
392
421
 
393
422
  // Global pointermove / pointerup while a drag is in flight. Bound at the
394
423
  // window level so a fast drag past any element still feeds the reducer.
424
+ //
425
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `zoomRef` is a live
426
+ // getter (stable identity, reads through refs), not state — the handlers must
427
+ // read the CURRENT zoom at event time. Listing it would re-subscribe these
428
+ // listeners whenever zoom changed, tearing the pointer stream down mid-drag.
395
429
  useEffect(() => {
396
430
  if (dragState.kind === 'idle') return;
397
431
  if (typeof window === 'undefined') return;
@@ -41,6 +41,16 @@ export interface VideoCompMeta {
41
41
  /** A live registration — the Player handle + its meta, keyed by artboard id. */
42
42
  interface CompEntry extends VideoCompMeta {
43
43
  id: string;
44
+ /** The enclosing DCArtboard's id (`data-dc-screen`), resolved by DOM
45
+ * containment at mount. The VideoComp's own `compId` is usually an
46
+ * author-omitted `videocomp-N` mount slug, so this is the ONLY reliable way
47
+ * for the shell to map "the artboard the user is looking at" → "the comp the
48
+ * transport must drive" (rca/issue-timeline-drives-wrong-artboard). Null on a
49
+ * comp mounted outside an artboard (bare preview / test harness). */
50
+ artboardId: string | null;
51
+ /** The artboard's visible label — shown in the Timeline head so the user can
52
+ * see WHICH artboard the rows + transport belong to. */
53
+ artboardLabel: string | null;
44
54
  ref: React.RefObject<PlayerRef | null>;
45
55
  /** The Remotion composition component + its props — carried so the export
46
56
  * capture's renderMediaOnWeb path (DDR-148 addendum, audio export) can
@@ -55,6 +65,9 @@ interface CompEntry extends VideoCompMeta {
55
65
  /** Serializable meta the capture shim + Timeline panel read (no functions). */
56
66
  export interface CompSnapshot extends VideoCompMeta {
57
67
  id: string;
68
+ /** Enclosing DCArtboard id + label — see CompEntry. */
69
+ artboardId: string | null;
70
+ artboardLabel: string | null;
58
71
  }
59
72
 
60
73
  /** Result contract for `__maude_render_video__` — see exporters/video-render-lib.ts. */
@@ -81,7 +94,20 @@ interface MaudeSeekWindow {
81
94
  __maudeVideoComps?: Map<string, CompEntry>;
82
95
  /** Seek every registered comp to `frame` (paused). Ordinary artboards fall
83
96
  * back to a time-based WAAPI seek. Resolves once the frame has painted. */
84
- __maude_seek__?: (frame: number, opts?: { fps?: number }) => Promise<void>;
97
+ __maude_seek__?: (
98
+ frame: number,
99
+ opts?: {
100
+ fps?: number;
101
+ /** Capture mode: throw when a seek never lands, instead of only counting it. */
102
+ strict?: boolean;
103
+ }
104
+ ) => Promise<void>;
105
+ /**
106
+ * Count of seeks that never landed. The capture shim reads this and refuses
107
+ * to ship the export rather than encoding a stale frame — a silent wrong
108
+ * frame is worse than a loud failure.
109
+ */
110
+ __maude_seek_failures__?: number;
85
111
  /** Serializable comp list for the capture shim / Timeline panel. */
86
112
  __maude_comps__?: () => CompSnapshot[];
87
113
  /**
@@ -140,6 +166,36 @@ function afterPaint(): Promise<void> {
140
166
  });
141
167
  }
142
168
 
169
+ /** How many paint-spaced attempts a cold Player gets before a seek is a failure. */
170
+ const SEEK_ATTEMPTS = 3;
171
+
172
+ /**
173
+ * Seek one comp, giving a not-yet-mounted Player a bounded number of chances.
174
+ *
175
+ * Returns whether the seek actually landed. The retry replaces the old silent
176
+ * swallow: a Player that is genuinely mid-mount settles within a paint or two,
177
+ * and one that never settles is a real fault the caller must not paper over.
178
+ */
179
+ async function seekEntryWithRetry(
180
+ ref: { current: { pause?: () => void; seekTo: (f: number) => void } | null },
181
+ frame: number
182
+ ): Promise<boolean> {
183
+ for (let attempt = 0; attempt < SEEK_ATTEMPTS; attempt += 1) {
184
+ try {
185
+ const player = ref.current;
186
+ if (player) {
187
+ player.pause?.();
188
+ player.seekTo(frame);
189
+ return true;
190
+ }
191
+ } catch {
192
+ /* fall through to the retry — the next paint may settle it */
193
+ }
194
+ if (attempt < SEEK_ATTEMPTS - 1) await afterPaint();
195
+ }
196
+ return false;
197
+ }
198
+
143
199
  /**
144
200
  * Install the singleton seek bridge on `window`. Idempotent — the first
145
201
  * VideoComp to mount wins; later mounts reuse the same functions. The bridge
@@ -156,11 +212,32 @@ export function installMaudeSeekBridge(): void {
156
212
  if (entries.length > 0) {
157
213
  for (const e of entries) {
158
214
  const f = clamp(Math.round(frame), 0, Math.max(0, e.durationInFrames - 1));
159
- try {
160
- e.ref.current?.pause();
161
- e.ref.current?.seekTo(f);
162
- } catch {
163
- /* Player not yet ready — a later seek settles it */
215
+ // This used to be a bare try/catch with an empty body and the comment
216
+ // "Player not yet ready — a later seek settles it". That made a seek
217
+ // that COMPLETELY FAILED resolve as success: `afterPaint()` proves a
218
+ // paint happened, not that the right content was in it. Serially it was
219
+ // benign — frame N+1 repaired frame N's miss, and only frame 0 was ever
220
+ // exposed — which is exactly why it survived. It is not benign the
221
+ // moment anything captures ranges in parallel: every range boundary is a
222
+ // first seek on a cold page, so "a later seek settles it" would be
223
+ // invoked once per worker instead of once per export, and a stale frame
224
+ // is a valid-looking file with wrong pixels, not a crash.
225
+ //
226
+ // So the hope becomes an explicit bounded retry, and exhausting it is a
227
+ // real failure the capture shim can see and refuse to ship.
228
+ if (!(await seekEntryWithRetry(e.ref, f))) {
229
+ // ALWAYS recorded, so the capture shim can refuse the export even on
230
+ // the non-strict path. Only THROWN under `strict`, which the shim
231
+ // passes: a live UI scrub of a still-mounting comp should stay
232
+ // resilient, but a capture must never proceed on a seek that did not
233
+ // happen. The old code did neither — it just resolved.
234
+ w.__maude_seek_failures__ = (w.__maude_seek_failures__ ?? 0) + 1;
235
+ if (opts?.strict) {
236
+ throw new Error(
237
+ `__maude_seek__: comp did not accept frame ${f} after ${SEEK_ATTEMPTS} attempts ` +
238
+ '(Player never became ready) — refusing to report a seek that did not happen'
239
+ );
240
+ }
164
241
  }
165
242
  }
166
243
  await afterPaint();
@@ -191,6 +268,8 @@ export function installMaudeSeekBridge(): void {
191
268
  durationInFrames: e.durationInFrames,
192
269
  width: e.width,
193
270
  height: e.height,
271
+ artboardId: e.artboardId ?? null,
272
+ artboardLabel: e.artboardLabel ?? null,
194
273
  }));
195
274
 
196
275
  w.__maude_render_video__ = async (compId, opts) => {
@@ -224,6 +303,35 @@ export function findCompIdIn(container: Element): string | null {
224
303
  return container.querySelector('[data-comp-id]')?.getAttribute('data-comp-id') ?? null;
225
304
  }
226
305
 
306
+ /** The inverse of `findCompIdIn` — walk UP from a mounted comp to the artboard
307
+ * that contains it, and read its id + visible label. Same rationale (the two
308
+ * ids are independent), used at registration time so every announced comp
309
+ * carries the artboard the user actually sees it on. */
310
+ export function findArtboardOf(el: Element | null): {
311
+ artboardId: string | null;
312
+ artboardLabel: string | null;
313
+ } {
314
+ const board = el?.closest?.('[data-dc-screen]') ?? null;
315
+ if (!board) return { artboardId: null, artboardLabel: null };
316
+ // DIRECT children only, and only the two tags canvas-lib actually renders the
317
+ // label as (`<header>` unpositioned / `<button>` positioned). A subtree-wide
318
+ // `querySelector('.dc-artboard-label')` would take the first match in document
319
+ // order anywhere below the artboard — including a hidden node planted ahead of
320
+ // the real header, or one belonging to a nested artboard — and `textContent`
321
+ // reads hidden nodes happily. The label is shell chrome the user reads to know
322
+ // which artboard the transport moves, so it must come from the element
323
+ // canvas-lib owns, not from whatever matches the class first.
324
+ const header = Array.from(board.children).find(
325
+ (c) =>
326
+ (c.tagName === 'HEADER' || c.tagName === 'BUTTON') &&
327
+ c.classList.contains('dc-artboard-label')
328
+ );
329
+ return {
330
+ artboardId: board.getAttribute('data-dc-screen'),
331
+ artboardLabel: header?.textContent?.trim() || null,
332
+ };
333
+ }
334
+
227
335
  // ─────────────────────────────────────────────────────────────────────────────
228
336
  // VideoComp — the canvas-lib wrapper
229
337
 
@@ -297,6 +405,7 @@ export function VideoComp({
297
405
  autoPlay,
298
406
  }: VideoCompProps) {
299
407
  const ref = useRef<PlayerRef | null>(null);
408
+ const hostRef = useRef<HTMLDivElement | null>(null);
300
409
  // Stable id across re-renders; unique per mount when the author omits one.
301
410
  const [autoId] = useState(() => id ?? `videocomp-${++mountCounter}`);
302
411
  const compId = id ?? autoId;
@@ -326,8 +435,13 @@ export function VideoComp({
326
435
  // Register with the seek bridge for the lifetime of this mount.
327
436
  useEffect(() => {
328
437
  installMaudeSeekBridge();
438
+ // Resolved here (not at render) because the wrapper is only in the DOM once
439
+ // the effect runs — that's also when the enclosing DCArtboard exists.
440
+ const { artboardId, artboardLabel } = findArtboardOf(hostRef.current);
329
441
  const entry: CompEntry = {
330
442
  id: compId,
443
+ artboardId,
444
+ artboardLabel,
331
445
  fps,
332
446
  durationInFrames,
333
447
  width,
@@ -443,6 +557,7 @@ export function VideoComp({
443
557
  return (
444
558
  <div
445
559
  className="dc-video-comp"
560
+ ref={hostRef}
446
561
  data-comp-id={compId}
447
562
  style={{ width, height, maxWidth: '100%' }}
448
563
  >
@@ -1,6 +1,104 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "cloud-asset-push-completes",
6
+ "version": "0.59.0",
7
+ "date": "2026-08-12",
8
+ "kind": "fix",
9
+ "title": "Every asset reaches the cloud, in one go",
10
+ "summary": "A project with a lot of images, fonts and video used to trickle a handful of files to the cloud per boot and never catch up. Now the whole set uploads in one pass, and a file the hub refuses is named with the real reason instead of a bare error code.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "figma-explode-artboard",
15
+ "version": "0.59.0",
16
+ "date": "2026-08-12",
17
+ "kind": "feature",
18
+ "title": "Make an imported Figma artboard editable",
19
+ "summary": "A Figma page imports as faithful pictures of your frames. Now you can pick one artboard and turn it into real, editable layout — Maude asks Figma's own Dev Mode for that frame's resolved design and converts it locally, so you get flex boxes and live text instead of an image. Needs the Figma desktop app open on the same file, with Dev Mode on.",
20
+ "surface": "design-ui"
21
+ },
22
+ {
23
+ "id": "figma-import-fidelity-fixes",
24
+ "version": "0.59.0",
25
+ "date": "2026-08-12",
26
+ "kind": "fix",
27
+ "title": "Figma imports keep their fonts and their board",
28
+ "summary": "Three things a real migration surfaced: text in imported frames fell back to a serif when the font wasn't installed, an imported FigJam board landed dark inside screen chrome instead of on white paper, and a slow render check could quietly drop every image in a large import. Imported files now look like the ones you imported.",
29
+ "surface": "design-ui"
30
+ },
31
+ {
32
+ "id": "canvas-perf-benchmark",
33
+ "version": "0.59.0",
34
+ "date": "2026-08-12",
35
+ "kind": "improvement",
36
+ "title": "Measure a canvas's pan/zoom performance",
37
+ "summary": "A new `maude design perf` command drives a scripted pan and zoom over a canvas and reports frame timings plus how much React work the gesture caused — with a delta against your previous run, so a change is either faster or it isn't. It can measure Chrome or Safari (WebKit, the engine the desktop app uses), and `/design:smoke --perf` folds it into a smoke sweep. Under the hood, pan and zoom no longer re-render the canvas tree while you're moving: on a 128-artboard canvas that removed about 90% of the renders a gesture used to trigger.",
38
+ "surface": "design-ui"
39
+ },
40
+ {
41
+ "id": "video-export-degraded-visible",
42
+ "version": "0.59.0",
43
+ "date": "2026-08-12",
44
+ "kind": "fix",
45
+ "title": "A video export tells you when it's missing audio",
46
+ "summary": "If an MP4/WebM export can't get audio — an unsupported media element, or the fast renderer had to fall back to a frame-by-frame capture — the export no longer quietly reports \"done\" with a clean checkmark. The Exports panel now shows a degraded pill and a completion toast explaining exactly what was dropped and how to fix it, and it's recorded in your export history too.",
47
+ "surface": "design-ui"
48
+ },
49
+ {
50
+ "id": "sync-panel-per-file",
51
+ "version": "0.59.0",
52
+ "date": "2026-08-12",
53
+ "kind": "feature",
54
+ "title": "Watch your project sync, file by file",
55
+ "summary": "Click the hub sync chip in the status bar to open the new Sync panel: every canvas with its own live state (syncing, synced, or refused and why), plus your images and fonts as they upload to the cloud. “Is my work actually up there?” now has a per-file answer.",
56
+ "surface": "design-ui",
57
+ "tour": [
58
+ {
59
+ "target": "[data-testid=\"open-sync\"]",
60
+ "title": "Per-file sync progress",
61
+ "body": "The hub sync chip is now a button — click it to see every canvas and asset move to the cloud, and which ones need attention.",
62
+ "placement": "top"
63
+ }
64
+ ]
65
+ },
66
+ {
67
+ "id": "cloud-brand-assets-and-steady-link",
68
+ "version": "0.59.0",
69
+ "date": "2026-08-12",
70
+ "kind": "fix",
71
+ "title": "Brand logos load in the cloud (and a steadier status bar)",
72
+ "summary": "Your design system's own logos, signs, fonts and photos now show up in the cloud copy instead of grey placeholders — the last piece of \"the cloud copy matches your desktop\". And the desktop status bar no longer flickers to \"reconnecting\" when you step away for a minute; an idle link stays live.",
73
+ "surface": "design-ui"
74
+ },
75
+ {
76
+ "id": "cloud-sync-matches-your-desktop",
77
+ "version": "0.58.3",
78
+ "date": "2026-08-11",
79
+ "kind": "fix",
80
+ "title": "The cloud copy matches your desktop",
81
+ "summary": "A project you connect to the cloud now looks exactly like it does on your machine: no duplicated files, every canvas opens instead of breaking, and your images show up instead of grey boxes. The cloud picker says \"Connected\" for the project this folder is linked to, with a Disconnect right there — and once you're connected, the cloud saves every change automatically, so there's one place your work is saved, not two.",
82
+ "surface": "design-ui"
83
+ },
84
+ {
85
+ "id": "cloud-link-self-renews",
86
+ "version": "0.58.3",
87
+ "date": "2026-08-11",
88
+ "kind": "fix",
89
+ "title": "Cloud sync keeps itself signed in",
90
+ "summary": "A workspace you connect to the cloud stays synced — the session refreshes itself in the background instead of quietly expiring after a few hours and stranding your canvases on \"connecting…\". If a link ever can't sync for a few minutes it now says so and points you to reconnect, instead of spinning forever.",
91
+ "surface": "design-ui"
92
+ },
93
+ {
94
+ "id": "figma-import",
95
+ "version": "0.58.3",
96
+ "date": "2026-08-11",
97
+ "kind": "feature",
98
+ "title": "Bring your Figma work in",
99
+ "summary": "Paste a Figma link and get the real thing, not a picture of it: design frames become editable canvases, a FigJam board becomes your whiteboard with its sticky notes and arrows still attached, and your colour and text styles become design tokens. Add a Figma access token once in Settings to get started.",
100
+ "surface": "design-ui"
101
+ },
4
102
  {
5
103
  "id": "sync-carries-the-path",
6
104
  "version": "0.58.2",
@@ -66,6 +66,10 @@ export const FORBIDDEN_ROUTE_PREFIXES: ReadonlyArray<{ prefix: string; why: stri
66
66
  prefix: '/_api/generate',
67
67
  why: 'media generation runs tenant-authored prompts against a provider key held here',
68
68
  },
69
+ {
70
+ prefix: '/_api/figma',
71
+ why: 'the Figma lane STORES and SPENDS a user personal access token, and imports write the design root — a cell must hold no provider credential (DDR-216 D2/D3, same class as /_api/generate)',
72
+ },
69
73
  {
70
74
  prefix: '/_api/shell-shot',
71
75
  why: 'the shell screenshot spawns a headless browser against the studio — the same evaluation `/_api/export` is forbidden for',
@@ -69,6 +69,14 @@ const BIN_VERBS = new Set([
69
69
  // contract, and patches (theme-block-scoped) or scaffolds the target
70
70
  // design system. CLI-only — no HTTP route, no in-app panel.
71
71
  'import-tokens',
72
+ // DDR-216 (feature-figma-import, Phase 2 / T6). `import-figma` is the REST
73
+ // door: pulls a real Figma/FigJam document over the API and translates it
74
+ // with DETERMINISTIC code — no vision model, no agent anywhere in the
75
+ // ingestion path. That is the structural difference from
76
+ // `/design:import --reconstruct` (DDR-174), whose whole orchestrator/agent
77
+ // split exists because an LLM reads its input; do not copy that architecture
78
+ // into this one, it would close nothing here.
79
+ 'import-figma',
72
80
  // DDR-173 (Phase 3 / T12). `import-brand` extracts typed palette/font
73
81
  // cues + a hardened logo asset from an already-DDR-167-sanitized SVG for
74
82
  // `/design:setup-ds --from-brand` — never re-reads or re-sanitizes the
@@ -118,6 +126,13 @@ const BIN_VERBS = new Set([
118
126
  // host — see DDR-185's rejected alternatives). No dev server dependency —
119
127
  // pure Node, no `<designRoot>` involvement.
120
128
  'curl-local',
129
+ // feature-canvas-render-performance (Task 1 + 11). `perf` drives a scripted
130
+ // pan/zoom against a live canvas and reports frame-time percentiles plus the
131
+ // React render count during the gesture, delta'd against the previous run of
132
+ // the same (canvas, engine) pair. It is a benchmark a human reads, never a CI
133
+ // gate — headless frame timings swing with GPU load, so an absolute threshold
134
+ // would go red for reasons unrelated to the code.
135
+ 'perf',
121
136
  // DDR-185 security addendum. `agent-browser-safe` replaces the bare
122
137
  // `Bash(agent-browser:*)` allow-list entry a security review found was a
123
138
  // zero-confirmation session-hijack primitive (agent-browser's own bundled
@@ -406,7 +406,14 @@ async function verbRecordLog(state, args, pkgRoot) {
406
406
  // Temp file + `kg ingest --file`, matching the importer: the same plumbing
407
407
  // that ingested 310 decisions during migration, so no new stdin path to get
408
408
  // wrong (and a verdict body is far past comfortable argv size anyway).
409
- const tmp = join(tmpdir(), `kg-record-${kind}-${built.slug}.json`);
409
+ // `built.slug` is `${scope.repo}/<slug>` under this store's per-repo
410
+ // namespacing convention (scopedSlug) — a bare join() with that slash reads
411
+ // as an implied subdirectory `writeFileSync` never creates, so every call
412
+ // in a repo-namespaced store failed ENOENT before it ever reached `kg
413
+ // ingest`. This is a filesystem staging path, not the graph identity, so
414
+ // sanitizing it here doesn't touch what actually gets recorded.
415
+ const tmpSlug = built.slug.replace(/\//g, '_');
416
+ const tmp = join(tmpdir(), `kg-record-${kind}-${tmpSlug}.json`);
410
417
  writeFileSync(tmp, JSON.stringify({ decisions: [built.decision] }));
411
418
  const status = runKg(state, ['ingest', '--file', tmp], {
412
419
  timeoutMs: 30000,
@@ -116,3 +116,27 @@ test('passthrough strips the maude-owned --root flag from the kg argv', () => {
116
116
  assert.match(r.stdout, /ARGV:context --about x/);
117
117
  assert.doesNotMatch(r.stdout, /--root/);
118
118
  });
119
+
120
+ test('record-log succeeds when the scoped slug contains a slash (repo-namespaced store)', () => {
121
+ // scopedSlug() prefixes the log's slug with `${scope.repo}/` under this
122
+ // store's per-repo namespacing convention, so `built.slug` is e.g.
123
+ // "acme/foo" — a temp filename built by bare string interpolation reads
124
+ // that slash as an implied subdirectory writeFileSync never creates, and
125
+ // every record-log call in a repo-namespaced store failed ENOENT before it
126
+ // ever reached `kg ingest`.
127
+ const root = repoWith({ mode: 'on', scope: { repo: 'acme', dept: 'dev' } });
128
+ const logDir = join(root, '.ai', 'logs', 'rca');
129
+ mkdirSync(logDir, { recursive: true });
130
+ writeFileSync(join(logDir, 'foo.md'), '# RCA: something broke\n\nbody text\n');
131
+ const stub = join(root, 'kg-stub.sh');
132
+ writeFileSync(stub, '#!/usr/bin/env bash\nexit 0\n');
133
+ spawnSync('chmod', ['+x', stub]);
134
+ const r = spawnSync(
135
+ 'node',
136
+ [MAUDE_BIN, 'kg', 'record-log', '--file', '.ai/logs/rca/foo.md', '--root', root],
137
+ { cwd: root, encoding: 'utf8', env: { ...process.env, NO_COLOR: '1', KGAI_BIN: stub } }
138
+ );
139
+ assert.equal(r.status ?? 0, 0, r.stderr);
140
+ assert.doesNotMatch(r.stderr, /ENOENT/);
141
+ assert.match(r.stdout, /\[kg\] recorded rca:acme\/foo/);
142
+ });
@@ -0,0 +1,104 @@
1
+ // DDR-219 D2 regression guard — the codegen channel is a CONTROL, not an intent.
2
+ //
3
+ // The first draft of DDR-219 routed Figma Dev Mode codegen through the REMOTE
4
+ // MCP (`https://mcp.figma.com/mcp`) and treated our own code's inability to
5
+ // reach it as the safety property. The design-stage review took that apart:
6
+ //
7
+ // - the remote catalog "allowlist" is enforced on a self-reported OAuth
8
+ // `client_name`, and a public bypass for it exists — so it is a waitlist,
9
+ // not a boundary;
10
+ // - the LOCAL Dev Mode server (`http://127.0.0.1:3845/mcp`) takes plain
11
+ // loopback HTTP with NO credential and no catalog gate at all, so nothing
12
+ // but intent stands between a future contributor and `fetch(...)` inside
13
+ // `apps/studio/figma/`;
14
+ // - DDR-185's `curl-local` *permits* that endpoint by construction (its rule
15
+ // is "every resolved address is loopback", and 127.0.0.1 is), and an ACP
16
+ // session auto-approves `Bash(maude:*)` — so the one egress control the
17
+ // session trusts is, for this specific service, an allow rule.
18
+ //
19
+ // Reaching the remote endpoint from product code would put a third-party model's
20
+ // output into an ingestion path that DDR-216 D1 says is deterministic end to
21
+ // end. Reaching the local one from anywhere except the single designated client
22
+ // would make codegen a bulk ingestion route by accident — DDR-219 D1's table
23
+ // silently false, its fallback never firing, and nothing in CI noticing.
24
+ //
25
+ // So both halves are asserted here rather than described in a decision record.
26
+
27
+ import assert from 'node:assert/strict';
28
+ import { execSync } from 'node:child_process';
29
+ import { test } from 'node:test';
30
+
31
+ /** The ONE module allowed to name the local Dev Mode endpoint (DDR-219 D2). */
32
+ const CODEGEN_CLIENT = 'apps/studio/figma/codegen-client.ts';
33
+
34
+ /**
35
+ * Tests under the Figma lane may name it too, and the widening is narrow on
36
+ * purpose. `codegen-client.test.ts` asserts the endpoint constant — that IS the
37
+ * control ("the URL is never influenced by input"), and a guard that forbids
38
+ * asserting the thing it guards would push the assertion out of the repo.
39
+ * `from-codegen.test.ts` carries a captured response fixture whose asset
40
+ * constants are loopback URLs, and the property under test is that the converter
41
+ * DISCARDS them — which can only be asserted against a fixture that has them.
42
+ *
43
+ * Neither executes anything against the port. What stays absolutely banned is a
44
+ * second non-test module reaching it, which is the shape that would make codegen
45
+ * a bulk ingestion route by accident.
46
+ */
47
+ const TEST_FILE_RE = /^apps\/studio\/figma\/[a-z-]+\.test\.ts$/;
48
+
49
+ /** Trees that ship or execute. Prose (`.ai/`, `docs/`, `site/`) may name both. */
50
+ const RUNTIME_TREES = ['apps/studio', 'cli', 'plugins'];
51
+
52
+ /** Source only. `node_modules` vendors binaries that contain arbitrary strings
53
+ * (the bundled agent-sdk `claude` matched `mcp.figma.com` on the first run),
54
+ * and `dist/` is build output, not a code path anyone edits. `-I` drops binary
55
+ * files outright so a vendored blob can never turn this guard into noise —
56
+ * a guard that cries wolf gets deleted, which would be worse than not having it. */
57
+ function grep(pattern, trees) {
58
+ // `|| true` because grep exits 1 on no-match, which is the passing case.
59
+ const out = execSync(
60
+ `grep -rlIE --exclude-dir=node_modules --exclude-dir=dist --exclude-dir=.git ${JSON.stringify(pattern)} ${trees.join(' ')} 2>/dev/null || true`,
61
+ { encoding: 'utf8', cwd: new URL('../../', import.meta.url).pathname }
62
+ );
63
+ return out
64
+ .trim()
65
+ .split('\n')
66
+ .filter(Boolean)
67
+ .filter((f) => !f.endsWith('figma-codegen-reachability.test.mjs'));
68
+ }
69
+
70
+ test('no runtime code names the REMOTE Figma MCP endpoint', () => {
71
+ const offenders = grep('mcp\\.figma\\.com', RUNTIME_TREES);
72
+ assert.deepEqual(
73
+ offenders,
74
+ [],
75
+ `These files name the remote Figma MCP endpoint. It is banned from every runtime code path (DDR-219 D2, and \`feature-figma-import\` § Out of scope before it): it is reachable only by a catalog-listed agent client, so any use here means a model is in the ingestion path. Codegen goes over the LOCAL server, with apps/studio as the client:\n ${offenders.join('\n ')}`
76
+ );
77
+ });
78
+
79
+ test('only the designated client names the LOCAL Dev Mode endpoint', () => {
80
+ // Match the endpoint shape, not a bare `3845`, which could legitimately occur
81
+ // as a byte count or a hash fragment.
82
+ const offenders = grep('(localhost|127\\.0\\.0\\.1|:)3845', RUNTIME_TREES).filter(
83
+ (f) => f !== CODEGEN_CLIENT && !TEST_FILE_RE.test(f)
84
+ );
85
+ assert.deepEqual(
86
+ offenders,
87
+ [],
88
+ `Only ${CODEGEN_CLIENT} may reach the local Figma Dev Mode MCP server (DDR-219 D2). A second caller makes codegen an ingestion route by accident — and the local server is UNAUTHENTICATED loopback, so there is no credential step to notice it. If codegen is genuinely needed elsewhere, route it through the client:\n ${offenders.join('\n ')}`
89
+ );
90
+ });
91
+
92
+ test('no canvas-reachable codegen route exists', () => {
93
+ // A canvas-origin codegen route would hand the untrusted iframe (DDR-054) a
94
+ // primitive that reads the user's open Figma document. It belongs in NEITHER
95
+ // allowlist — the same standing rule DDR-088 sets for every privileged route.
96
+ const offenders = grep('CANVAS_SAFE_API[^\\n]*codegen|codegen[^\\n]*CANVAS_SAFE_API', [
97
+ 'apps/studio',
98
+ ]);
99
+ assert.deepEqual(
100
+ offenders,
101
+ [],
102
+ `A codegen route appears near CANVAS_SAFE_API. Codegen is privileged-origin only (DDR-219 D2):\n ${offenders.join('\n ')}`
103
+ );
104
+ });