@1agh/maude 0.58.3 → 0.60.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 (81) hide show
  1. package/apps/studio/annotations-layer.tsx +49 -15
  2. package/apps/studio/bin/_import-asset.mjs +18 -0
  3. package/apps/studio/bin/_import-figma.mjs +1180 -242
  4. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  5. package/apps/studio/bin/_perf-probe.mjs +228 -0
  6. package/apps/studio/bin/_perf-shared.mjs +345 -0
  7. package/apps/studio/bin/_video-playwright.mjs +17 -4
  8. package/apps/studio/bin/import-figma.sh +10 -1
  9. package/apps/studio/bin/perf.sh +228 -0
  10. package/apps/studio/bin/smoke.sh +49 -5
  11. package/apps/studio/canvas-lib.tsx +148 -6
  12. package/apps/studio/client/app.jsx +152 -37
  13. package/apps/studio/client/panels/SyncPanel.jsx +320 -0
  14. package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
  15. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  16. package/apps/studio/client/styles/3-shell-maude.css +40 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/context.ts +4 -0
  19. package/apps/studio/dist/client.bundle.js +772 -772
  20. package/apps/studio/dist/styles.css +1 -1
  21. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  22. package/apps/studio/exporters/video.ts +10 -0
  23. package/apps/studio/figma/assets.test.ts +92 -0
  24. package/apps/studio/figma/assets.ts +63 -9
  25. package/apps/studio/figma/codegen-client.test.ts +276 -0
  26. package/apps/studio/figma/codegen-client.ts +509 -0
  27. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  28. package/apps/studio/figma/codegen-fonts.ts +195 -0
  29. package/apps/studio/figma/codegen-values.test.ts +179 -0
  30. package/apps/studio/figma/codegen-values.ts +270 -0
  31. package/apps/studio/figma/endpoints.ts +73 -0
  32. package/apps/studio/figma/fig-decode.test.ts +788 -0
  33. package/apps/studio/figma/fig-decode.ts +839 -0
  34. package/apps/studio/figma/fig-differential.test.ts +182 -0
  35. package/apps/studio/figma/fig-kiwi.ts +410 -0
  36. package/apps/studio/figma/fig-translator.test.ts +192 -0
  37. package/apps/studio/figma/fig-vector.test.ts +113 -0
  38. package/apps/studio/figma/fig-vector.ts +145 -0
  39. package/apps/studio/figma/fig-zip.ts +270 -0
  40. package/apps/studio/figma/from-codegen.test.ts +408 -0
  41. package/apps/studio/figma/from-codegen.ts +1103 -0
  42. package/apps/studio/figma/sanitize.test.ts +69 -0
  43. package/apps/studio/figma/sanitize.ts +146 -47
  44. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  45. package/apps/studio/figma/tailwind-map.ts +545 -0
  46. package/apps/studio/figma/to-artboard.ts +41 -1
  47. package/apps/studio/figma/to-render.ts +25 -3
  48. package/apps/studio/figma/types.ts +6 -1
  49. package/apps/studio/http.ts +94 -0
  50. package/apps/studio/sync/asset-push-worker.ts +84 -0
  51. package/apps/studio/sync/asset-push.ts +441 -39
  52. package/apps/studio/sync/asset-sweep.ts +262 -0
  53. package/apps/studio/sync/connection-state.ts +71 -3
  54. package/apps/studio/sync/index.ts +39 -6
  55. package/apps/studio/sync/presentation.ts +21 -0
  56. package/apps/studio/sync/status.ts +18 -0
  57. package/apps/studio/sync/supervisor.ts +20 -0
  58. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  59. package/apps/studio/test/figma-explode.test.ts +438 -0
  60. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  61. package/apps/studio/test/import-figma.test.ts +192 -4
  62. package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
  63. package/apps/studio/test/sync-asset-push.test.ts +639 -47
  64. package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
  65. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  66. package/apps/studio/test/sync-panel-surface.test.ts +123 -0
  67. package/apps/studio/test/sync-resync-routes.test.ts +125 -0
  68. package/apps/studio/test/sync-status.test.ts +28 -0
  69. package/apps/studio/test/sync-supervisor.test.ts +46 -0
  70. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  71. package/apps/studio/test/video-comp.test.ts +81 -1
  72. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  73. package/apps/studio/use-artboard-drag.tsx +37 -3
  74. package/apps/studio/video-comp.tsx +51 -0
  75. package/apps/studio/whats-new.json +87 -0
  76. package/cli/commands/design.mjs +7 -0
  77. package/cli/commands/kg.mjs +8 -1
  78. package/cli/commands/kg.test.mjs +24 -0
  79. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  80. package/cli/lib/figma-import-controls.test.mjs +70 -0
  81. package/package.json +8 -8
