@1agh/maude 1.0.6 → 1.0.8

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 (48) hide show
  1. package/apps/studio/bin/_html-playwright.mjs +198 -19
  2. package/apps/studio/bin/_pdf-playwright.mjs +1 -0
  3. package/apps/studio/bin/_png-playwright.mjs +28 -8
  4. package/apps/studio/bin/_pptx-playwright.mjs +4 -1
  5. package/apps/studio/bin/_pw-launch.mjs +48 -0
  6. package/apps/studio/bin/_svg-playwright.mjs +63 -149
  7. package/apps/studio/bin/_video-playwright.mjs +123 -11
  8. package/apps/studio/canvas-edit.ts +33 -0
  9. package/apps/studio/canvas-lib.tsx +136 -0
  10. package/apps/studio/client/app.jsx +316 -28
  11. package/apps/studio/client/export-lane.js +191 -0
  12. package/apps/studio/dist/client.bundle.js +907 -907
  13. package/apps/studio/dist/runtime/.min-sizes.json +2 -1
  14. package/apps/studio/dist/runtime/dom-to-svg.js +31 -0
  15. package/apps/studio/export-dialog.tsx +24 -19
  16. package/apps/studio/exporters/_browser-bundles.ts +30 -0
  17. package/apps/studio/exporters/_runtime.ts +31 -1
  18. package/apps/studio/exporters/capture-chrome.ts +111 -0
  19. package/apps/studio/exporters/capture-core.ts +947 -0
  20. package/apps/studio/exporters/format-scopes.ts +88 -0
  21. package/apps/studio/exporters/html.ts +3 -2
  22. package/apps/studio/exporters/jobs.ts +180 -18
  23. package/apps/studio/exporters/pdf.ts +21 -1
  24. package/apps/studio/exporters/pptx.ts +46 -11
  25. package/apps/studio/exporters/remote.ts +94 -11
  26. package/apps/studio/exporters/scope.ts +14 -3
  27. package/apps/studio/exporters/svg.ts +17 -3
  28. package/apps/studio/exporters/video.ts +75 -8
  29. package/apps/studio/http.ts +175 -7
  30. package/apps/studio/runtime-bundle.ts +6 -0
  31. package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
  32. package/apps/studio/test/capture-core.test.ts +32 -0
  33. package/apps/studio/test/export-assemble.test.ts +53 -0
  34. package/apps/studio/test/export-browser-lane.test.ts +126 -0
  35. package/apps/studio/test/export-capture-fidelity.test.ts +146 -0
  36. package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
  37. package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
  38. package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
  39. package/apps/studio/test/exporters/jobs.test.ts +73 -0
  40. package/apps/studio/test/exporters/scope.test.ts +33 -0
  41. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  42. package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
  43. package/apps/studio/test/photo-canvas-bundle.test.ts +18 -0
  44. package/apps/studio/test/workspace-containment.test.ts +8 -0
  45. package/apps/studio/whats-new.json +18 -0
  46. package/apps/studio/workspace-mode.ts +15 -2
  47. package/package.json +8 -8
  48. package/plugins/design/templates/_shell.html +2 -1
