@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
@@ -146,6 +146,19 @@ export function selectorIndex(doc: Document, selector: string, el: Element | nul
146
146
  return 0;
147
147
  }
148
148
 
149
+ /**
150
+ * GLOBAL occurrence index of `el` among EVERY node in the document that shares
151
+ * its `data-cd-id` — the DOM instance index the server-side reused-component
152
+ * usage resolver (`resolveUsageId`) expects, in source order. Distinct from a
153
+ * `Selection.index` (which counts within an artboard-SCOPED selector). Used to
154
+ * route a whole-instance move/resize per-occurrence (Stage H3) so it stays local
155
+ * to the dragged instance. Matches the reorder drag's own snapshot occurrence.
156
+ */
157
+ export function globalCdOccurrence(doc: Document, cdId: string, el: Element | null): number {
158
+ if (!cdId || !el) return 0;
159
+ return selectorIndex(doc, `[data-cd-id="${cssEscape(cdId)}"]`, el);
160
+ }
161
+
149
162
  /**
150
163
  * Resolve a stored Selection to its live element, artboard-scoped. Prefers the
151
164
  * id+artboardId scoped selector (the robust path), then the stored `selector`
@@ -175,6 +188,63 @@ export function resolveSelectionEl(
175
188
  return null;
176
189
  }
177
190
 
191
+ /**
192
+ * Length of the trailing run `a` and `b` share, walked from the end. `domPath()`
193
+ * hops carry no positional (nth-child) info, so a shared suffix survives a
194
+ * sibling insertion/removal anywhere earlier in the tree — exactly the DDR-019
195
+ * `data-cd-id` renumbering case this fallback exists for.
196
+ */
197
+ function matchingSuffixLength(a: string[], b: string[]): number {
198
+ let n = 0;
199
+ const max = Math.min(a.length, b.length);
200
+ for (let i = 1; i <= max; i++) {
201
+ if (a[a.length - i] !== b[b.length - i]) break;
202
+ n++;
203
+ }
204
+ return n;
205
+ }
206
+
207
+ /**
208
+ * Best-effort structural fallback for when a stored `data-cd-id` selector no
209
+ * longer identifies the intended element. A canvas rewrite (`/design:edit`
210
+ * regenerating JSX) renumbers `data-cd-id` — it's an AST-position fingerprint,
211
+ * not a stable identity (DDR-019) — so the id can end up on an unrelated
212
+ * element, or on none at all. This scores every stamped element in the
213
+ * artboard by how much of its live `domPath()` matches the STORED path
214
+ * (as a trailing run) plus authored-class overlap, and requires the leaf tag
215
+ * to match. Returns null when nothing scores above zero (no plausible match —
216
+ * the caller should treat the target as gone).
217
+ */
218
+ export function resolveByDomPath(
219
+ doc: Document,
220
+ opts: { artboardId?: string | null; tag?: string; classes?: string; dom_path?: string[] }
221
+ ): Element | null {
222
+ const storedPath = opts.dom_path;
223
+ const wantTag = (opts.tag || '').toLowerCase();
224
+ if (!storedPath || storedPath.length === 0 || !wantTag) return null;
225
+ let scope: ParentNode = doc;
226
+ if (opts.artboardId) {
227
+ const artboard = doc.querySelector(`[data-dc-screen="${opts.artboardId}"]`);
228
+ if (artboard) scope = artboard;
229
+ }
230
+ const wantClasses = (opts.classes || '').split(/\s+/).filter(Boolean);
231
+ let best: Element | null = null;
232
+ let bestScore = 0;
233
+ for (const el of Array.from(scope.querySelectorAll('[data-cd-id]'))) {
234
+ if (el.tagName.toLowerCase() !== wantTag) continue;
235
+ const suffix = matchingSuffixLength(domPath(el), storedPath);
236
+ if (suffix === 0) continue;
237
+ const liveClasses = realClasses(el).split(/\s+/).filter(Boolean);
238
+ const overlap = wantClasses.filter((c) => liveClasses.includes(c)).length;
239
+ const score = suffix * 10 + overlap;
240
+ if (score > bestScore) {
241
+ bestScore = score;
242
+ best = el;
243
+ }
244
+ }
245
+ return best;
246
+ }
247
+
178
248
  /**
179
249
  * Phase 12.2 — style maps for the CSS-knob properties. `authored` is what the
180
250
  * element sets INLINE (React renders `style={{padding:8}}` → `style="padding:8px"`),
@@ -193,9 +263,18 @@ const KNOB_PROPS = [
193
263
  // Layout
194
264
  'display',
195
265
  'flex-direction',
266
+ 'flex-wrap',
196
267
  'align-items',
197
268
  'justify-content',
198
269
  'gap',
270
+ // Stage M — flex-CHILD props (sizing mode Fill + the Auto-layout child rows).
271
+ // Shown only when the PARENT is flex (Selection.parentDisplay); captured here so
272
+ // they round-trip. `flex` shorthand is listed for customStyles-exclusion only.
273
+ 'flex',
274
+ 'flex-grow',
275
+ 'flex-shrink',
276
+ 'flex-basis',
277
+ 'align-self',
199
278
  // Typography
200
279
  'font-family',
201
280
  'color',
@@ -219,7 +298,10 @@ const KNOB_PROPS = [
219
298
  // Size
220
299
  'width',
221
300
  'height',
301
+ 'min-width',
302
+ 'min-height',
222
303
  'max-width',
304
+ 'max-height',
223
305
  // Appearance
224
306
  'background-color',
225
307
  'border-radius',
@@ -232,6 +314,32 @@ const KNOB_PROPS = [
232
314
  'border-color',
233
315
  'box-shadow',
234
316
  'opacity',
317
+ // feature-element-editing-robustness Stage B — promotes DDR-104 §3's OUT-list
318
+ // into curated rows (superseded by the Stage-G DDR). Adding them here captures
319
+ // their authored/computed values for the new panel controls AND moves them out
320
+ // of the Advanced "customStyles" hatch (a canvas that carried one as a raw
321
+ // custom prop now surfaces it in its curated row instead).
322
+ // Position + stacking
323
+ 'position',
324
+ 'top',
325
+ 'right',
326
+ 'bottom',
327
+ 'left',
328
+ 'z-index',
329
+ // Transform
330
+ 'transform',
331
+ 'transform-origin',
332
+ // Typography (extra)
333
+ 'font-style',
334
+ 'text-transform',
335
+ 'text-decoration',
336
+ 'white-space',
337
+ // Overflow
338
+ 'overflow',
339
+ // Media framing
340
+ 'object-fit',
341
+ 'aspect-ratio',
342
+ 'object-position',
235
343
  ] as const;
236
344
 
237
345
  // Phase 12.3 — HTML attributes the custom-attribute hatch may have written, so a
@@ -245,6 +353,8 @@ function styleMapsFor(el: Element | null): {
245
353
  computed: Record<string, string>;
246
354
  customStyles: Record<string, string>;
247
355
  attrs: Record<string, string>;
356
+ parentDisplay?: string;
357
+ parentFlexDirection?: string;
248
358
  } {
249
359
  if (!el || typeof window === 'undefined' || !window.getComputedStyle) {
250
360
  return { authored: {}, computed: {}, customStyles: {}, attrs: {} };
@@ -283,7 +393,16 @@ function styleMapsFor(el: Element | null): {
283
393
  if (ATTR_SKIP.test(a.name)) continue;
284
394
  attrs[a.name] = a.value;
285
395
  }
286
- return { authored, computed, customStyles, attrs };
396
+ // Stage M parent's layout context (for the Fixed/Hug/Fill sizing control +
397
+ // flex-child row gating). Read here because the shell can't reach the
398
+ // cross-origin iframe to compute it after selection.
399
+ const parent = (el as HTMLElement).parentElement;
400
+ const parentDisplay = parent ? window.getComputedStyle(parent).display : '';
401
+ const parentFlexDirection =
402
+ parent && (parentDisplay === 'flex' || parentDisplay === 'inline-flex')
403
+ ? window.getComputedStyle(parent).flexDirection
404
+ : '';
405
+ return { authored, computed, customStyles, attrs, parentDisplay, parentFlexDirection };
287
406
  } catch {
288
407
  return { authored: {}, computed: {}, customStyles: {}, attrs: {} };
289
408
  }
@@ -336,6 +455,13 @@ export function hoverTargetToSelection(target: HoverTarget, file?: string): Sele
336
455
  h: Math.round(rect.height),
337
456
  }
338
457
  : null,
458
+ // WORLD-unit size — `offsetWidth`/`offsetHeight` are the element's own local
459
+ // pixel box, unaffected by an ancestor's `.dc-world` zoom transform (unlike
460
+ // `bounds`, which is the SCREEN rect and lies at any zoom other than 100%).
461
+ // The Inspector's artboard-resize fields (Stage D4 tail) need the true
462
+ // JSX-authored width/height to pre-fill correctly regardless of zoom.
463
+ worldW: el instanceof HTMLElement ? Math.round(el.offsetWidth) : undefined,
464
+ worldH: el instanceof HTMLElement ? Math.round(el.offsetHeight) : undefined,
339
465
  html: el ? (el.outerHTML ?? '').slice(0, 4000) : '',
340
466
  ...styleMapsFor(el),
341
467
  };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @file drag-state.ts
3
+ * @purpose Tiny shared module-scope flag: is an in-canvas ELEMENT drag
4
+ * (`ReorderDrag` — reorder or out-of-flow reposition) currently
5
+ * active? Consulted by per-frame rAF overlays (`ElementResizeOverlay`,
6
+ * `SpacingHandlesOverlay`) so they can skip their own
7
+ * getBoundingClientRect/getComputedStyle work while the drag's OWN
8
+ * live-preview chrome is what matters — avoids compounding rAF cost
9
+ * during the single most DOM-churn-heavy gesture the canvas has
10
+ * (dogfood 2026-07-07: "hrozně zavaší ten toolbar" during reorder).
11
+ * A standalone module (not exported from canvas-shell.tsx) so the
12
+ * overlay files can import it without a circular import back into
13
+ * canvas-shell.tsx (which imports THEM).
14
+ */
15
+
16
+ let active = false;
17
+
18
+ export function setElementDragActive(v: boolean): void {
19
+ active = v;
20
+ }
21
+
22
+ export function isElementDragActive(): boolean {
23
+ return active;
24
+ }
@@ -96,3 +96,208 @@ export function detectEqualSpacing(
96
96
 
97
97
  return { axis, gapPx: median, midpoints };
98
98
  }
