@1agh/maude 0.58.3 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/apps/studio/annotations-layer.tsx +49 -15
  2. package/apps/studio/bin/_import-asset.mjs +18 -0
  3. package/apps/studio/bin/_import-figma.mjs +1180 -242
  4. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  5. package/apps/studio/bin/_perf-probe.mjs +228 -0
  6. package/apps/studio/bin/_perf-shared.mjs +345 -0
  7. package/apps/studio/bin/_video-playwright.mjs +17 -4
  8. package/apps/studio/bin/import-figma.sh +10 -1
  9. package/apps/studio/bin/perf.sh +228 -0
  10. package/apps/studio/bin/smoke.sh +49 -5
  11. package/apps/studio/canvas-lib.tsx +148 -6
  12. package/apps/studio/client/app.jsx +152 -37
  13. package/apps/studio/client/panels/SyncPanel.jsx +320 -0
  14. package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
  15. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  16. package/apps/studio/client/styles/3-shell-maude.css +40 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/context.ts +4 -0
  19. package/apps/studio/dist/client.bundle.js +772 -772
  20. package/apps/studio/dist/styles.css +1 -1
  21. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  22. package/apps/studio/exporters/video.ts +10 -0
  23. package/apps/studio/figma/assets.test.ts +92 -0
  24. package/apps/studio/figma/assets.ts +63 -9
  25. package/apps/studio/figma/codegen-client.test.ts +276 -0
  26. package/apps/studio/figma/codegen-client.ts +509 -0
  27. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  28. package/apps/studio/figma/codegen-fonts.ts +195 -0
  29. package/apps/studio/figma/codegen-values.test.ts +179 -0
  30. package/apps/studio/figma/codegen-values.ts +270 -0
  31. package/apps/studio/figma/endpoints.ts +73 -0
  32. package/apps/studio/figma/fig-decode.test.ts +788 -0
  33. package/apps/studio/figma/fig-decode.ts +839 -0
  34. package/apps/studio/figma/fig-differential.test.ts +182 -0
  35. package/apps/studio/figma/fig-kiwi.ts +410 -0
  36. package/apps/studio/figma/fig-translator.test.ts +192 -0
  37. package/apps/studio/figma/fig-vector.test.ts +113 -0
  38. package/apps/studio/figma/fig-vector.ts +145 -0
  39. package/apps/studio/figma/fig-zip.ts +270 -0
  40. package/apps/studio/figma/from-codegen.test.ts +408 -0
  41. package/apps/studio/figma/from-codegen.ts +1103 -0
  42. package/apps/studio/figma/sanitize.test.ts +69 -0
  43. package/apps/studio/figma/sanitize.ts +146 -47
  44. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  45. package/apps/studio/figma/tailwind-map.ts +545 -0
  46. package/apps/studio/figma/to-artboard.ts +41 -1
  47. package/apps/studio/figma/to-render.ts +25 -3
  48. package/apps/studio/figma/types.ts +6 -1
  49. package/apps/studio/http.ts +94 -0
  50. package/apps/studio/sync/asset-push-worker.ts +84 -0
  51. package/apps/studio/sync/asset-push.ts +441 -39
  52. package/apps/studio/sync/asset-sweep.ts +262 -0
  53. package/apps/studio/sync/connection-state.ts +71 -3
  54. package/apps/studio/sync/index.ts +39 -6
  55. package/apps/studio/sync/presentation.ts +21 -0
  56. package/apps/studio/sync/status.ts +18 -0
  57. package/apps/studio/sync/supervisor.ts +20 -0
  58. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  59. package/apps/studio/test/figma-explode.test.ts +438 -0
  60. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  61. package/apps/studio/test/import-figma.test.ts +192 -4
  62. package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
  63. package/apps/studio/test/sync-asset-push.test.ts +639 -47
  64. package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
  65. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  66. package/apps/studio/test/sync-panel-surface.test.ts +123 -0
  67. package/apps/studio/test/sync-resync-routes.test.ts +125 -0
  68. package/apps/studio/test/sync-status.test.ts +28 -0
  69. package/apps/studio/test/sync-supervisor.test.ts +46 -0
  70. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  71. package/apps/studio/test/video-comp.test.ts +81 -1
  72. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  73. package/apps/studio/use-artboard-drag.tsx +37 -3
  74. package/apps/studio/video-comp.tsx +51 -0
  75. package/apps/studio/whats-new.json +87 -0
  76. package/cli/commands/design.mjs +7 -0
  77. package/cli/commands/kg.mjs +8 -1
  78. package/cli/commands/kg.test.mjs +24 -0
  79. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  80. package/cli/lib/figma-import-controls.test.mjs +70 -0
  81. package/package.json +8 -8