@@ -0,0 +1,89 @@
1
+ // export-format-scope-coherence.test.ts — DDR-231 Phase 2 T4.
2
+ //
3
+ // REPRODUCED DEFECT (local, 2026-08-23, workspace-shaped studio + a real
4
+ // maude-render service): a cloud PDF or HTML export failed with
5
+ //
6
+ // render service refused the job: invalid render job
7
+ //
8
+ // `POST /_api/export-jobs {format:'pdf', scope:'project-raw'}` is accepted by
9
+ // the route (it validates `isFormat` and `isScope` INDEPENDENTLY, never the
10
+ // pair), `resolveScope` correctly returns a `file-tree` target for that scope,
11
+ // and the render service's `validBody` correctly refuses a file-tree target —
12
+ // it names repo paths that process holds no checkout for and must never read.
13
+ // Every layer did what it was specified to do; the PAIR was never legal, and
14
+ // no layer said so.
15
+ //
16
+ // The scope table lived in `client/app.jsx` and `export-dialog.tsx` and in
17
+ // neither server. It now lives in `exporters/format-scopes.ts`; these tests
18
+ // pin the table, the refusal, and — the part that actually closes the bug —
19
+ // that the two client copies have not drifted from it.
20
+
21
+ import { describe, expect, test } from 'bun:test';
22
+ import { readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+
25
+ import {
26
+ defaultScopeForFormat,
27
+ isScopeValidForFormat,
28
+ scopeRefusalMessage,
29
+ VALID_SCOPES_BY_FORMAT,
30
+ } from '../exporters/format-scopes.ts';
31
+ import { FORMATS } from '../exporters/index.ts';
32
+ import { DEV_SERVER_ROOT } from '../paths.ts';
33
+
34
+ describe('format × scope coherence', () => {
35
+ test('every known format has a scope list', () => {
36
+ for (const f of FORMATS) {
37
+ expect(VALID_SCOPES_BY_FORMAT[f]?.length ?? 0).toBeGreaterThan(0);
38
+ }
39
+ });
40
+
41
+ test('project-raw is legal for zip ONLY — it resolves to a file-tree target', () => {
42
+ for (const f of FORMATS) {
43
+ expect(isScopeValidForFormat(f, 'project-raw')).toBe(f === 'zip');
44
+ }
45
+ });
46
+
47
+ test('the exact reproduced pairs are refused', () => {
48
+ // These two are the live failures, verbatim.
49
+ expect(isScopeValidForFormat('pdf', 'project-raw')).toBe(false);
50
+ expect(isScopeValidForFormat('html', 'project-raw')).toBe(false);
51
+ // …while the shapes the dialogs really offer stay legal.
52
+ expect(isScopeValidForFormat('pdf', 'artboard')).toBe(true);
53
+ expect(isScopeValidForFormat('html', 'canvas-as-separate')).toBe(true);
54
+ expect(isScopeValidForFormat('mp4', 'artboard')).toBe(true);
55
+ });
56
+
57
+ test('the refusal names the format, the bad scope and the way out', () => {
58
+ const msg = scopeRefusalMessage('pdf', 'project-raw');
59
+ expect(msg).toContain('PDF');
60
+ expect(msg).toContain('project-raw');
61
+ expect(msg).toContain('artboard');
62
+ });
63
+
64
+ test('the default scope is the first offered one', () => {
65
+ expect(defaultScopeForFormat('zip')).toBe('project-raw');
66
+ expect(defaultScopeForFormat('pptx')).toBe('canvas-as-separate');
67
+ expect(defaultScopeForFormat('mp4')).toBe('artboard');
68
+ });
69
+ });
70
+
71
+ /**
72
+ * Both dialogs must render pickers from the shared table. A literal map left
73
+ * behind in either client is how the three copies drifted in the first place —
74
+ * `export-dialog.tsx`'s copy was missing mp4/webm/gif entirely.
75
+ */
76
+ describe('no client keeps its own copy of the scope table', () => {
77
+ const read = (...p: string[]) => readFileSync(join(DEV_SERVER_ROOT, ...p), 'utf8');
78
+
79
+ for (const [file, banned] of [
80
+ ['client/app.jsx', 'EXPORT_VALID_SCOPES = {'],
81
+ ['export-dialog.tsx', 'VALID_SCOPES_PER_FORMAT: Record'],
82
+ ] as const) {
83
+ test(`${file} sources its scopes from exporters/format-scopes.ts`, () => {
84
+ const src = read(...file.split('/'));
85
+ expect(src).not.toContain(banned);
86
+ expect(src).toContain('format-scopes');
87
+ });
88
+ }
89
+ });
@@ -267,3 +267,76 @@ describe('exporters/jobs — byte retrieval + eviction', () => {
267
267
  }
268
268
  });
