@1agh/maude 0.58.3 → 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 (67) 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 +868 -214
  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 +229 -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 +30 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/dist/client.bundle.js +772 -772
  19. package/apps/studio/dist/styles.css +1 -1
  20. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  21. package/apps/studio/exporters/video.ts +10 -0
  22. package/apps/studio/figma/assets.test.ts +92 -0
  23. package/apps/studio/figma/assets.ts +63 -9
  24. package/apps/studio/figma/codegen-client.test.ts +276 -0
  25. package/apps/studio/figma/codegen-client.ts +509 -0
  26. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  27. package/apps/studio/figma/codegen-fonts.ts +195 -0
  28. package/apps/studio/figma/codegen-values.test.ts +179 -0
  29. package/apps/studio/figma/codegen-values.ts +270 -0
  30. package/apps/studio/figma/endpoints.ts +73 -0
  31. package/apps/studio/figma/fig-decode.test.ts +702 -0
  32. package/apps/studio/figma/fig-decode.ts +617 -0
  33. package/apps/studio/figma/fig-kiwi.ts +410 -0
  34. package/apps/studio/figma/fig-zip.ts +270 -0
  35. package/apps/studio/figma/from-codegen.test.ts +408 -0
  36. package/apps/studio/figma/from-codegen.ts +1103 -0
  37. package/apps/studio/figma/sanitize.test.ts +69 -0
  38. package/apps/studio/figma/sanitize.ts +139 -47
  39. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  40. package/apps/studio/figma/tailwind-map.ts +545 -0
  41. package/apps/studio/figma/to-render.ts +25 -3
  42. package/apps/studio/figma/types.ts +6 -1
  43. package/apps/studio/http.ts +47 -0
  44. package/apps/studio/sync/asset-push.ts +346 -38
  45. package/apps/studio/sync/connection-state.ts +71 -3
  46. package/apps/studio/sync/index.ts +10 -1
  47. package/apps/studio/sync/status.ts +18 -0
  48. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  49. package/apps/studio/test/figma-explode.test.ts +438 -0
  50. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  51. package/apps/studio/test/import-figma.test.ts +192 -4
  52. package/apps/studio/test/sync-asset-push.test.ts +490 -47
  53. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  54. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  55. package/apps/studio/test/sync-status.test.ts +28 -0
  56. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  57. package/apps/studio/test/video-comp.test.ts +81 -1
  58. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  59. package/apps/studio/use-artboard-drag.tsx +37 -3
  60. package/apps/studio/video-comp.tsx +51 -0
  61. package/apps/studio/whats-new.json +71 -0
  62. package/cli/commands/design.mjs +7 -0
  63. package/cli/commands/kg.mjs +8 -1
  64. package/cli/commands/kg.test.mjs +24 -0
  65. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  66. package/cli/lib/figma-import-controls.test.mjs +70 -0
  67. package/package.json +8 -8
