@1agh/maude 0.40.0 → 0.42.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 (102) hide show
  1. package/apps/studio/ai-banner.tsx +2 -2
  2. package/apps/studio/annotations-layer.tsx +53 -2
  3. package/apps/studio/api.ts +504 -44
  4. package/apps/studio/artboard-marquee.tsx +1 -1
  5. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  6. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  7. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  8. package/apps/studio/bin/_html-playwright.mjs +26 -4
  9. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  10. package/apps/studio/bin/_png-playwright.mjs +15 -2
  11. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  12. package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
  13. package/apps/studio/bin/_svg-playwright.mjs +26 -4
  14. package/apps/studio/bin/annotate.mjs +576 -34
  15. package/apps/studio/bin/canvas-rects.sh +152 -0
  16. package/apps/studio/bin/fetch-asset.sh +34 -0
  17. package/apps/studio/bin/read-annotations.mjs +138 -7
  18. package/apps/studio/bin/screenshot.sh +53 -4
  19. package/apps/studio/bin/smoke.sh +42 -6
  20. package/apps/studio/build.ts +21 -0
  21. package/apps/studio/canvas-comment-mount.tsx +138 -4
  22. package/apps/studio/canvas-edit.ts +744 -11
  23. package/apps/studio/canvas-lib.tsx +219 -2
  24. package/apps/studio/canvas-shell.tsx +487 -20
  25. package/apps/studio/client/app.jsx +1476 -76
  26. package/apps/studio/client/comments-overlay.css +130 -126
  27. package/apps/studio/client/export-center.jsx +426 -0
  28. package/apps/studio/client/github.js +8 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +65 -0
  30. package/apps/studio/client/styles/4-components.css +150 -0
  31. package/apps/studio/comments-overlay.tsx +148 -41
  32. package/apps/studio/config.schema.json +2 -2
  33. package/apps/studio/context-menu.tsx +15 -5
  34. package/apps/studio/contextual-toolbar.tsx +262 -4
  35. package/apps/studio/cursors-overlay.tsx +4 -4
  36. package/apps/studio/dist/client.bundle.js +20 -20
  37. package/apps/studio/dist/comment-mount.js +59 -1
  38. package/apps/studio/dist/styles.css +1 -1
  39. package/apps/studio/dom-selection.ts +127 -1
  40. package/apps/studio/drag-state.ts +24 -0
  41. package/apps/studio/equal-spacing-detector.ts +205 -0
  42. package/apps/studio/export-dialog.tsx +19 -26
  43. package/apps/studio/exporters/_runtime.ts +104 -0
  44. package/apps/studio/exporters/html.ts +12 -20
  45. package/apps/studio/exporters/index.ts +14 -2
  46. package/apps/studio/exporters/jobs.ts +334 -0
  47. package/apps/studio/exporters/pdf.ts +16 -20
  48. package/apps/studio/exporters/png.ts +12 -20
  49. package/apps/studio/exporters/pptx.ts +22 -23
  50. package/apps/studio/exporters/scope.ts +1 -0
  51. package/apps/studio/exporters/svg.ts +14 -22
  52. package/apps/studio/exporters/video.ts +15 -17
  53. package/apps/studio/git/service.ts +3 -1
  54. package/apps/studio/history.ts +47 -1
  55. package/apps/studio/http.ts +368 -50
  56. package/apps/studio/input-router.tsx +12 -0
  57. package/apps/studio/marquee-overlay.tsx +1 -1
  58. package/apps/studio/measure-overlay.tsx +241 -0
  59. package/apps/studio/participants-chrome.tsx +3 -3
  60. package/apps/studio/server.ts +3 -1
  61. package/apps/studio/sizing-mode.ts +117 -0
  62. package/apps/studio/spacing-handles.ts +166 -0
  63. package/apps/studio/test/annotate-write.test.ts +890 -0
  64. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  65. package/apps/studio/test/canvas-edit.test.ts +50 -0
  66. package/apps/studio/test/canvas-origin-gate.test.ts +24 -0
  67. package/apps/studio/test/canvas-rects.test.ts +198 -0
  68. package/apps/studio/test/comments-overlay.test.ts +117 -0
  69. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  70. package/apps/studio/test/dom-selection.test.ts +130 -0
  71. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  72. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  73. package/apps/studio/test/element-resize.test.ts +136 -0
  74. package/apps/studio/test/element-structural-api.test.ts +360 -0
  75. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  76. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  77. package/apps/studio/test/export-center.test.tsx +287 -0
  78. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  79. package/apps/studio/test/exporters/history.test.ts +32 -3
  80. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  81. package/apps/studio/test/history-rollback.test.ts +26 -0
  82. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  83. package/apps/studio/test/read-annotations.test.ts +154 -0
  84. package/apps/studio/test/sizing-mode.test.ts +102 -0
  85. package/apps/studio/test/spacing-handles.test.ts +138 -0
  86. package/apps/studio/test/specimen-select.test.ts +88 -0
  87. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  88. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  89. package/apps/studio/tool-palette.tsx +122 -2
  90. package/apps/studio/undo-hud.tsx +2 -2
  91. package/apps/studio/use-element-resize.tsx +732 -0
  92. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  93. package/apps/studio/use-selection-set.tsx +14 -0
  94. package/apps/studio/use-spacing-handles.tsx +388 -0
  95. package/apps/studio/whats-new.json +45 -0
  96. package/apps/studio/ws.ts +6 -0
  97. package/cli/commands/design.mjs +6 -1
  98. package/cli/commands/design.test.mjs +49 -1
  99. package/cli/lib/fetch-asset.test.mjs +213 -0
  100. package/cli/lib/gitignore-block.mjs +1 -0
  101. package/package.json +8 -8
  102. package/plugins/design/dependencies.json +10 -2
