@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.
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/bin/_import-asset.mjs +18 -0
- package/apps/studio/bin/_import-figma.mjs +1180 -242
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +17 -4
- package/apps/studio/bin/import-figma.sh +10 -1
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +152 -37
- package/apps/studio/client/panels/SyncPanel.jsx +320 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/styles/3-shell-maude.css +40 -0
- package/apps/studio/client/styles/4-components.css +4 -4
- package/apps/studio/context.ts +4 -0
- package/apps/studio/dist/client.bundle.js +772 -772
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/video-encode-lib.ts +8 -5
- package/apps/studio/exporters/video.ts +10 -0
- package/apps/studio/figma/assets.test.ts +92 -0
- package/apps/studio/figma/assets.ts +63 -9
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/endpoints.ts +73 -0
- package/apps/studio/figma/fig-decode.test.ts +788 -0
- package/apps/studio/figma/fig-decode.ts +839 -0
- package/apps/studio/figma/fig-differential.test.ts +182 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-translator.test.ts +192 -0
- package/apps/studio/figma/fig-vector.test.ts +113 -0
- package/apps/studio/figma/fig-vector.ts +145 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +69 -0
- package/apps/studio/figma/sanitize.ts +146 -47
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-artboard.ts +41 -1
- package/apps/studio/figma/to-render.ts +25 -3
- package/apps/studio/figma/types.ts +6 -1
- package/apps/studio/http.ts +94 -0
- package/apps/studio/sync/asset-push-worker.ts +84 -0
- package/apps/studio/sync/asset-push.ts +441 -39
- package/apps/studio/sync/asset-sweep.ts +262 -0
- package/apps/studio/sync/connection-state.ts +71 -3
- package/apps/studio/sync/index.ts +39 -6
- package/apps/studio/sync/presentation.ts +21 -0
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/import-figma.test.ts +192 -4
- package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
- package/apps/studio/test/sync-asset-push.test.ts +639 -47
- package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
- package/apps/studio/test/sync-connection-state.test.ts +66 -0
- package/apps/studio/test/sync-panel-surface.test.ts +123 -0
- package/apps/studio/test/sync-resync-routes.test.ts +125 -0
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/sync-supervisor.test.ts +46 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +81 -1
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +51 -0
- package/apps/studio/whats-new.json +87 -0
- package/cli/commands/design.mjs +7 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- 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
|
-
/**
|
|
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
|
-
|
|
308
|
-
|
|
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",
|
package/cli/commands/design.mjs
CHANGED
|
@@ -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
|
package/cli/commands/kg.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/cli/commands/kg.test.mjs
CHANGED
|
@@ -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
|
+
});
|