99
+
100
+ // ─────────────────────────────────────────────────────────────────────────────
101
+ // Task L7 — Alt-hover distance measurement between exactly TWO rects (the
102
+ // selected element + whatever's hovered). Same pairwise-gap formula as
103
+ // `detectEqualSpacing`'s loop body, but without its 3-rect / equal-tolerance
104
+ // preconditions, and shaped for line-painting rather than midpoint dots.
105
+
106
+ export interface PairGap {
107
+ axis: SpacingAxis;
108
+ /** The gap between the two rects' facing edges, in the caller's coord space. */
109
+ gap: number;
110
+ /** Leading edge of the gap span — `left` for an x-axis line, `top` for y. */
111
+ from: number;
112
+ /** Perpendicular-axis center of the pair — `top` for an x-axis line
113
+ * (vertically centers it between the two rects), `left` for y. */
114
+ cross: number;
115
+ }
116
+
117
+ /**
118
+ * Distance between two rects along one axis. Returns `null` when they
119
+ * overlap on that axis (no meaningful gap — e.g. two rects stacked only on
120
+ * y have no x-axis gap to show).
121
+ */
122
+ export function computePairGap(a: Rect, b: Rect, axis: SpacingAxis): PairGap | null {
123
+ const [first, second] =
124
+ axis === 'x' ? (a.x <= b.x ? [a, b] : [b, a]) : a.y <= b.y ? [a, b] : [b, a];
125
+ if (axis === 'x') {
126
+ const gap = second.x - (first.x + first.w);
127
+ if (gap <= 0) return null;
128
+ return {
129
+ axis,
130
+ gap,
131
+ from: first.x + first.w,
132
+ cross: (first.y + first.h / 2 + second.y + second.h / 2) / 2,
133
+ };
134
+ }
135
+ const gap = second.y - (first.y + first.h);
136
+ if (gap <= 0) return null;
137
+ return {
138
+ axis,
139
+ gap,
140
+ from: first.y + first.h,
141
+ cross: (first.x + first.w / 2 + second.x + second.w / 2) / 2,
142
+ };
143
+ }
144
+
145
+ // ─────────────────────────────────────────────────────────────────────────────
146
+ // Task L5 — distribute / align / tidy-up for a multi-selection of ELEMENTS.
147
+ //
148
+ // These mirror the artboard `distributeArtboards`/`alignArtboards` algorithms
149
+ // in canvas-shell.tsx verbatim, generalized from `ArtboardRect` to any rect
150
+ // set. Kept pure/DOM-free like `detectEqualSpacing` above — the caller
151
+ // resolves DOM rects, calls these, then posts one `reposition-request` per
152
+ // moved rect through the existing element-drag write lane (no new endpoint).
153
+ //
154
+ // `key` is an OPAQUE caller-supplied correlation token (not a `data-cd-id`) —
155
+ // a shared-component instance's cd-id can repeat across selections
156
+ // (disambiguated only by occurrence index), so matching results back to the
157
+ // caller's own bookkeeping by `id` would collide. Callers pass e.g. the
158
+ // selection's array index as `key` and zip the result back themselves.
159
+
160
+ export interface KeyedRect extends Rect {
161
+ key: string;
162
+ }
163
+
164
+ export type AlignAxisMode = 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y';
165
+
166
+ export interface MovedRect {
167
+ key: string;
168
+ x: number;
169
+ y: number;
170
+ }
171
+
172
+ /**
173
+ * Align 2+ rects to a common edge/midline of their union bbox. Returns only
174
+ * the rects whose position actually changes (rounded-equal = no-op, skipped).
175
+ */
176
+ export function computeAlign(rects: KeyedRect[], mode: AlignAxisMode): MovedRect[] {
177
+ if (rects.length < 2) return [];
178
+ let xMin = Number.POSITIVE_INFINITY;
179
+ let yMin = Number.POSITIVE_INFINITY;
180
+ let xMax = Number.NEGATIVE_INFINITY;
181
+ let yMax = Number.NEGATIVE_INFINITY;
182
+ for (const r of rects) {
183
+ if (r.x < xMin) xMin = r.x;
184
+ if (r.y < yMin) yMin = r.y;
185
+ if (r.x + r.w > xMax) xMax = r.x + r.w;
186
+ if (r.y + r.h > yMax) yMax = r.y + r.h;
187
+ }
188
+ const cx = (xMin + xMax) / 2;
189
+ const cy = (yMin + yMax) / 2;
190
+ const moved: MovedRect[] = [];
191
+ for (const r of rects) {
192
+ let nx = r.x;
193
+ let ny = r.y;
194
+ switch (mode) {
195
+ case 'left':
196
+ nx = xMin;
197
+ break;
198
+ case 'right':
199
+ nx = xMax - r.w;
200
+ break;
201
+ case 'center-x':
202
+ nx = cx - r.w / 2;
203
+ break;
204
+ case 'top':
205
+ ny = yMin;
206
+ break;
207
+ case 'bottom':
208
+ ny = yMax - r.h;
209
+ break;
210
+ case 'center-y':
211
+ ny = cy - r.h / 2;
212
+ break;
213
+ }
214
+ if (Math.round(nx) === Math.round(r.x) && Math.round(ny) === Math.round(r.y)) continue;
215
+ moved.push({ key: r.key, x: Math.round(nx), y: Math.round(ny) });
216
+ }
217
+ return moved;
218
+ }
219
+
220
+ /**
221
+ * Distribute 3+ rects with equal gaps on one axis. Sorts by leading edge,
222
+ * holds the first + last fixed, redistributes the middle ones so the trailing
223
+ * → next-leading gap is equal everywhere.
224
+ */
225
+ export function computeDistribute(rects: KeyedRect[], axis: SpacingAxis): MovedRect[] {
226
+ if (rects.length < 3) return [];
227
+ const sorted = [...rects].sort((a, b) => (axis === 'x' ? a.x - b.x : a.y - b.y));
228
+ const first = sorted[0];
229
+ const last = sorted[sorted.length - 1];
230
+ if (!first || !last) return [];
231
+ const sideLen = (r: KeyedRect) => (axis === 'x' ? r.w : r.h);
232
+ const totalSides = sorted.reduce((acc, r) => acc + sideLen(r), 0);
233
+ const span = (axis === 'x' ? last.x + last.w - first.x : last.y + last.h - first.y) - totalSides;
234
+ const gap = span / (sorted.length - 1);
235
+ const moved: MovedRect[] = [];
236
+ let cursor = axis === 'x' ? first.x + first.w + gap : first.y + first.h + gap;
237
+ for (let i = 1; i < sorted.length - 1; i++) {
238
+ const r = sorted[i];
239
+ if (!r) continue;
240
+ if (axis === 'x') {
241
+ moved.push({ key: r.key, x: Math.round(cursor), y: Math.round(r.y) });
242
+ cursor += r.w + gap;
243
+ } else {
244
+ moved.push({ key: r.key, x: Math.round(r.x), y: Math.round(cursor) });
245
+ cursor += r.h + gap;
246
+ }
247
+ }
248
+ return moved;
249
+ }
250
+
251
+ export interface TidyOptions {
252
+ /** Pixel gap between grid cells, both axes. Default 16. */
253
+ gap?: number;
254
+ /** Column count. Default: near-square (`round(sqrt(n))`). */
255
+ columns?: number;
256
+ }
257
+
258
+ /**
259
+ * Snap 2+ rects into a clean grid: reading-order sort (top-to-bottom, then
260
+ * left-to-right), laid out at a fixed gap from the union bbox's top-left,
261
+ * each column/row sized to its widest/tallest member so nothing overlaps.
262
+ */
263
+ export function computeTidyGrid(rects: KeyedRect[], opts: TidyOptions = {}): MovedRect[] {
264
+ const n = rects.length;
265
+ if (n < 2) return [];
266
+ const gap = opts.gap ?? 16;
267
+ const columns = Math.max(1, Math.min(n, opts.columns ?? Math.round(Math.sqrt(n))));
268
+ const sorted = [...rects].sort((a, b) => a.y - b.y || a.x - b.x);
269
+ const originX = Math.min(...rects.map((r) => r.x));
270
+ const originY = Math.min(...rects.map((r) => r.y));
271
+ const rows = Math.ceil(n / columns);
272
+ const colWidths: number[] = new Array(columns).fill(0);
273
+ const rowHeights: number[] = new Array(rows).fill(0);
274
+ sorted.forEach((r, i) => {
275
+ const row = Math.floor(i / columns);
276
+ const col = i % columns;
277
+ colWidths[col] = Math.max(colWidths[col] ?? 0, r.w);
278
+ rowHeights[row] = Math.max(rowHeights[row] ?? 0, r.h);
279
+ });
280
+ const colX: number[] = [];
281
+ {
282
+ let cursor = originX;
283
+ for (let c = 0; c < columns; c++) {
284
+ colX.push(cursor);
285
+ cursor += (colWidths[c] ?? 0) + gap;
286
+ }
287
+ }
288
+ const rowY: number[] = [];
289
+ {
290
+ let cursor = originY;
291
+ for (let r = 0; r < rows; r++) {
292
+ rowY.push(cursor);
293
+ cursor += (rowHeights[r] ?? 0) + gap;
294
+ }
295
+ }
296
+ return sorted.map((r, i) => {
297
+ const row = Math.floor(i / columns);
298
+ const col = i % columns;
299
+ const nx = colX[col] ?? r.x;
300
+ const ny = rowY[row] ?? r.y;
301
+ return { key: r.key, x: Math.round(nx), y: Math.round(ny) };
302
+ });
303
+ }
@@ -3,8 +3,10 @@
3
3
  * @scope apps/studio/export-dialog.tsx