@@ -13,10 +13,11 @@ import path from 'node:path';
13
13
  import JSZip from 'jszip';
14
14
 
15
15
  import { getBrowserBundle } from './_browser-bundles.ts';
16
- import { exportShimPath, resolveExportRuntime } from './_runtime.ts';
16
+ import { exportShimPath, runShim } from './_runtime.ts';
17
17
  import {
18
18
  canvasShellUrl,
19
19
  type ExportContext,
20
+ type ExportHooks,
20
21
  type ExportOptions,
21
22
  type ExportResult,
22
23
  } from './index.ts';
@@ -30,7 +31,8 @@ async function captureSvg(
30
31
  ctx: ExportContext,
31
32
  outDir: string,
32
33
  timeoutSec: number,
33
- bundlePath: string
34
+ bundlePath: string,
35
+ hooks?: ExportHooks
34
36
  ): Promise<string[]> {
35
37
  const args = [
36
38
  SVG_PLAYWRIGHT,
@@ -55,31 +57,20 @@ async function captureSvg(
55
57
  // Run via a resolved node/bun runtime so the shim's `import 'playwright'`
56
58
  // resolves against dev-server/node_modules (playwright is a devDep). `npm exec`
57
59
  // doesn't bridge the module path for ESM imports — confirmed against npm 10.x.
58
- // resolveExportRuntime() replaces a hardcoded `'node'` so a compiled binary
59
- // without `node` on PATH surfaces an actionable error, not `posix_spawn 'node'`.
60
- const proc = Bun.spawn([resolveExportRuntime(), ...args], {
60
+ // runShim resolves the runtime internally so a compiled binary without
61
+ // `node` on PATH surfaces an actionable error, not `posix_spawn 'node'`.
62
+ return runShim(args, {
61
63
  cwd: path.dirname(SVG_PLAYWRIGHT),
62
- stdout: 'pipe',
63
- stderr: 'pipe',
64
+ signal: hooks?.signal,
65
+ onProgress: hooks?.onProgress,
64
66
  });
65
- const [stdout, stderr] = await Promise.all([
66
- new Response(proc.stdout).text(),
67
- new Response(proc.stderr).text(),
68
- ]);
69
- const code = await proc.exited;
70
- if (code !== 0) {
71
- throw new Error(`_svg-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
72
- }
73
- return stdout
74
- .split('\n')
75
- .map((s) => s.trim())
76
- .filter(Boolean);
77
67
  }
78
68
 
79
69
  export async function run(
80
70
  targets: Target[],
81
71
  options: ExportOptions,
82
- ctx: ExportContext
72
+ ctx: ExportContext,
73
+ hooks?: ExportHooks
83
74
  ): Promise<ExportResult> {
84
75
  if (!targets.length) {
85
76
  return { filename: 'export.svg', contentType: 'image/svg+xml', body: new Uint8Array(0) };
@@ -96,9 +87,10 @@ export async function run(
96
87
 
97
88
  try {
98
89
  const written: string[] = [];
99
- for (const t of elementTargets) {
100
- const paths = await captureSvg(t, ctx, tmp, timeoutSec, bundlePath);
90
+ for (let i = 0; i < elementTargets.length; i += 1) {
91
+ const paths = await captureSvg(elementTargets[i], ctx, tmp, timeoutSec, bundlePath, hooks);
101
92
  written.push(...paths);
93
+ hooks?.onProgress?.({ current: i + 1, total: elementTargets.length });
102
94
  }
103
95
  if (!written.length) {
104
96
  return { filename: 'export.svg', contentType: 'image/svg+xml', body: new Uint8Array(0) };
@@ -20,10 +20,11 @@ import { tmpdir } from 'node:os';
20
20
  import path from 'node:path';
21
21
 
22
22
  import { getEncodeLibBundle, getWebRendererBundle } from './_browser-bundles.ts';
23
- import { exportShimPath, resolveExportRuntime } from './_runtime.ts';
23
+ import { exportShimPath, runShim } from './_runtime.ts';
24
24
  import {
25
25
  canvasShellUrl,
26
26
  type ExportContext,
27
+ type ExportHooks,
27
28
  type ExportOptions,
28
29
  type ExportResult,
29
30
  } from './index.ts';
@@ -47,7 +48,8 @@ async function runVideo(
47
48
  format: VideoFormat,
48
49
  targets: Target[],
49
50
  options: ExportOptions,
50
- ctx: ExportContext
51
+ ctx: ExportContext,
52
+ hooks?: ExportHooks
51
53
  ): Promise<ExportResult> {
52
54
  const el = targets.find((t): t is Extract<Target, { kind: 'element' }> => t.kind === 'element');
53
55
  if (!el) {
@@ -119,25 +121,18 @@ async function runVideo(
119
121
  }
120
122
 
121
123
  try {
122
- const proc = Bun.spawn([resolveExportRuntime(), ...args], {
124
+ const stdoutLines = await runShim(args, {
123
125
  cwd: path.dirname(VIDEO_PLAYWRIGHT),
124
- stdout: 'pipe',
125
- stderr: 'pipe',
126
+ signal: hooks?.signal,
127
+ // No MAUDE_PROGRESS line from this shim yet — frame-count progress is a
128
+ // follow-up (video scope is always a single artboard, never multi).
126
129
  });
127
- const [stdout, stderr] = await Promise.all([
128
- new Response(proc.stdout).text(),
129
- new Response(proc.stderr).text(),
130
- ]);
131
- const code = await proc.exited;
132
- if (code !== 0) {
133
- throw new Error(`_video-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
134
- }
135
130
  const body = new Uint8Array(readFileSync(outPath));
136
131
  // The container can differ from the request (mp4 → webm fallback when the
137
132
  // browser has no H.264 encoder). Read the summary line for the real ext.
138
133
  let ext: string = format;
139
134
  try {
140
- const lastLine = stdout.trim().split('\n').filter(Boolean).pop() ?? '{}';
135
+ const lastLine = stdoutLines.at(-1) ?? '{}';
141
136
  const summary = JSON.parse(lastLine) as { container?: string };
142
137
  if (summary.container) ext = summary.container;
143
138
  } catch {
@@ -174,11 +169,14 @@ function resolveFrames(options: ExportOptions, fps: number | undefined): number
174
169
  }
175
170
 
176
171
  export const mp4 = {
177
- run: (t: Target[], o: ExportOptions, c: ExportContext) => runVideo('mp4', t, o, c),
172
+ run: (t: Target[], o: ExportOptions, c: ExportContext, h?: ExportHooks) =>
173
+ runVideo('mp4', t, o, c, h),
178
174
  };
179
175
  export const webm = {
180
- run: (t: Target[], o: ExportOptions, c: ExportContext) => runVideo('webm', t, o, c),
176
+ run: (t: Target[], o: ExportOptions, c: ExportContext, h?: ExportHooks) =>
177
+ runVideo('webm', t, o, c, h),
181
178
  };
182
179
  export const gif = {
183
- run: (t: Target[], o: ExportOptions, c: ExportContext) => runVideo('gif', t, o, c),
180
+ run: (t: Target[], o: ExportOptions, c: ExportContext, h?: ExportHooks) =>
181
+ runVideo('gif', t, o, c, h),
184
182
  };
@@ -211,7 +211,9 @@ function isMaudeRuntimeState(p: string): boolean {
211
211
  return (
212
212
  /(^|\/)_(?:server|active|sync|preflight|locator|export-history)\.json$/.test(p) ||
213
213
  /(^|\/)_server\.(?:lock|log)$/.test(p) ||
214
- /(^|\/)_(?:history|trash|draw|smoke|canvas-state|state|chat|comments|untrusted)(?:\/|$)/.test(p)
214
+ /(^|\/)_(?:history|trash|draw|smoke|canvas-state|state|chat|comments|untrusted|export-jobs)(?:\/|$)/.test(
215
+ p
216
+ )
215
217
  );
216
218
  }
217
219
 
@@ -6,11 +6,24 @@
6
6
 
7
7
  import type { Dirent } from 'node:fs';
8
8
  import { existsSync } from 'node:fs';
9
- import { readdir } from 'node:fs/promises';
9
+ import { readdir, unlink } from 'node:fs/promises';
10
10
  import path from 'node:path';
11
11
 
12
12
  import type { Context } from './context.ts';
13
13
 
14
+ /**
15
+ * Per-slug snapshot cap (G3 security, DDR-152). The structural-edit routes
16
+ * (delete / insert / resize-artboard) each write a WHOLE-FILE snapshot here, and
17
+ * an untrusted active canvas can drive them in a loop — so `_history/<slug>/`
18
+ * was the uncapped disk-fill surface the adversarial review flagged. Pruning the
19
+ * oldest pairs bounds disk regardless of edit rate; 300 pairs is still a deep
20
+ * rollback runway for real work. Read lazily so tests can tune it via env.
21
+ */
22
+ function maxSnapshotsPerSlug(): number {
23
+ const env = Number(process.env.MAUDE_MAX_SNAPSHOTS);
24
+ return Number.isFinite(env) && env > 0 ? Math.floor(env) : 300;
25
+ }
26
+
14
27
  export interface Snapshot {
15
28
  slug: string;
16
29
  ts: string; // ISO
@@ -92,9 +105,42 @@ export function createHistory(ctx: Context): History {
92
105
  };
93
106
  await Bun.write(contentPath, contentBytes);
94
107
  await Bun.write(meta.metaPath, JSON.stringify({ ...meta, file }, null, 2));
108
+ await pruneSnapshots(slug).catch(() => {
109
+ /* pruning is best-effort — never fail a snapshot over it */
110
+ });
95
111
  return meta;
96
112
  }
97
113
 
114
+ /**
115
+ * Keep only the newest MAX_SNAPSHOTS_PER_SLUG snapshot pairs for a slug,
116
+ * unlinking the oldest content-blob + `.json` sidecar beyond the cap.
117
+ * Filenames are ts-derived (`tsForFilename`) so a lexical sort is chronological.
118
+ */
119
+ async function pruneSnapshots(slug: string): Promise<void> {
120
+ const dir = path.join(ctx.paths.historyDir, slug);
121
+ let names: string[];
122
+ try {
123
+ names = await readdir(dir);
124
+ } catch {
125
+ return; // no dir yet
126
+ }
127
+ const stems = names
128
+ .filter((n) => n.endsWith('.json'))
129
+ .map((n) => n.slice(0, -'.json'.length))
130
+ .sort();
131
+ const excess = stems.length - maxSnapshotsPerSlug();
132
+ if (excess <= 0) return;
133
+ for (const stem of stems.slice(0, excess)) {
134
+ for (const n of names) {
135
+ // The meta (`<stem>.json`) and its content blob (`<stem>.<ext>`) share
136
+ // the stem; the fixed-length ISO stem is never a prefix of another.
137
+ if (n === `${stem}.json` || n.startsWith(`${stem}.`)) {
138
+ await unlink(path.join(dir, n)).catch(() => {});
139
+ }
140
+ }
141
+ }
142
+ }
143
+
98
144
  async function listSnapshots(file: string): Promise<Snapshot[]> {
99
145
  const slug = fileSlug(file, ctx.paths.designRel);
100
146
  const dir = path.join(ctx.paths.historyDir, slug);