@1agh/maude 0.40.0 → 0.41.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 (73) 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 -3
  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/annotate.mjs +576 -34
  9. package/apps/studio/bin/canvas-rects.sh +152 -0
  10. package/apps/studio/bin/fetch-asset.sh +34 -0
  11. package/apps/studio/bin/read-annotations.mjs +138 -7
  12. package/apps/studio/bin/smoke.sh +42 -6
  13. package/apps/studio/build.ts +21 -0
  14. package/apps/studio/canvas-comment-mount.tsx +138 -4
  15. package/apps/studio/canvas-edit.ts +744 -11
  16. package/apps/studio/canvas-lib.tsx +219 -2
  17. package/apps/studio/canvas-shell.tsx +487 -20
  18. package/apps/studio/client/app.jsx +1451 -22
  19. package/apps/studio/client/comments-overlay.css +130 -126
  20. package/apps/studio/client/github.js +8 -0
  21. package/apps/studio/client/styles/3-shell-maude.css +48 -0
  22. package/apps/studio/comments-overlay.tsx +148 -41
  23. package/apps/studio/context-menu.tsx +15 -5
  24. package/apps/studio/contextual-toolbar.tsx +262 -4
  25. package/apps/studio/cursors-overlay.tsx +4 -4
  26. package/apps/studio/dist/client.bundle.js +20 -20
  27. package/apps/studio/dist/comment-mount.js +59 -1
  28. package/apps/studio/dist/styles.css +1 -1
  29. package/apps/studio/dom-selection.ts +127 -1
  30. package/apps/studio/drag-state.ts +24 -0
  31. package/apps/studio/equal-spacing-detector.ts +205 -0
  32. package/apps/studio/export-dialog.tsx +1 -1
  33. package/apps/studio/history.ts +47 -1
  34. package/apps/studio/http.ts +223 -0
  35. package/apps/studio/input-router.tsx +12 -0
  36. package/apps/studio/marquee-overlay.tsx +1 -1
  37. package/apps/studio/measure-overlay.tsx +241 -0
  38. package/apps/studio/participants-chrome.tsx +3 -3
  39. package/apps/studio/sizing-mode.ts +117 -0
  40. package/apps/studio/spacing-handles.ts +166 -0
  41. package/apps/studio/test/annotate-write.test.ts +890 -0
  42. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  43. package/apps/studio/test/canvas-edit.test.ts +50 -0
  44. package/apps/studio/test/canvas-origin-gate.test.ts +18 -0
  45. package/apps/studio/test/canvas-rects.test.ts +198 -0
  46. package/apps/studio/test/comments-overlay.test.ts +117 -0
  47. package/apps/studio/test/dom-selection.test.ts +130 -0
  48. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  49. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  50. package/apps/studio/test/element-resize.test.ts +136 -0
  51. package/apps/studio/test/element-structural-api.test.ts +360 -0
  52. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  53. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  54. package/apps/studio/test/history-rollback.test.ts +26 -0
  55. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  56. package/apps/studio/test/read-annotations.test.ts +154 -0
  57. package/apps/studio/test/sizing-mode.test.ts +102 -0
  58. package/apps/studio/test/spacing-handles.test.ts +138 -0
  59. package/apps/studio/test/specimen-select.test.ts +88 -0
  60. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  61. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  62. package/apps/studio/tool-palette.tsx +122 -2
  63. package/apps/studio/undo-hud.tsx +2 -2
  64. package/apps/studio/use-element-resize.tsx +732 -0
  65. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  66. package/apps/studio/use-selection-set.tsx +14 -0
  67. package/apps/studio/use-spacing-handles.tsx +388 -0
  68. package/apps/studio/whats-new.json +28 -0
  69. package/cli/commands/design.mjs +6 -1
  70. package/cli/commands/design.test.mjs +49 -1
  71. package/cli/lib/fetch-asset.test.mjs +213 -0
  72. package/package.json +8 -8
  73. 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
+ }
@@ -271,7 +271,7 @@ const DIALOG_CSS = `
271
271
  .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
272
  .dc-export-dialog footer button:disabled { opacity: 0.4; cursor: not-allowed; }
273
273
  .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); }
274
+ .dc-export-dialog .dc-ed-status.is-error { color: #c0392b; }
275
275
  `;
276
276
 
277
277
  // ─────────────────────────────────────────────────────────────────────────────
@@ -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);