269
269
  });
270
+
271
+ // DDR-232 follow-up — the cloud video export that "didn't answer" after 4½
272
+ // minutes. Bun's `fetch` carries its OWN default ceiling of 300 s, independent
273
+ // of the AbortSignal, and rejects with a bare TimeoutError that renderRemotely's
274
+ // catch reported as a waking service. `timeout: false` lifts it (measured:
275
+ // a 315 s response then arrives; without it the same fetch rejects at 300 s
276
+ // exactly). A behavioural test would take five minutes per run, so this pins
277
+ // the source instead — remove the option and a real video job silently
278
+ // re-inherits the ceiling.
279
+ describe('renderRemotely — no second timeout under the job timeout', () => {
280
+ test("the render fetch disables Bun's default 300 s fetch ceiling", async () => {
281
+ const { readFileSync } = await import('node:fs');
282
+ const { join } = await import('node:path');
283
+ const src = readFileSync(join(import.meta.dir, '..', '..', 'exporters', 'remote.ts'), 'utf8');
284
+ const call = src.slice(
285
+ src.indexOf('${service.url}/render') - 600,
286
+ src.indexOf('${service.url}/render') + 200
287
+ );
288
+ expect(call).toContain('timeout: false');
289
+ });
290
+ });
291
+
292
+ // Security review F2 — a viewer flooding the browser-lane ledger must not evict
293
+ // another member's real export bytes. `recordBrowserExport` fabricates a
294
+ // `done` row stamped now (top of the sort); before the fix, `persistAndEvict`
295
+ // ranked ALL finished jobs together, so 20 byteless browser rows pushed a real
296
+ // job past HISTORY_DEPTH and `rm`'d its bytes — an append that was secretly a
297
+ // delete primitive, reachable by the lowest-privilege role.
298
+ describe('exporters/jobs — browser-lane rows never evict real job bytes (F2)', () => {
299
+ test('a real job stays downloadable after a flood of recordBrowserExport rows', async () => {
300
+ const { root } = makeSandbox();
301
+ const port = nextPort();
302
+ const proc = await bootServer(root, port, { MAUDE_EXPORT_MAX_QUEUED: '30' });
303
+ try {
304
+ // A real completed export with bytes on disk.
305
+ const r0 = await fetch(`http://localhost:${port}/_api/export-jobs`, {
306
+ method: 'POST',
307
+ headers: { 'content-type': 'application/json' },
308
+ body: JSON.stringify({ format: 'zip', scope: 'project-raw' }),
309
+ });
310
+ const { jobId } = (await r0.json()) as { jobId: string };
311
+ let done = false;
312
+ const deadline = Date.now() + 4000;
313
+ while (Date.now() < deadline && !done) {
314
+ const jobs = await listJobs(port);
315
+ done = jobs.find((j) => j.id === jobId)?.status === 'done';
316
+ if (!done) await Bun.sleep(15);
317
+ }
318
+ expect(done).toBe(true);
319
+ expect(
320
+ (await fetch(`http://localhost:${port}/_api/export-jobs/download?id=${jobId}`)).status
321
+ ).toBe(200);
322
+
323
+ // Flood: 30 browser-lane ledger rows (what a viewer POSTs), well past the
324
+ // 20-row cap — enough to have evicted the real job under the old logic.
325
+ for (let i = 0; i < 30; i += 1) {
326
+ const rr = await fetch(`http://localhost:${port}/_api/export-history`, {
327
+ method: 'POST',
328
+ headers: { 'content-type': 'application/json', origin: `http://localhost:${port}` },
329
+ body: JSON.stringify({ format: 'png', scope: 'artboard', filename: `flood-${i}.png` }),
330
+ });
331
+ expect(rr.status).toBe(201);
332
+ }
333
+
334
+ // The real job's bytes must still be there.
335
+ const dl = await fetch(`http://localhost:${port}/_api/export-jobs/download?id=${jobId}`);
336
+ expect(dl.status).toBe(200);
337
+ expect((await dl.arrayBuffer()).byteLength).toBeGreaterThan(0);
338
+ } finally {
339
+ await killProc(proc);
340
+ }
341
+ });
342
+ });
@@ -257,3 +257,36 @@ describe('resolveScope — project-raw', () => {
257
257
  expect(targets).toEqual([]);
258
258
  });