4
4
  * @purpose Native `<dialog>`-based export modal. Three controls — format,
5
5
  * scope, per-format options — plus a Recent tab populated by
6
- * `/_api/export-history`. Submit fires `POST /_api/export`, the
7
- * response is piped to a Blob URL anchor download. `⌘E` opens
6
+ * `/_api/export-history`. Submit POSTs `/_api/export-jobs` and
7
+ * closes immediately (feature-background-export-notification-
8
+ * center) — the main-shell notification center owns status/
9
+ * progress/completion from there, not this dialog. `⌘E` opens
8
10
  * the dialog from anywhere inside the canvas; `⌘⇧E` re-runs the
9
11
  * most recent export without opening (T10 fast path).
10
12
  *
@@ -271,7 +273,7 @@ const DIALOG_CSS = `
271
273
  .dc-export-dialog footer button.dc-ed-primary { background: var(--maude-hud-accent, #1a1a1a); color: var(--maude-hud-accent-fg, #fff); border-color: transparent; }
272
274
  .dc-export-dialog footer button:disabled { opacity: 0.4; cursor: not-allowed; }
273
275
  .dc-export-dialog .dc-ed-status { padding: 8px 20px; font-size: 12px; color: var(--maude-chrome-fg-1, rgba(40,30,20,0.65)); border-top: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.08)); }
274
- .dc-export-dialog .dc-ed-status.is-error { color: var(--status-error, #c0392b); }
276
+ .dc-export-dialog .dc-ed-status.is-error { color: #c0392b; }
275
277
  `;
