@1agh/maude 1.0.7 → 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 (34) 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 +23 -6
  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 +45 -12
  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 +7 -0
  10. package/apps/studio/client/app.jsx +84 -23
  11. package/apps/studio/client/export-lane.js +37 -2
  12. package/apps/studio/dist/client.bundle.js +601 -601
  13. package/apps/studio/export-dialog.tsx +24 -19
  14. package/apps/studio/exporters/_runtime.ts +31 -1
  15. package/apps/studio/exporters/capture-chrome.ts +111 -0
  16. package/apps/studio/exporters/capture-core.ts +659 -3
  17. package/apps/studio/exporters/format-scopes.ts +88 -0
  18. package/apps/studio/exporters/html.ts +3 -2
  19. package/apps/studio/exporters/jobs.ts +180 -18
  20. package/apps/studio/exporters/pdf.ts +21 -1
  21. package/apps/studio/exporters/remote.ts +73 -4
  22. package/apps/studio/exporters/scope.ts +14 -3
  23. package/apps/studio/exporters/video.ts +75 -8
  24. package/apps/studio/http.ts +74 -7
  25. package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
  26. package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
  27. package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
  28. package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
  29. package/apps/studio/test/exporters/jobs.test.ts +73 -0
  30. package/apps/studio/test/exporters/scope.test.ts +33 -0
  31. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  32. package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
  33. package/apps/studio/whats-new.json +9 -0
  34. package/package.json +8 -8
@@ -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
+ });
@@ -1,6 +1,15 @@
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
+ },
4
13
  {
5
14
  "id": "instant-exports-in-your-browser",
6
15
  "version": "1.0.7",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "1.0.7",
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.7",
58
- "@1agh/maude-darwin-x64": "1.0.7",
59
- "@1agh/maude-linux-arm64": "1.0.7",
60
- "@1agh/maude-linux-arm64-musl": "1.0.7",
61
- "@1agh/maude-linux-x64": "1.0.7",
62
- "@1agh/maude-linux-x64-musl": "1.0.7",
63
- "@1agh/maude-win32-x64": "1.0.7"
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",