259
259
  });
260
+
261
+ // DDR-232 follow-up — the live dialog's canvas file beats the persisted
262
+ // `_active.json`: a job submitted right after a tab switch used to resolve
263
+ // against the PREVIOUS canvas (with the new canvas's artboardId — a selector
264
+ // that never appears; a 60 s waitFor timeout on the worker).
265
+ describe('canvasFile hint precedence', () => {
266
+ test('options.canvasFile wins over activeJson.active', async () => {
267
+ const targets = await resolveScope({
268
+ scope: 'artboard',
269
+ activeJson: { active: '.design/ui/stale-previous.tsx', selected: null },
270
+ designRoot: '/abs/.design',
271
+ options: { canvasFile: '.design/ui/the-open-one.tsx', artboardId: 'hero' },
272
+ });
273
+ expect(targets).toEqual([
274
+ {
275
+ kind: 'element',
276
+ cssPath: '[data-dc-screen="hero"]',
277
+ canvasSlug: 'ui-the-open-one',
278
+ file: '.design/ui/the-open-one.tsx',
279
+ widen: false,
280
+ },
281
+ ]);
282
+ });
283
+ test('canvasFile alone is enough — a null active no longer empties the job', async () => {
284
+ const targets = await resolveScope({
285
+ scope: 'artboard',
286
+ activeJson: { active: null, selected: null },
287
+ designRoot: '/abs/.design',
288
+ options: { canvasFile: '.design/ui/open.tsx', artboardId: 'a1' },
289
+ });
290
+ expect(targets.length).toBe(1);
291
+ });
292
+ });
@@ -324,3 +324,36 @@ describe('pdf-print-boxes — applyPageFit (non-print artboard scale-to-paper)',
324
324
  expect(doc.getPage(0).getSize()).toEqual({ width: 100, height: 100 });
325
325
  });
326
326
  });
327
+
328
+ // DDR-232 follow-up — the WORKER-lane print gap. pdf.ts read the artboard's
329
+ // `print` prop from the canvas source ON DISK; the render worker holds no
330
+ // checkout (DDR-230 §1), so every cloud print PDF silently came back with no
331
+ // BleedBox/TrimBox and no marks — the fall-through was "not a print artboard".
332
+ // The cell now resolves every print artboard's prop up front
333
+ // (readAllArtboardPrintProps) and ships it in the job as `options.printProps`.
334
+ describe('readAllArtboardPrintProps — what the cell ships to the worker', () => {
335
+ const SRC = `import { DesignCanvas, DCSection, DCArtboard } from "@maude/canvas-lib";
336
+ export default function C() {
337
+ return (
338
+ <DesignCanvas>
339
+ <DCSection id="s" title="t">
340
+ <DCArtboard id="screen" width={400} height={300}><div/></DCArtboard>
341
+ <DCArtboard id="flyer" kind="print" print={{ paper: "a6", bleedMm: 3 }} width={397} height={559}><div/></DCArtboard>
342
+ <DCArtboard id="poster" kind="print" print={{ paper: "a3" }} width={1123} height={1587}><div/></DCArtboard>
343
+ </DCSection>
344
+ </DesignCanvas>
345
+ );
346
+ }
347
+ `;
348
+ test('collects every print artboard by id and skips screen artboards', async () => {
349
+ const { readAllArtboardPrintProps } = await import('../canvas-edit.ts');
350
+ const props = readAllArtboardPrintProps('/x/c.tsx', SRC);
351
+ expect(Object.keys(props).sort()).toEqual(['flyer', 'poster']);
352
+ expect(props.flyer).toEqual({ paper: 'a6', bleedMm: 3 });
353
+ expect(props.poster).toEqual({ paper: 'a3' });
354
+ });
355
+ test('an unparsable canvas contributes nothing rather than throwing', async () => {
356
+ const { readAllArtboardPrintProps } = await import('../canvas-edit.ts');
357
+ expect(readAllArtboardPrintProps('/x/c.tsx', 'export default <<<')).toEqual({});
358
+ });
359
+ });
@@ -59,3 +59,21 @@ describe('lazy-bundle guarantee — <PhotoLayer> keeps pixi out of the eager gra
59
59
  expect(js.includes('isDefaultEdit') || js.includes('PhotoLayer')).toBe(true);
60
60
  });
