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