@@ -0,0 +1,139 @@
1
+ // timeline-comp-target — issue #75 regression coverage.
2
+ //
3
+ // The Timeline draws the rows of ONE artboard's comp but drives the transport
4
+ // through a targeted postMessage, so "which comp id" is a real decision. The
5
+ // old rule matched the parsed row total against `durationInFrames`; two comps
6
+ // of equal length collapsed onto the FIRST one, so Play moved artboard #1 while
7
+ // the panel showed artboard #2. These tests pin the artboard-first rule.
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+
11
+ import {
12
+ activeComp,
13
+ resolveCompTarget,
14
+ sanitizeArtboardText,
15
+ } from '../client/panels/timeline-comp-target.js';
16
+
17
+ const comp = (id: string, artboardId: string | null, durationInFrames: number) => ({
18
+ id,
19
+ artboardId,
20
+ durationInFrames,
21
+ fps: 30,
22
+ width: 1920,
23
+ height: 1080,
24
+ artboardLabel: artboardId,
25
+ });
26
+
27
+ describe('resolveCompTarget', () => {
28
+ test('empty / missing comps → null', () => {
29
+ expect(resolveCompTarget([], { artboardId: 'a' })).toBe(null);
30
+ // @ts-expect-error — defensive: the shell hands through canvas-origin data.
31
+ expect(resolveCompTarget(null, {})).toBe(null);
32
+ });
33
+
34
+ test('THE BUG — equal-duration comps resolve by artboard, not by order', () => {
35
+ const comps = [comp('videocomp-1', 'intro', 150), comp('videocomp-2', 'outro', 150)];
36
+ expect(resolveCompTarget(comps, { artboardId: 'outro', total: 150 })).toBe('videocomp-2');
37
+ expect(resolveCompTarget(comps, { artboardId: 'intro', total: 150 })).toBe('videocomp-1');
38
+ });
39
+
40
+ test('an author-set comp id equal to the artboard id also matches', () => {
41
+ const comps = [comp('intro', null, 150), comp('outro', null, 150)];
42
+ expect(resolveCompTarget(comps, { artboardId: 'outro', total: 150 })).toBe('outro');
43
+ });
44
+
45
+ test('no artboard known → an UNAMBIGUOUS duration still identifies the comp', () => {
46
+ const comps = [comp('videocomp-1', 'intro', 150), comp('videocomp-2', 'outro', 90)];
47
+ expect(resolveCompTarget(comps, { artboardId: null, total: 90 })).toBe('videocomp-2');
48
+ });
49
+
50
+ test('no artboard known + ambiguous duration → first comp (the only guess left)', () => {
51
+ const comps = [comp('videocomp-1', 'intro', 150), comp('videocomp-2', 'outro', 150)];
52
+ expect(resolveCompTarget(comps, { artboardId: null, total: 150 })).toBe('videocomp-1');
53
+ });
54
+
55
+ // Adversarial review 2026-08-12 — comp ids and artboard ids are separate
56
+ // author-chosen namespaces, so the weaker claim must never beat the real one.
57
+ test('an announced artboard OUTRANKS a comp that merely names itself after it', () => {
58
+ const comps = [comp('outro', 'intro', 150), comp('videocomp-2', 'outro', 150)];
59
+ expect(resolveCompTarget(comps, { artboardId: 'outro', total: 150 })).toBe('videocomp-2');
60
+ });
61
+
62
+ // The row parser derives the artboard lexically from the .tsx; canvas-lib
63
+ // reports the viewport one structurally. When the lexical claim names an
64
+ // artboard no comp is mounted in, the pan signal must still land the target.
65
+ test('candidate list — a stale first candidate falls through to the live one', () => {
66
+ const comps = [comp('videocomp-1', 'intro', 150), comp('videocomp-2', 'outro', 150)];
67
+ expect(resolveCompTarget(comps, { artboardId: ['ghost', 'outro'], total: 150 })).toBe(
68
+ 'videocomp-2'
69
+ );
70
+ // First candidate wins whenever it DOES name a mounted comp.
71
+ expect(resolveCompTarget(comps, { artboardId: ['intro', 'outro'], total: 150 })).toBe(
72
+ 'videocomp-1'
73
+ );
74
+ expect(resolveCompTarget(comps, { artboardId: [null, undefined, 'outro'] })).toBe(
75
+ 'videocomp-2'
76
+ );
77
+ });
78
+
79
+ test('an artboard with no comp falls back rather than returning null', () => {
80
+ const comps = [comp('videocomp-1', 'intro', 150)];
81
+ expect(resolveCompTarget(comps, { artboardId: 'a-still-image-board', total: 150 })).toBe(
82
+ 'videocomp-1'
83
+ );
84
+ });
85
+ });
86
+
87
+ // The artboard label is untrusted canvas-origin text rendered as shell chrome
88
+ // (security-review 2026-08-12): a chip that can be made to read as a different
89
+ // artboard than the transport is scoped to defeats its own purpose.
90
+ describe('sanitizeArtboardText', () => {
91
+ test('strips bidi overrides + isolates', () => {
92
+ expect(sanitizeArtboardText('Intro\u202e')).toBe('Intro');
93
+ expect(sanitizeArtboardText('\u2066Outro\u2069')).toBe('Outro');
94
+ expect(sanitizeArtboardText('gnp\u202egpj.Hero')).toBe('gnpgpj.Hero');
95
+ });
96
+
97
+ test('strips zero-width, soft hyphen, BOM and C0/C1 controls', () => {
98
+ expect(sanitizeArtboardText('In\u200btro')).toBe('Intro');
99
+ expect(sanitizeArtboardText('\ufeffHero\u00ad')).toBe('Hero');
100
+ expect(sanitizeArtboardText('Line\nBreak\tTab')).toBe('LineBreakTab');
101
+ expect(sanitizeArtboardText('Bell\u0007')).toBe('Bell');
102
+ });
103
+
104
+ test('an all-invisible label reads as NO label, not as a blank chip', () => {
105
+ expect(sanitizeArtboardText('\u200b\u200b\u200b')).toBe(null);
106
+ expect(sanitizeArtboardText(' ')).toBe(null);
107
+ expect(sanitizeArtboardText('')).toBe(null);
108
+ });
109
+
110
+ test('non-strings are null, never coerced', () => {
111
+ expect(sanitizeArtboardText(undefined)).toBe(null);
112
+ expect(sanitizeArtboardText(null)).toBe(null);
113
+ expect(sanitizeArtboardText(42)).toBe(null);
114
+ expect(sanitizeArtboardText({ toString: () => 'nope' })).toBe(null);
115
+ });
116
+
117
+ test('caps length, and a cap that lands mid-space still trims', () => {
118
+ expect(sanitizeArtboardText('x'.repeat(500))).toHaveLength(120);
119
+ expect(sanitizeArtboardText(`${'x'.repeat(119)} yz`)).toBe('x'.repeat(119));
120
+ });
121
+
122
+ test('ordinary labels survive intact (accents, emoji, punctuation)', () => {
123
+ expect(sanitizeArtboardText('Přehled — 2. krok')).toBe('Přehled — 2. krok');
124
+ expect(sanitizeArtboardText('Hero 🎬')).toBe('Hero 🎬');
125
+ });
126
+ });
127
+
128
+ describe('activeComp', () => {
129
+ test('reads meta off the SAME comp the transport targets', () => {
130
+ const comps = [comp('videocomp-1', 'intro', 150), comp('videocomp-2', 'outro', 90)];
131
+ expect(activeComp(comps, 'videocomp-2')?.durationInFrames).toBe(90);
132
+ });
133
+
134
+ test('unknown id → first comp (panel still renders); empty → null', () => {
135
+ const comps = [comp('videocomp-1', 'intro', 150)];
136
+ expect(activeComp(comps, 'nope')?.id).toBe('videocomp-1');
137
+ expect(activeComp([], 'x')).toBe(null);
138
+ });
139
+ });
@@ -11,7 +11,7 @@
11
11
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
12
12
  import { GlobalRegistrator } from '@happy-dom/global-registrator';