@@ -0,0 +1,90 @@
1
+ // feature-sync-progress-modal — the Sync panel + HUB SYNC chip surface.
2
+ //
3
+ // Source-level assertions (cloud-shell-surfaces style): what these pin is
4
+ // exactly what would regress — someone dropping the linked-only gate (a solo
5
+ // project would get a dock tab that opens onto nothing), rewiring the chip
6
+ // away from the dock helpers (breaking the one-panel-per-side invariant), or
7
+ // the panel inventing sync vocabulary / dropping the a11y live region the
8
+ // CloudBar note established.
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ const STUDIO = join(import.meta.dir, '..');
15
+ const APP = readFileSync(join(STUDIO, 'client', 'app.jsx'), 'utf8');
16
+ const PANEL = readFileSync(join(STUDIO, 'client', 'panels', 'SyncPanel.jsx'), 'utf8');
17
+
18
+ describe('the Sync panel exists only where a hub link exists', () => {
19
+ test('sync is a dock panel with a right-side default', () => {
20
+ expect(APP).toContain("{ id: 'sync', label: 'Sync' }");
21
+ expect(APP).toMatch(/sync: 'right'/);
22
+ });
23
+
24
+ test('panelAvailable gates sync on a live syncStatus — solo projects get no tab', () => {
25
+ expect(APP).toContain("if (id === 'sync') return !!syncStatus;");
26
+ });
27
+
28
+ test('the chip toggles through the dock helpers, not a raw setState', () => {
29
+ // toggleRightPanel keeps the one-panel-per-side invariant; a bare
30
+ // setSyncPanelOpen(true) here would let Sync render behind an open sibling.
31
+ expect(APP).toMatch(/onOpenSync=\{syncStatus \? \(\) => toggleRightPanel\('sync'\)/);
32
+ });
33
+
34
+ test('the chip is a real button with pressed-state semantics', () => {
35
+ expect(APP).toContain('data-testid="open-sync"');
36
+ expect(APP).toMatch(/aria-pressed=\{syncOpen\}/);
37
+ });
38
+
39
+ test('the span fallback remains for sessions without a handler', () => {
40
+ // Older wiring / no-handler renders must keep the passive chip rather
41
+ // than a dead button.
42
+ expect(APP).toMatch(/onOpenSync \? \(/);
43
+ });
44
+ });
45
+
46
+ describe('the Sync panel speaks the presentation vocabulary, safely', () => {
47
+ test('the header sentence comes from syncPresentation — the one shared rule', () => {
48
+ expect(PANEL).toContain("from '../../sync/presentation.ts'");
49
+ expect(PANEL).toContain('syncPresentation(status');
50
+ });
51
+
52
+ test('rows map DocSyncState onto existing words, never new ones', () => {
53
+ expect(PANEL).toMatch(/pending: 'syncing'/);
54
+ expect(PANEL).toMatch(/connected: 'synced'/);
55
+ expect(PANEL).toMatch(/'auth-rejected': 'refused'/);
56
+ });
57
+
58
+ test('every name that reaches the DOM goes through safeName', () => {
59
+ // Slugs and asset keys are local, but the payload is read back off disk —
60
+ // bounded text-only rendering is the house rule (DDR-054).
61
+ expect(PANEL).toContain('import { safeName, syncPresentation }');
62
+ expect(PANEL).not.toMatch(/dangerouslySetInnerHTML/);
63
+ });
64
+
65
+ test('the live header is a polite status region (the CloudBar a11y pattern)', () => {
66
+ expect(PANEL).toMatch(/role="status" aria-live="polite"/);
67
+ });
68
+
69
+ test('the asset lane renders from the payload, with the retry promise', () => {
70
+ expect(PANEL).toContain('data-testid="sync-assets"');
71
+ expect(PANEL).toContain('retry on the next launch');
72
+ });
73
+ });
74
+
75
+ describe('the panel reads the off-disk payload fail-closed (DDR-102 discipline)', () => {
76
+ test('items, assets and the header counts all pass a readCounts-style gate', () => {
77
+ // `_sync.json` is JSON.parse with no schema — a partial write or older
78
+ // producer must degrade to "nothing to show", never crash or print NaN
79
+ // (security review 2026-08-11, defender W1).
80
+ expect(PANEL).toContain('function readItems(');
81
+ expect(PANEL).toContain('function readAssets(');
82
+ expect(PANEL).toContain('readItems(status?.items)');
83
+ expect(PANEL).toContain('readAssets(status?.assets)');
84
+ expect(PANEL).toMatch(/Number\.isInteger/);
85
+ // The header chip validates the same counts the note fails closed on.
86
+ expect(PANEL).toMatch(
87
+ /\[rawDocs\.synced, rawDocs\.pending, rawDocs\.rejected\]\.every\(isCount\)/
88
+ );
89
+ });
90
+ });
@@ -144,3 +144,31 @@ describe('sync status store — DDR-102 additive fields', () => {
144
144
  expect(p.state).toBe('online');
145
145
  });
146
146
  });
147
+
148
+ describe('sync status store — asset lane (feature-sync-progress-modal)', () => {
149
+ test('updateAssets() merges progress into the payload + flushes', () => {
150
+ const { store, writes, broadcasts } = makeStore();
151
+ const progress = {
152
+ total: 10,
153
+ done: 4,
154
+ pushed: 3,
155
+ skipped: 1,
156
+ failedCount: 0,
157
+ failures: [],
158
+ active: 'assets/hero.png',
159
+ finished: false,
160
+ };
161
+ store.updateAssets(progress);
162
+ expect(writes[writes.length - 1].assets).toEqual(progress);
163
+ expect(broadcasts[broadcasts.length - 1].assets).toEqual(progress);
164
+ // …and a later monitor update keeps the asset lane (separate slices).
165
+ store.update(snap({ state: 'online' }));
166
+ expect(store.get().assets).toEqual(progress);
167
+ });
168
+
169
+ test('payloads before the first asset emit carry no assets field', () => {
170
+ const { store } = makeStore();
171
+ store.update(snap());
172
+ expect('assets' in store.get()).toBe(false);
173
+ });
174
+ });
@@ -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
  >