@@ -0,0 +1,438 @@
1
+ // `maude design import-figma --explode` — the DDR-219 D8 write model.
2
+ //
3
+ // Every clause of D8 is a REFUSAL, so most of this file is refusals. The one
4
+ // happy path drives the whole verb against a stubbed local MCP server and a
5
+ // stubbed asset lane, and then asserts the two things that make a codegen
6
+ // artboard honest: it renders as JSX, and the file says where it came from.
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { parseSync } from 'oxc-parser';
13
+ import { explodeArtboard, ImportFigmaError } from '../bin/_import-figma.mjs';
14
+ import { CodegenSession } from '../figma/codegen-client.ts';
15
+
16
+ // The asset constant deliberately omits the Dev Mode PORT: the reachability
17
+ // guard (`cli/lib/figma-codegen-reachability.test.mjs`) bans that literal
18
+ // outside the codegen client and its own tests, and a guard is only worth having
19
+ // while it stays absolute. The port is irrelevant to what this file asserts —
20
+ // that the URL is DISCARDED and the artwork comes back through the node-id lane.
21
+ // The byte-faithful capture lives in `figma/from-codegen.test.ts`.
22
+ const MODULE = `const imgIcon = "http://localhost/assets/aaaa.svg";
23
+
24
+ export default function Cover() {
25
+ return (
26
+ <div className="bg-white flex flex-col size-full" data-node-id="6:907" data-name="Cover">
27
+ <p className="text-[16px] font-bold">Hello</p>
28
+ <div className="size-[24px]" data-node-id="6:908" data-name="Mark">
29
+ <img alt="" className="size-full" src={imgIcon} />
30
+ </div>
31
+ </div>
32
+ );
33
+ }`;
34
+
35
+ const CANVAS = `// Imported from Figma — THIRD-PARTY CONTENT (DDR-216).
36
+ import { DCArtboard, DesignCanvas } from '@maude/canvas-lib';
37
+
38
+ export default function Canvas() {
39
+ return (
40
+ <DesignCanvas>
41
+ <DCArtboard id="node-6-907" label="Cover" width={1440} height={1024} kind="digital" layout="block" background="#ffffff">
42
+ <img src="/assets/aaa.svg" alt="Cover" data-dc-element="Cover" data-figma-node="6:907" style={{ width: "100%", height: "100%" }} />
43
+ </DCArtboard>
44
+ <DCArtboard id="node-6-910" label="Second" width={800} height={600} kind="digital" layout="block" background="#ffffff">
45
+ <img src="/assets/bbb.svg" alt="Second" data-figma-node="6:910" style={{ width: "100%", height: "100%" }} />
46
+ </DCArtboard>
47
+ </DesignCanvas>
48
+ );
49
+ }
50
+ `;
51
+
52
+ const META = {
53
+ kind: 'imported-figma',
54
+ mode: 'render',
55
+ source: { fileKey: '2H6a9YUgPAu0AGdEiwP895', nodeId: '0:1', importedAt: '2026-08-11T00:00:00Z' },
56
+ layout: { artboards: [{ id: 'node-6-907', x: 0, y: 0 }] },
57
+ figma: {
58
+ frames: [
59
+ {
60
+ id: 'node-6-907',
61
+ nodeId: '6:907',
62
+ type: 'FRAME',
63
+ label: 'Cover',
64
+ w: 1440,
65
+ h: 1024,
66
+ route: 'render',
67
+ },
68
+ {
69
+ id: 'node-6-910',
70
+ nodeId: '6:910',
71
+ type: 'FRAME',
72
+ label: 'Second',
73
+ w: 800,
74
+ h: 600,
75
+ route: 'render',
76
+ },
77
+ ],
78
+ },
79
+ };
80
+
81
+ function sse(payload: unknown): Response {
82
+ return new Response(`event: message\ndata: ${JSON.stringify(payload)}\n\n`, {
83
+ status: 200,
84
+ headers: { 'content-type': 'text/event-stream', 'mcp-session-id': 's' },
85
+ });
86
+ }
87
+
88
+ /**
89
+ * A stubbed asset lane. The REAL one is exercised by `figma/assets.test.ts`; the
90
+ * codegen route reuses it verbatim (D6 — re-fetch by node id), and wiring the
91
+ * real one in here would spend the developer's actual Figma PAT against a
92
+ * fixture file key.
93
+ */
94
+ function stubAssets(refs: Record<string, string> = {}) {
95
+ return async (
96
+ _fileKey: string,
97
+ requests: ReadonlyArray<{ nodeId: string; placeholder: string }>
98
+ ) => {
99
+ const rewrites = new Map<string, string>();
100
+ const resolved: Array<{ nodeId: string }> = [];
101
+ for (const r of requests) {
102
+ const ref = refs[r.nodeId];
103
+ if (!ref) continue;
104
+ rewrites.set(r.placeholder, ref);
105
+ resolved.push({ nodeId: r.nodeId });
106
+ }
107
+ return { resolved, rewrites, totalBytes: 0 };
108
+ };
109
+ }
110
+
111
+ /** A local Dev Mode server that answers exactly like the measured one. */
112
+ function mcpSession(code = MODULE) {
113
+ const fetchImpl = async (_url: string, init: RequestInit): Promise<Response> => {
114
+ const body = JSON.parse(String(init.body)) as { method: string; id: number };
115
+ if (body.method === 'initialize') {
116
+ return sse({ result: { serverInfo: { name: 'Figma Dev Mode MCP Server' } }, id: body.id });
117
+ }
118
+ if (body.method === 'notifications/initialized') return new Response('', { status: 202 });
119
+ if (body.method === 'tools/list') {
120
+ return sse({ result: { tools: [{ name: 'get_design_context' }] }, id: body.id });
121
+ }
122
+ return sse({ result: { content: [{ type: 'text', text: code }] }, id: body.id });
123
+ };
124
+ return new CodegenSession({ fetchImpl });
125
+ }
126
+
127
+ function project(over: { meta?: unknown; canvas?: string } = {}) {
128
+ const root = mkdtempSync(join(tmpdir(), 'maude-explode-'));
129
+ const uiDir = join(root, '.design', 'ui', 'figma-2h6a9yug');
130
+ mkdirSync(uiDir, { recursive: true });
131
+ writeFileSync(join(uiDir, 'Page 1.tsx'), over.canvas ?? CANVAS);
132
+ writeFileSync(join(uiDir, 'Page 1.meta.json'), `${JSON.stringify(over.meta ?? META, null, 2)}\n`);
133
+ return { root, canvasRel: 'ui/figma-2h6a9yug/Page 1.tsx' };
134
+ }
135
+
136
+ const BASE = { artboardId: 'node-6-907', designRootRel: '.design' };
137
+
138
+ describe('the happy path', () => {
139
+ test('one artboard becomes editable JSX, and only that one', async () => {
140
+ const { root, canvasRel } = project();
141
+ try {
142
+ const r = await explodeArtboard({
143
+ root,
144
+ canvasRel,
145
+ ...BASE,
146
+ session: mcpSession(),
147
+ resolveAssetsImpl: stubAssets({ '6:908': '/assets/deadbeef.svg' }),
148
+ });
149
+ expect(r.written).toBe(true);
150
+
151
+ const out = readFileSync(join(root, '.design', canvasRel), 'utf8');
152
+ // The exploded artboard is now real JSX…
153
+ expect(out).toContain("{'Hello'}");
154
+ expect(out).toContain('label="Cover · codegen"');
155
+ // …and the other artboard is untouched.
156
+ expect(out).toContain('label="Second"');
157
+ expect(out).toContain('src="/assets/bbb.svg"');
158
+ // D6 — the response's own asset URL was discarded and the artwork came
159
+ // back through the node-id lane instead.
160
+ expect(out).toContain('src="/assets/deadbeef.svg"');
161
+ expect(out).not.toContain('localhost');
162
+ // The canvas still parses, which is the whole bar.
163
+ expect(parseSync('c.tsx', out, { sourceType: 'module' }).errors).toHaveLength(0);
164
+ } finally {
165
+ rmSync(root, { recursive: true, force: true });
166
+ }
167
+ });
168
+
169
+ test('provenance lands in the FILE, because the chip cannot express mixed provenance', async () => {
170
+ const { root, canvasRel } = project();
171
+ try {
172
+ const r = await explodeArtboard({
173
+ root,
174
+ canvasRel,
175
+ ...BASE,
176
+ session: mcpSession(),
177
+ resolveAssetsImpl: stubAssets({ '6:908': '/assets/deadbeef.svg' }),
178
+ });
179
+ const out = readFileSync(join(root, '.design', canvasRel), 'utf8');
180
+ // `design-system-keeper`, the critic panel and `/design:edit` read the
181
+ // FILE and never see a tree chip (DDR-219 D7).
182
+ expect(out).toContain('GENERATED BY FIGMA, NOT BY MAUDE');
183
+ expect(out).toContain('NOT reproducible');
184
+ expect(out).toContain(r.responseSha256);
185
+
186
+ const meta = JSON.parse(
187
+ readFileSync(join(root, '.design', 'ui/figma-2h6a9yug/Page 1.meta.json'), 'utf8')
188
+ );
189
+ const frames = meta.figma.frames as Array<Record<string, unknown>>;
190
+ expect(frames[0]).toMatchObject({
191
+ id: 'node-6-907',
192
+ route: 'codegen',
193
+ endpoint: 'local',
194
+ tool: 'get_design_context',
195
+ responseSha256: r.responseSha256,
196
+ });
197
+ // The sibling frame's provenance must NOT have been rewritten.
198
+ expect(frames[1].route).toBe('render');
199
+ } finally {
200
+ rmSync(root, { recursive: true, force: true });
201
+ }
202
+ });
203
+
204
+ test('the prior canvas is snapshotted before the write', async () => {
205
+ const { root, canvasRel } = project();
206
+ try {
207
+ await explodeArtboard({
208
+ root,
209
+ canvasRel,
210
+ ...BASE,
211
+ session: mcpSession(),
212
+ resolveAssetsImpl: stubAssets({ '6:908': '/assets/deadbeef.svg' }),
213
+ });
214
+ const histDir = join(root, '.design', '_history', 'ui-figma-2h6a9yug-page_1');
215
+ const files = readdirSync(histDir);
216
+ expect(files.some((f) => f.endsWith('.tsx'))).toBe(true);
217
+ const snap = readFileSync(join(histDir, files.find((f) => f.endsWith('.tsx'))!), 'utf8');
218
+ expect(snap).toBe(CANVAS);
219
+ } finally {
220
+ rmSync(root, { recursive: true, force: true });
221
+ }
222
+ });
223
+
224
+ test('--dry-run spends the codegen call and writes NOTHING', async () => {
225
+ const { root, canvasRel } = project();
226
+ try {
227
+ const r = await explodeArtboard({
228
+ root,
229
+ canvasRel,
230
+ ...BASE,
231
+ dryRun: true,
232
+ session: mcpSession(),
233
+ resolveAssetsImpl: stubAssets({ '6:908': '/assets/deadbeef.svg' }),
234
+ });
235
+ expect(r.written).toBe(false);
236
+ expect(readFileSync(join(root, '.design', canvasRel), 'utf8')).toBe(CANVAS);
237
+ } finally {
238
+ rmSync(root, { recursive: true, force: true });
239
+ }
240
+ });
241
+ });
242
+
243
+ describe('the target is never the producer’s choice (D8 / DDR-216 D3)', () => {
244
+ const cases: Array<[string, (p: ReturnType<typeof project>) => Record<string, unknown>]> = [
245
+ ['a canvas outside the design root', (_p) => ({ canvasRel: '../../../etc/passwd.tsx' })],
246
+ ['an absolute path', () => ({ canvasRel: '/etc/passwd.tsx' })],
247
+ ['a non-existent canvas', () => ({ canvasRel: 'ui/nope.tsx' })],
248
+ ['a bad artboard charset', (p) => ({ canvasRel: p.canvasRel, artboardId: '../../etc' })],
249
+ [
250
+ 'an artboard not in figma.frames[]',
251
+ (p) => ({ canvasRel: p.canvasRel, artboardId: 'node-9-9' }),
252
+ ],
253
+ ];
254
+
255
+ for (const [label, build] of cases) {
256
+ test(`refuses: ${label}`, async () => {
257
+ const p = project();
258
+ try {
259
+ await expect(
260
+ explodeArtboard({
261
+ root: p.root,
262
+ ...BASE,
263
+ canvasRel: p.canvasRel,
264
+ ...build(p),
265
+ session: mcpSession(),
266
+ resolveAssetsImpl: stubAssets({ '6:908': '/assets/deadbeef.svg' }),
267
+ })
268
+ ).rejects.toBeInstanceOf(ImportFigmaError);
269
+ } finally {
270
+ rmSync(p.root, { recursive: true, force: true });
271
+ }
272
+ });
273
+ }
274
+
275
+ test('refuses to CREATE a canvas — an explode is an edit, never a mint', async () => {
276
+ const p = project();
277
+ try {
278
+ rmSync(join(p.root, '.design', p.canvasRel));
279
+ await expect(
280
+ explodeArtboard({
281
+ root: p.root,
282
+ canvasRel: p.canvasRel,
283
+ ...BASE,
284
+ session: mcpSession(),
285
+ resolveAssetsImpl: stubAssets({ '6:908': '/assets/deadbeef.svg' }),
286
+ })
287
+ ).rejects.toThrow(/no such imported canvas/);
288
+ } finally {
289
+ rmSync(p.root, { recursive: true, force: true });
290
+ }
291
+ });
292
+
293
+ test('refuses a canvas that is not imported-figma', async () => {
294
+ const p = project({ meta: { ...META, kind: 'digital' } });
295
+ try {
296
+ await expect(
297
+ explodeArtboard({
298
+ root: p.root,
299
+ canvasRel: p.canvasRel,
300
+ ...BASE,
301
+ session: mcpSession(),
302
+ resolveAssetsImpl: stubAssets({ '6:908': '/assets/deadbeef.svg' }),
303
+ })
304
+ ).rejects.toThrow(/not an imported-figma canvas/);
305
+ } finally {
306
+ rmSync(p.root, { recursive: true, force: true });
307
+ }
308
+ });
309
+
310
+ test('refuses to re-explode an artboard that is already codegen', async () => {
311
+ const meta = structuredClone(META);
312
+ meta.figma.frames[0].route = 'codegen';
313
+ const p = project({ meta });
314
+ try {
315
+ await expect(
316
+ explodeArtboard({
317
+ root: p.root,
318
+ canvasRel: p.canvasRel,
319
+ ...BASE,
320
+ session: mcpSession(),
321
+ resolveAssetsImpl: stubAssets({ '6:908': '/assets/deadbeef.svg' }),
322
+ })
323
+ ).rejects.toThrow(/already codegen/);
324
+ } finally {
325
+ rmSync(p.root, { recursive: true, force: true });
326
+ }
327
+ });
328
+ });
329
+
330
+ describe('the open-document coupling (probe finding 1 / residual 8)', () => {
331
+ test('a node id that resolves in the WRONG document is caught by the name cross-check', async () => {
332
+ // `get_design_context` takes NO file key — it reads whatever Figma has open —
333
+ // and node ids are not unique across files. Every other control passes here:
334
+ // the id matches, the canvas is right, the write model is satisfied. Only
335
+ // the name says the document is wrong.
336
+ const p = project();
337
+ try {
338
+ const wrongDoc = MODULE.replace('data-name="Cover"', 'data-name="Checkout step 2"');
339
+ await expect(
340
+ explodeArtboard({
341
+ root: p.root,
342
+ canvasRel: p.canvasRel,
343
+ ...BASE,
344
+ session: mcpSession(wrongDoc),
345
+ resolveAssetsImpl: stubAssets(),
346
+ })
347
+ ).rejects.toThrow(/does not match this canvas/);
348
+ // Nothing was written.
349
+ expect(readFileSync(join(p.root, '.design', p.canvasRel), 'utf8')).toBe(CANVAS);
350
+ } finally {
351
+ rmSync(p.root, { recursive: true, force: true });
352
+ }
353
+ });
354
+
355
+ test('the refusal names NEITHER document’s text — stdout is code-owned (D10)', async () => {
356
+ const p = project();
357
+ try {
358
+ const wrongDoc = MODULE.replace('data-name="Cover"', 'data-name="Secret Client Project"');
359
+ await explodeArtboard({
360
+ root: p.root,
361
+ canvasRel: p.canvasRel,
362
+ ...BASE,
363
+ session: mcpSession(wrongDoc),
364
+ resolveAssetsImpl: stubAssets(),
365
+ });
366
+ throw new Error('should have refused');
367
+ } catch (err) {
368
+ expect((err as Error).message).not.toContain('Secret Client Project');
369
+ } finally {
370
+ rmSync(p.root, { recursive: true, force: true });
371
+ }
372
+ });
373
+
374
+ test('--confirm-document is the escape hatch for a frame renamed in Figma', async () => {
375
+ const p = project();
376
+ try {
377
+ const renamed = MODULE.replace('data-name="Cover"', 'data-name="Cover v2"');
378
+ const r = await explodeArtboard({
379
+ root: p.root,
380
+ canvasRel: p.canvasRel,
381
+ ...BASE,
382
+ confirmDocument: true,
383
+ session: mcpSession(renamed),
384
+ resolveAssetsImpl: stubAssets(),
385
+ });
386
+ expect(r.written).toBe(true);
387
+ } finally {
388
+ rmSync(p.root, { recursive: true, force: true });
389
+ }
390
+ });
391
+
392
+ test('a different NODE than the one requested is always a refusal', async () => {
393
+ const p = project();
394
+ try {
395
+ const otherNode = MODULE.replace('data-node-id="6:907"', 'data-node-id="9:999"');
396
+ await expect(
397
+ explodeArtboard({
398
+ root: p.root,
399
+ canvasRel: p.canvasRel,
400
+ ...BASE,
401
+ confirmDocument: true,
402
+ session: mcpSession(otherNode),
403
+ resolveAssetsImpl: stubAssets(),
404
+ })
405
+ ).rejects.toThrow(/different node/);
406
+ } finally {
407
+ rmSync(p.root, { recursive: true, force: true });
408
+ }
409
+ });
410
+ });
411
+
412
+ describe('the call ceiling is the verb’s, not the caller’s (T20b / D10)', () => {
413
+ test('one session serves one explode; a second refuses', async () => {
414
+ const p = project();
415
+ const session = mcpSession();
416
+ try {
417
+ await explodeArtboard({
418
+ root: p.root,
419
+ canvasRel: p.canvasRel,
420
+ ...BASE,
421
+ session,
422
+ resolveAssetsImpl: stubAssets(),
423
+ });
424
+ await expect(
425
+ explodeArtboard({
426
+ root: p.root,
427
+ canvasRel: p.canvasRel,
428
+ artboardId: 'node-6-910',
429
+ designRootRel: '.design',
430
+ session,
431
+ resolveAssetsImpl: stubAssets(),
432
+ })
433
+ ).rejects.toMatchObject({ kind: 'ceiling' });
434
+ } finally {
435
+ rmSync(p.root, { recursive: true, force: true });
436
+ }
437
+ });
438
+ });
@@ -0,0 +1,201 @@
1
+ // perf-canvas.mjs — deterministic large-canvas fixture generator.
2
+ //
3
+ // Produces the synthetic worst-case the render-performance work is measured
4
+ // against: N artboards on one world plane plus M annotation stickies, with NO
5
+ // filters, blends or feTurbulence. That absence is the point — the earlier
6
+ // moodboard RCA (.ai/archive/logs/rca/issue-canvas-pan-zoom-jank-large-
7
+ // moodboard.md) covered the expensive-paint regime; the failures this fixture
8
+ // reproduces (a 128-artboard onboarding flow, a sticky-dense FigJam board) are
9
+ // the opposite shape: cheap paint, huge node count. Mixing filters back in
10
+ // would re-measure the case that is already fixed.
11
+ //
12
+ // Written as .mjs rather than the .ts the plan named, because the probe
13
+ // (`_perf-probe.mjs`) runs under plain node and imports this module directly;
14
+ // bun tests import .mjs identically, so nothing is lost.
15
+ //
16
+ // Deterministic by construction — no Date.now(), no Math.random(). Two runs
17
+ // produce byte-identical files, so a re-measured baseline compares like with
18
+ // like.
19
+
20
+ import { mkdirSync, writeFileSync } from 'node:fs';
21
+ import { dirname, join } from 'node:path';
22
+
23
+ /** Grid geometry — mirrors DesignCanvas's own default grid vocabulary. */
24
+ const BOARD_W = 1280;
25
+ const BOARD_H = 800;
26
+ const GUTTER = 80;
27
+ const COLS = 8;
28
+
29
+ /** Sticky geometry, matching the imported-FigJam shape (240px square, r=8). */
30
+ const STICKY = 240;
31
+ const STICKY_GAP = 40;
32
+ const STICKY_COLS = 12;
33
+
34
+ // Fixed word pool — index-addressed, never shuffled, so labels stay stable
35
+ // across regenerations.
36
+ const WORDS = [
37
+ 'Onboarding',
38
+ 'Signup',
39
+ 'Paywall',
40
+ 'Empty state',
41
+ 'Error',
42
+ 'Success',
43
+ 'Profile',
44
+ 'Settings',
45
+ 'Search',
46
+ 'Detail',
47
+ 'Checkout',
48
+ 'Review',
49
+ ];
50
+
51
+ const STICKY_TEXT = [
52
+ 'Ship the import path',
53
+ 'Cut the cold start',
54
+ 'Fewer modal steps',
55
+ 'Copy is too long',
56
+ 'Needs an empty state',
57
+ 'Great onboarding',
58
+ 'Slow on mobile',
59
+ 'Loved the polish',
60
+ ];
61
+
62
+ /** Deterministic pseudo-shuffle: a fixed odd stride over the pool. */
63
+ function pick(pool, i) {
64
+ return pool[(i * 7) % pool.length];
65
+ }
66
+
67
+ /**
68
+ * Body markup for one artboard. Deliberately ~35 DOM nodes — a realistic mock,
69
+ * not an empty box. An empty artboard would understate reconciliation cost and
70
+ * flatter every measurement taken against this fixture.
71
+ */
72
+ function artboardBody(i) {
73
+ const rows = Array.from(
74
+ { length: 6 },
75
+ (_, r) => `
76
+ <div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
77
+ <div style={{ width: 32, height: 32, borderRadius: 6, background: 'var(--bg-2, #e8e3d8)' }} />
78
+ <div style={{ flex: 1 }}>
79
+ <div style={{ height: 10, width: '${40 + ((i + r) % 5) * 10}%', borderRadius: 3, background: 'var(--bg-3, #ddd6c8)' }} />
80
+ <div style={{ height: 8, width: '${25 + ((i + r) % 4) * 8}%', marginTop: 6, borderRadius: 3, background: 'var(--bg-2, #e8e3d8)' }} />
81
+ </div>
82
+ <div style={{ width: 56, height: 24, borderRadius: 4, background: 'var(--accent, #6b5bd6)', opacity: 0.18 }} />
83
+ </div>`
84
+ ).join('');
85
+
86
+ return `
87
+ <div style={{ padding: 32, display: 'flex', flexDirection: 'column', gap: 16 }}>
88
+ <h2 style={{ margin: 0, font: '600 24px/1.2 var(--font-sans, system-ui)' }}>${pick(WORDS, i)} ${i}</h2>
89
+ <p style={{ margin: 0, font: '400 14px/1.5 var(--font-sans, system-ui)', opacity: 0.7 }}>
90
+ Synthetic perf fixture board ${i} — no filters, no blends, cheap paint.
91
+ </p>${rows}
92
+ </div>`;
93
+ }
94
+
95
+ /** Build the canvas .tsx source for `boards` artboards. */
96
+ export function renderCanvasSource(boards) {
97
+ const items = Array.from({ length: boards }, (_, i) => {
98
+ const col = i % COLS;
99
+ const row = Math.floor(i / COLS);
100
+ const x = col * (BOARD_W + GUTTER);
101
+ const y = row * (BOARD_H + GUTTER);
102
+ return ` <DCArtboard
103
+ id="b${i}"
104
+ label="${pick(WORDS, i)} ${i}"
105
+ width={${BOARD_W}}
106
+ height={${BOARD_H}}
107
+ x={${x}}
108
+ y={${y}}
109
+ fixed
110
+ >${artboardBody(i)}
111
+ </DCArtboard>`;
112
+ }).join('\n');
113
+
114
+ return `// GENERATED by apps/studio/test/fixtures/perf-canvas.mjs — do not hand-edit.
115
+ // Synthetic render-performance fixture: ${boards} artboards, no filters/blends.
116
+ import { DCArtboard, DesignCanvas } from '@maude/canvas-lib';
117
+
118
+ export default function Canvas() {
119
+ return (
120
+ <DesignCanvas>
121
+ ${items}
122
+ </DesignCanvas>
123
+ );
124
+ }
125
+ `;
126
+ }
127
+
128
+ /** Build the `.meta.json` sidecar carrying the artboard grid positions. */
129
+ export function renderMeta(boards) {
130
+ const artboards = Array.from({ length: boards }, (_, i) => ({
131
+ id: `b${i}`,
132
+ x: (i % COLS) * (BOARD_W + GUTTER),
133
+ y: Math.floor(i / COLS) * (BOARD_H + GUTTER),
134
+ }));
135
+ return {
136
+ title: `Perf fixture (${boards} boards)`,
137
+ subtitle: 'Generated render-performance fixture — safe to delete.',
138
+ tags: ['perf-fixture'],
139
+ status: 'draft',
140
+ layout: { artboards },
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Build the annotations SVG carrying `strokes` stickies. Shape copied from a
146
+ * real imported-FigJam board so the layer exercises the same code path
147
+ * (`data-mdcc-annotations`, `data-tool="sticky"`, rect + body text per group).
148
+ */
149
+ export function renderAnnotations(strokes) {
150
+ if (strokes <= 0) return null;
151
+ const nodes = Array.from({ length: strokes }, (_, i) => {
152
+ const col = i % STICKY_COLS;
153
+ const row = Math.floor(i / STICKY_COLS);
154
+ const x = col * (STICKY + STICKY_GAP);
155
+ // Park the sticky field below the artboard grid so the two layers are
156
+ // measurable together without overlapping hit-test surfaces.
157
+ const y = 100000 + row * (STICKY + STICKY_GAP);
158
+ return (
159
+ `<g data-id="perf_${i}" data-tool="sticky" data-r="8" data-fs="14" fill="#cfc4ec">` +
160
+ `<rect x="${x}" y="${y}" width="${STICKY}" height="${STICKY}" rx="8" ry="8"/>` +
161
+ `<text data-sticky-body="1" x="${x + 12}" y="${y + 12}" font-size="14" fill="#1a1a1a" dominant-baseline="hanging">` +
162
+ `${pick(STICKY_TEXT, i)} ${i}</text></g>`
163
+ );
164
+ }).join('');
165
+ return `<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">${nodes}</svg>`;
166
+ }
167
+
168
+ /**
169
+ * Write the fixture into a design root.
170
+ *
171
+ * @param {object} opts
172
+ * @param {string} opts.designRoot Absolute path to a `.design/` directory.
173
+ * @param {number} [opts.boards] Artboard count (default 128 — the SPKIE repro).
174
+ * @param {number} [opts.strokes] Sticky count (default 150 — the Team Retro repro).
175
+ * @param {string} [opts.slug] Canvas basename (default `perf-fixture`).
176
+ * @returns {{canvasPath: string, metaPath: string, annotationsPath: string|null, canvasRel: string}}
177
+ */
178
+ export function writePerfCanvas({
179
+ designRoot,
180
+ boards = 128,
181
+ strokes = 150,
182
+ slug = 'perf-fixture',
183
+ }) {
184
+ const canvasPath = join(designRoot, 'ui', `${slug}.tsx`);
185
+ const metaPath = join(designRoot, 'ui', `${slug}.meta.json`);
186
+ mkdirSync(dirname(canvasPath), { recursive: true });
187
+
188
+ writeFileSync(canvasPath, renderCanvasSource(boards), 'utf8');
189
+ writeFileSync(metaPath, `${JSON.stringify(renderMeta(boards), null, 2)}\n`, 'utf8');
190
+
191
+ // Annotations live at the design root under the flattened `<dir>-<slug>`
192
+ // naming the annotations API uses, NOT beside the canvas.
193
+ let annotationsPath = null;
194
+ const svg = renderAnnotations(strokes);
195
+ if (svg) {
196
+ annotationsPath = join(designRoot, `ui-${slug.replace(/-/g, '_')}.annotations.svg`);
197
+ writeFileSync(annotationsPath, svg, 'utf8');
198
+ }
199
+
200
+ return { canvasPath, metaPath, annotationsPath, canvasRel: `ui/${slug}.tsx` };
201
+ }