276
278
 
277
279
  // ─────────────────────────────────────────────────────────────────────────────
@@ -293,7 +295,9 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
293
295
  dialogRef.current?.close();
294
296
  }, []);
295
297
 
296
- // Pre-load history when the dialog opens; refresh after each export.
298
+ // Loaded when the dialog opens. Submit now closes the dialog immediately
299
+ // (the job runs in the background) instead of refreshing this in place —
300
+ // the next open picks up whatever's finished by then.
297
301
  const loadHistory = useCallback(async () => {
298
302
  try {
299
303
  if (isCrossOriginFramed()) {
@@ -364,10 +368,13 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
364
368
  setStatus(null);
365
369
  try {
366
370
  if (isCrossOriginFramed()) {
367
- // Bridge through the main shell — the iframe can't reach /_api/export
368
- // (canvas origin 403s it). The parent runs the export + triggers the
369
- // download, then replies with the saved filename or an error string.
370
- const res = await bridgeRequest<{ ok?: boolean; filename?: string; error?: string }>(
371
+ // Bridge through the main shell — the iframe can't reach
372
+ // /_api/export-jobs (canvas origin 403s it, DDR-060). The parent
373
+ // enqueues the job and replies with the id immediately; the
374
+ // trusted main-shell notification center (which already owns the
375
+ // WS connection) is the single place status/progress/completion
376
+ // live from here — this dialog no longer polls or receives bytes.
377
+ const res = await bridgeRequest<{ ok?: boolean; jobId?: string; error?: string }>(
371
378
  'export-request',
372
379
  'export-result',
373
380
  { payload: { format, scope, options } }
@@ -376,11 +383,10 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
376
383
  setStatus({ text: `Export failed: ${res.error || 'unknown'}`, isError: true });
377
384
  return;
378
385
  }
379
- setStatus({ text: `Saved ${res.filename ?? 'export'}`, isError: false });
380
- void loadHistory();
386
+ close();
381
387
  return;
382
388
  }
383
- const r = await fetch('/_api/export', {
389
+ const r = await fetch('/_api/export-jobs', {
384
390
  method: 'POST',
385
391
  headers: { 'content-type': 'application/json' },
386
392
  body: JSON.stringify({ format, scope, options }),
@@ -390,20 +396,7 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
390
396
  setStatus({ text: `Export failed: ${text || r.status}`, isError: true });
391
397
  return;
392
398
  }
393
- const disp = r.headers.get('Content-Disposition') ?? '';
394
- const filename =
395
- /filename="([^"]+)"/.exec(disp)?.[1] ?? `export${FORMAT_META[format].defaultExt}`;
396
- const blob = await r.blob();
397
- const url = URL.createObjectURL(blob);
398
- const a = document.createElement('a');
399
- a.href = url;
400
- a.download = filename;
401
- document.body.appendChild(a);
402
- a.click();
403
- a.remove();
404
- URL.revokeObjectURL(url);
405
- setStatus({ text: `Saved ${filename}`, isError: false });
406
- void loadHistory();
399
+ close();
407
400
  } catch (err) {
408
401
  const msg = err instanceof Error ? err.message : String(err);
409
402
  setStatus({ text: `Export failed: ${msg}`, isError: true });
@@ -411,7 +404,7 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
411
404
  setSubmitting(false);
412
405
  }
413
406
  },
414
- [loadHistory]
407
+ [close]
415
408
  );
416
409
 
417
410
  const rerunLast = useCallback(async () => {
@@ -67,3 +67,107 @@ export function resolveExportRuntime(): string {
67
67
  }
68
68
  return runtime;
69
69
  }
70
+
71
+ export interface SpawnShimOptions {
72
+ /** cwd for the spawned shim — callers pass `path.dirname(<SHIM>)`. */
73
+ cwd: string;
74
+ /** Aborts the render — kills the child process. */
75
+ signal?: AbortSignal;
76
+ /** Fired for each `MAUDE_PROGRESS {"current":N,"total":M}` stdout line. */
77
+ onProgress?: (update: { current: number; total: number }) => void;
78
+ }
79
+
80
+ export interface SpawnShimResult {
81
+ code: number;
82
+ /** stdout lines with `MAUDE_PROGRESS` lines filtered out (written-file paths etc.). */
83
+ stdoutLines: string[];
84
+ stderr: string;
85
+ }
86
+
87
+ const PROGRESS_LINE = /^MAUDE_PROGRESS (.+)$/;
88
+
89
+ /**
90
+ * Spawn a render shim (`bin/_{png,pdf,svg,html,pptx,video}-playwright.mjs`)
91
+ * via a resolved node/bun runtime, reading stdout INCREMENTALLY (not
92
+ * buffered via `new Response(proc.stdout).text()`) so a `MAUDE_PROGRESS`
93
+ * line written mid-render reaches `onProgress` as soon as it's flushed,
94
+ * instead of only after the whole process exits. Non-progress lines
95
+ * (written-file paths, the existing per-adapter contract) collect into
96
+ * `stdoutLines` in the order they arrived. Exit-code handling stays with the
97
+ * caller — this returns the same shape the old `Promise.all([...text()])`
98
+ * block did, so adapters keep their existing `if (code !== 0) throw …`.
99
+ */
100
+ export async function spawnShim(args: string[], opts: SpawnShimOptions): Promise<SpawnShimResult> {
101
+ const proc = Bun.spawn([resolveExportRuntime(), ...args], {
102
+ cwd: opts.cwd,
103
+ stdout: 'pipe',
104
+ stderr: 'pipe',
105
+ signal: opts.signal,
106
+ });
107
+ // Bun.spawn's `signal` option kills the child on abort; this listener is a
108
+ // defensive fallback in case a given Bun build ignores it — cheap and
109
+ // idempotent (killing an already-exited process is a no-op).
110
+ const onAbort = () => {
111
+ try {
112
+ proc.kill();
113
+ } catch {
114
+ /* already exited */
115
+ }
116
+ };
117
+ opts.signal?.addEventListener('abort', onAbort);
118
+
119
+ const stderrPromise = new Response(proc.stderr).text();
120
+ const stdoutLines: string[] = [];
121
+ let buffer = '';
122
+ const consumeLine = (line: string) => {
123
+ if (!line) return;
124
+ const match = PROGRESS_LINE.exec(line);
125
+ if (!match) {
126
+ stdoutLines.push(line);
127
+ return;
128
+ }
129
+ try {
130
+ const update = JSON.parse(match[1]) as { current: number; total: number };
131
+ opts.onProgress?.(update);
132
+ } catch {
133
+ /* malformed progress line — drop it rather than treat it as a file path */
134
+ }
135
+ };
136
+
137
+ try {
138
+ const reader = proc.stdout.pipeThrough(new TextDecoderStream()).getReader();
139
+ for (;;) {
140
+ const { done, value } = await reader.read();
141
+ if (done) break;
142
+ buffer += value;
143
+ let newlineIdx = buffer.indexOf('\n');
144
+ while (newlineIdx !== -1) {
145
+ consumeLine(buffer.slice(0, newlineIdx).trim());
146
+ buffer = buffer.slice(newlineIdx + 1);
147
+ newlineIdx = buffer.indexOf('\n');
148
+ }
149
+ }
150
+ if (buffer.trim()) consumeLine(buffer.trim());
151
+ } finally {
152
+ opts.signal?.removeEventListener('abort', onAbort);
153
+ }
154
+
155
+ const [stderr, code] = await Promise.all([stderrPromise, proc.exited]);
156
+ return { code, stdoutLines, stderr };
157
+ }
158
+
159
+ /**
160
+ * `spawnShim()` + the exit-code check every adapter did identically
161
+ * (`if (code !== 0) throw new Error(...)`; `code review, /flow:done` — six
162
+ * near-copies collapsed into one). `args[0]` is the shim path by convention
163
+ * (every adapter builds it that way), used only for the error message.
164
+ */
165
+ export async function runShim(args: string[], opts: SpawnShimOptions): Promise<string[]> {
166
+ const { code, stdoutLines, stderr } = await spawnShim(args, opts);
167
+ if (code !== 0) {
168
+ throw new Error(
169
+ `${path.basename(args[0])} exited ${code}: ${stderr.trim() || stdoutLines.join('\n').trim()}`
170
+ );
171
+ }
172
+ return stdoutLines;
173
+ }