13
13
 
14
- import { installMaudeSeekBridge } from '../video-comp.tsx';
14
+ import { findArtboardOf, installMaudeSeekBridge } from '../video-comp.tsx';
15
15
 
16
16
  interface FakePlayer {
17
17
  seekTo: (frame: number) => void;
@@ -111,6 +111,11 @@ describe('video-comp seek bridge', () => {
111
111
  durationInFrames: 90,
112
112
  width: 640,
113
113
  height: 360,
114
+ // issue #75 — always present in the snapshot (null when the comp was
115
+ // registered outside an artboard), so the shell can key the transport
116
+ // target on artboard identity instead of guessing from duration.
117
+ artboardId: null,
118
+ artboardLabel: null,
114
119
  });
115
120
  // No ref/functions leak into the snapshot.
116
121
  expect(Object.keys(comps[0])).not.toContain('ref');
@@ -188,3 +193,78 @@ describe('video-comp seek bridge', () => {
188
193
  );
189
194
  });
190
195
  });
196
+
197
+ // issue #75 — a comp's own id is a mount slug (`videocomp-N`) unless the author
198
+ // set one, so the shell can only tell two comps apart by the artboard they are
199
+ // mounted in. This is the resolution that puts that id on the wire.
200
+ describe('findArtboardOf', () => {
201
+ test('resolves the enclosing artboard id + visible label', () => {
202
+ document.body.innerHTML = `
203
+ <article data-dc-screen="outro">
204
+ <button class="dc-artboard-label sku"><span aria-hidden="true"></span>Outro</button>
205
+ <div class="dc-artboard-body"><div class="dc-video-comp" data-comp-id="videocomp-2"></div></div>
206
+ </article>`;
207
+ const host = document.querySelector('.dc-video-comp');
208
+ expect(findArtboardOf(host)).toEqual({ artboardId: 'outro', artboardLabel: 'Outro' });
209
+ });
210
+
211
+ test('a comp mounted outside any artboard reports nulls, never throws', () => {
212
+ document.body.innerHTML = '<div class="dc-video-comp" data-comp-id="bare"></div>';
213
+ expect(findArtboardOf(document.querySelector('.dc-video-comp'))).toEqual({
214
+ artboardId: null,
215
+ artboardLabel: null,
216
+ });
217
+ expect(findArtboardOf(null)).toEqual({ artboardId: null, artboardLabel: null });
218
+ });
219
+
220
+ // Adversarial review 2026-08-12 — `.dc-artboard-label` is a class anyone can
221
+ // write, so the label must come from the element canvas-lib actually renders
222
+ // (a DIRECT header/button child), not from the first match anywhere below.
223
+ test('a planted label node cannot outrank the real header', () => {
224
+ document.body.innerHTML = `
225
+ <article data-dc-screen="intro">
226
+ <span class="dc-artboard-label" hidden>Outro</span>
227
+ <header class="dc-artboard-label sku">Intro</header>
228
+ <div class="dc-artboard-body"><div class="dc-video-comp"></div></div>
229
+ </article>`;
230
+ expect(findArtboardOf(document.querySelector('.dc-video-comp')).artboardLabel).toBe('Intro');
231
+ });
232
+
233
+ test('a nested artboard resolves to the NEAREST board, not the outer label', () => {
234
+ document.body.innerHTML = `
235
+ <article data-dc-screen="outer">
236
+ <header class="dc-artboard-label sku">Outer</header>
237
+ <article data-dc-screen="inner">
238
+ <header class="dc-artboard-label sku">Inner</header>
239
+ <div class="dc-video-comp"></div>
240
+ </article>
241
+ </article>`;
242
+ expect(findArtboardOf(document.querySelector('.dc-video-comp'))).toEqual({
243
+ artboardId: 'inner',
244
+ artboardLabel: 'Inner',
245
+ });
246
+ });
247
+
248
+ test('a label deep in the body is ignored — only a direct header/button counts', () => {
249
+ document.body.innerHTML = `
250
+ <article data-dc-screen="intro">
251
+ <div class="dc-artboard-body">
252
+ <p class="dc-artboard-label">Outro</p>
253
+ <div class="dc-video-comp"></div>
254
+ </div>
255
+ </article>`;
256
+ expect(findArtboardOf(document.querySelector('.dc-video-comp'))).toEqual({
257
+ artboardId: 'intro',
258
+ artboardLabel: null,
259
+ });
260
+ });
261
+
262
+ test('an unlabelled artboard still yields its id', () => {
263
+ document.body.innerHTML =
264
+ '<article data-dc-screen="intro"><div class="dc-video-comp"></div></article>';
265
+ expect(findArtboardOf(document.querySelector('.dc-video-comp'))).toEqual({
266
+ artboardId: 'intro',
267
+ artboardLabel: null,
268
+ });
269
+ });
270
+ });
@@ -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
+ });
@@ -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. */
@@ -255,6 +268,8 @@ export function installMaudeSeekBridge(): void {
255
268
  durationInFrames: e.durationInFrames,
256
269
  width: e.width,
257
270
  height: e.height,
271
+ artboardId: e.artboardId ?? null,
272
+ artboardLabel: e.artboardLabel ?? null,
258
273
  }));