61
61
  });
62
+
63
+ describe('lazy-bundle guarantee — the export-capture bridge keeps dom-to-svg out of the eager graph', () => {
64
+ // DDR-231: every canvas mounts useExportCaptureBridge (via DesignCanvas), so
65
+ // an EAGER dom-to-svg import would tax every canvas load with the ~110 KB
66
+ // bundle. Same fragility as pixi: canvas builds run `splitting: false`, and
67
+ // a refactor that turns the bridge's `await import('dom-to-svg')` static
68
+ // silently re-breaks this.
69
+ test('a plain canvas has NO eager `from "dom-to-svg"` import', async () => {
70
+ const js = await buildCanvas(CANVAS);
71
+ const eager = [...js.matchAll(/import\s[^;]*?from\s*["']dom-to-svg["']/g)];
72
+ expect(eager.length).toBe(0);
73
+ });
74
+
75
+ test('dom-to-svg is reachable only via a lazy runtime import("dom-to-svg")', async () => {
76
+ const js = await buildCanvas(CANVAS);
77
+ expect(/import\(\s*["']dom-to-svg["']\s*\)/.test(js)).toBe(true);
78
+ });
79
+ });
@@ -327,14 +327,22 @@ describe('pruneForWorkspace — how a cell can boot at all', () => {
327
327
  '/_api/export-jobs': 'j',
328
328
  '/_api/export-jobs/download': 'd',
329
329
  '/_api/export-history': 'h',
330
+ // DDR-231 — the browser lane's assemble half: pure data (client-captured
331
+ // PNGs) → PptxGenJS in-cell; nothing evaluates, same class as zip.
332
+ '/_api/export-assemble': 'a',
333
+ // DDR-231 T7 — warm-up ping: proxies GET /_health to the render service
334
+ // so the container wakes while the member is still picking options.
335
+ '/_api/export-warmup': 'w',
330
336
  '/_api/export-next-phase-surprise': 'f',
331
337
  '/_health': 'ok',
332
338
  };
333
339
  const { routes, removed } = pruneForWorkspace(table);
334
340
  expect(Object.keys(routes).sort()).toEqual([
341
+ '/_api/export-assemble',
335
342
  '/_api/export-history',
336
343
  '/_api/export-jobs',
337
344
  '/_api/export-jobs/download',
345
+ '/_api/export-warmup',
338
346
  '/_health',
339
347
  ]);
340
348
  expect(removed).toEqual(['/_api/export', '/_api/export-next-phase-surprise']);
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "exports-match-what-you-see",
6
+ "version": "1.0.8",
7
+ "date": "2026-08-24",
8
+ "kind": "improvement",
9
+ "title": "Exports now match what you see",
10
+ "summary": "Cloud and in-browser exports are now faithful to your canvas across every format — fonts, photo filters and duotones, gradients and full-bleed images all come through, and PNG, SVG, PPTX, PDF and HTML export fully self-contained. Video (MP4, WebM, GIF) now exports reliably from a hosted workspace too.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "instant-exports-in-your-browser",
15
+ "version": "1.0.7",
16
+ "date": "2026-08-23",
17
+ "kind": "feature",
18
+ "title": "Instant exports, right in your browser",
19
+ "summary": "In a hosted workspace, PNG and SVG of the artboard you're looking at — and the whole PPTX deck — now export straight from your browser in seconds. No render service, no waiting for a container to wake up. Video and PDF still render on the service, which now warms up the moment you open the export dialog.",
20
+ "surface": "design-ui"
21
+ },
4
22
  {
5
23
  "id": "cloud-exports-actually-arrive",
6
24
  "version": "1.0.6",
@@ -98,8 +98,21 @@ export const FORBIDDEN_ROUTE_PREFIXES: ReadonlyArray<{
98
98
  // The job lane: enqueue / list+stream / history. No evaluation happens
99
99
  // in-cell — browser formats dispatch to maude-render (exporters/jobs.ts
100
100
  // render lane), zip runs browser-free, and lane `none` refuses with a
101
- // remedy. DDR amending DDR-209 A′1 records the contract.
102
- except: ['/_api/export-jobs', '/_api/export-jobs/download', '/_api/export-history'],
101
+ // remedy. DDR-230 (amending DDR-209 A′1) records the contract.
102
+ // `/_api/export-assemble` (DDR-231): the browser lane's assemble half —
103
+ // the MEMBER's browser captured the artboard PNGs, the cell composes the
104
+ // deck with PptxGenJS (pure JS over pure data, the zip containment
105
+ // class). Nothing evaluates here either.
106
+ // `/_api/export-warmup` (DDR-231 T7): proxies GET /_health to the render
107
+ // service so the container wakes while the member picks export options —
108
+ // no job body, no evaluation, no secret in the response.
109
+ except: [
110
+ '/_api/export-jobs',
111
+ '/_api/export-jobs/download',
112
+ '/_api/export-history',
113
+ '/_api/export-assemble',
114
+ '/_api/export-warmup',
115
+ ],
103
116
  },
104
117
  {
105
118
  prefix: '/_api/generate',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -54,13 +54,13 @@
54
54
  "test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
55
55
  },
56
56
  "optionalDependencies": {
57
- "@1agh/maude-darwin-arm64": "1.0.6",
58
- "@1agh/maude-darwin-x64": "1.0.6",
59
- "@1agh/maude-linux-arm64": "1.0.6",
60
- "@1agh/maude-linux-arm64-musl": "1.0.6",
61
- "@1agh/maude-linux-x64": "1.0.6",
62
- "@1agh/maude-linux-x64-musl": "1.0.6",
63
- "@1agh/maude-win32-x64": "1.0.6"
57
+ "@1agh/maude-darwin-arm64": "1.0.8",
58
+ "@1agh/maude-darwin-x64": "1.0.8",
59
+ "@1agh/maude-linux-arm64": "1.0.8",
60
+ "@1agh/maude-linux-arm64-musl": "1.0.8",
61
+ "@1agh/maude-linux-x64": "1.0.8",
62
+ "@1agh/maude-linux-x64-musl": "1.0.8",
63
+ "@1agh/maude-win32-x64": "1.0.8"
64
64
  },
65
65
  "files": [
66
66
  "cli",
@@ -156,7 +156,8 @@
156
156
  "@remotion/transitions/wipe": "./_canvas-runtime/@remotion_transitions_wipe.js",
157
157
  "@remotion/transitions/flip": "./_canvas-runtime/@remotion_transitions_flip.js",
158
158
  "@remotion/transitions/clock-wipe": "./_canvas-runtime/@remotion_transitions_clock-wipe.js",
159
- "@remotion/transitions/none": "./_canvas-runtime/@remotion_transitions_none.js"
159
+ "@remotion/transitions/none": "./_canvas-runtime/@remotion_transitions_none.js",
160
+ "dom-to-svg": "./_canvas-runtime/dom-to-svg.js"
160
161
  }
161
162
  }
162
163
  </script>