259
274
 
260
275
  w.__maude_render_video__ = async (compId, opts) => {
@@ -288,6 +303,35 @@ export function findCompIdIn(container: Element): string | null {
288
303
  return container.querySelector('[data-comp-id]')?.getAttribute('data-comp-id') ?? null;
289
304
  }
290
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
+
291
335
  // ─────────────────────────────────────────────────────────────────────────────
292
336
  // VideoComp — the canvas-lib wrapper
293
337
 
@@ -361,6 +405,7 @@ export function VideoComp({
361
405
  autoPlay,
362
406
  }: VideoCompProps) {
363
407
  const ref = useRef<PlayerRef | null>(null);
408
+ const hostRef = useRef<HTMLDivElement | null>(null);
364
409
  // Stable id across re-renders; unique per mount when the author omits one.
365
410
  const [autoId] = useState(() => id ?? `videocomp-${++mountCounter}`);
366
411
  const compId = id ?? autoId;
@@ -390,8 +435,13 @@ export function VideoComp({
390
435
  // Register with the seek bridge for the lifetime of this mount.
391
436
  useEffect(() => {
392
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);
393
441
  const entry: CompEntry = {
394
442
  id: compId,
443
+ artboardId,
444
+ artboardLabel,
395
445
  fps,
396
446
  durationInFrames,
397
447
  width,
@@ -507,6 +557,7 @@ export function VideoComp({
507
557
  return (
508
558
  <div
509
559
  className="dc-video-comp"
560
+ ref={hostRef}
510
561
  data-comp-id={compId}
511
562
  style={{ width, height, maxWidth: '100%' }}
512
563
  >
@@ -1,6 +1,93 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "sync-resync-button",
6
+ "version": "0.60.0",
7
+ "date": "2026-08-12",
8
+ "kind": "feature",
9
+ "title": "Resync your project without restarting Maude",
10
+ "summary": "The Sync panel has a Resync button: one press re-checks everything against the cloud — every canvas and every file — instead of quitting and reopening. Uploads now run on their own too, so a failed upload stays a failed upload: Maude keeps working, the panel says what happened, and you can stop one part-way without leaving anything half-sent."
11
+ },
12
+ {
13
+ "id": "figma-offline-fig-import",
14
+ "version": "0.60.0",
15
+ "date": "2026-08-12",
16
+ "kind": "feature",
17
+ "title": "Import a Figma file with no Figma account",
18
+ "summary": "Drag in a .fig or .jam you exported and Maude reads it entirely offline — no token, no network, no seat. Images travel inside the file, so nothing expires and nothing is rate-limited. If the export is from a Figma version Maude does not recognise, it says so rather than guessing at the geometry."
19
+ },
20
+ {
21
+ "id": "cloud-asset-push-completes",
22
+ "version": "0.59.0",
23
+ "date": "2026-08-12",
24
+ "kind": "fix",
25
+ "title": "Every asset reaches the cloud, in one go",
26
+ "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.",
27
+ "surface": "design-ui"
28
+ },
29
+ {
30
+ "id": "figma-explode-artboard",
31
+ "version": "0.59.0",
32
+ "date": "2026-08-12",
33
+ "kind": "feature",
34
+ "title": "Make an imported Figma artboard editable",
35
+ "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.",
36
+ "surface": "design-ui"
37
+ },
38
+ {
39
+ "id": "figma-import-fidelity-fixes",
40
+ "version": "0.59.0",
41
+ "date": "2026-08-12",
42
+ "kind": "fix",
43
+ "title": "Figma imports keep their fonts and their board",
44
+ "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.",
45
+ "surface": "design-ui"
46
+ },
47
+ {
48
+ "id": "canvas-perf-benchmark",
49
+ "version": "0.59.0",
50
+ "date": "2026-08-12",
51
+ "kind": "improvement",
52
+ "title": "Measure a canvas's pan/zoom performance",
53
+ "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.",
54
+ "surface": "design-ui"
55
+ },
56
+ {
57
+ "id": "video-export-degraded-visible",
58
+ "version": "0.59.0",
59
+ "date": "2026-08-12",
60
+ "kind": "fix",
61
+ "title": "A video export tells you when it's missing audio",
62
+ "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.",
63
+ "surface": "design-ui"
64
+ },
65
+ {
66
+ "id": "sync-panel-per-file",
67
+ "version": "0.59.0",
68
+ "date": "2026-08-12",
69
+ "kind": "feature",
70
+ "title": "Watch your project sync, file by file",
71
+ "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.",
72
+ "surface": "design-ui",
73
+ "tour": [
74
+ {
75
+ "target": "[data-testid=\"open-sync\"]",
76
+ "title": "Per-file sync progress",
77
+ "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.",
78
+ "placement": "top"
79
+ }
80
+ ]
81
+ },
82
+ {
83
+ "id": "cloud-brand-assets-and-steady-link",
84
+ "version": "0.59.0",
85
+ "date": "2026-08-12",
86
+ "kind": "fix",
87
+ "title": "Brand logos load in the cloud (and a steadier status bar)",
88
+ "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.",
89
+ "surface": "design-ui"
90
+ },
4
91
  {
5
92
  "id": "cloud-sync-matches-your-desktop",
6
93
  "version": "0.58.3",
@@ -126,6 +126,13 @@ const BIN_VERBS = new Set([
126
126
  // host — see DDR-185's rejected alternatives). No dev server dependency —
127
127
  // pure Node, no `<designRoot>` involvement.
128
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',
129
136
  // DDR-185 security addendum. `agent-browser-safe` replaces the bare
130
137
  // `Bash(agent-browser:*)` allow-list entry a security review found was a
131
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
+ });