@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
@@ -2,10 +2,24 @@
2
2
 
3
3
  import { describe, expect, test } from 'bun:test';
4
4
 
5
- import { detectEqualSpacing } from '../equal-spacing-detector.ts';
5
+ import {
6
+ computeAlign,
7
+ computeDistribute,
8
+ computePairGap,
9
+ computeTidyGrid,
10
+ detectEqualSpacing,
11
+ type KeyedRect,
12
+ } from '../equal-spacing-detector.ts';
6
13
  import type { Rect } from '../use-snap-guides.tsx';
7
14
 
8
15
  const r = (x: number, y: number, w = 100, h = 60): Rect => ({ x, y, w, h });
16
+ const kr = (key: string, x: number, y: number, w = 100, h = 60): KeyedRect => ({
17
+ key,
18
+ x,
19
+ y,
20
+ w,
21
+ h,
22
+ });
9
23
 
10
24
  describe('detectEqualSpacing / preconditions', () => {
11
25
  test('fewer than 3 rects → null', () => {
@@ -91,3 +105,153 @@ describe('detectEqualSpacing / 4+ rects', () => {
91
105
  expect(out).toBeNull();
92
106
  });
93
107
  });
108
+
109
+ // Task L7 — Alt-hover measurement pairwise gap.
110
+
111
+ describe('computePairGap', () => {
112
+ test('measures the x-axis gap regardless of argument order', () => {
113
+ const a = r(0, 0, 100, 50);
114
+ const b = r(150, 20, 80, 50);
115
+ const fwd = computePairGap(a, b, 'x');
116
+ const rev = computePairGap(b, a, 'x');
117
+ expect(fwd).toEqual({ axis: 'x', gap: 50, from: 100, cross: 35 });
118
+ expect(rev).toEqual(fwd);
119
+ });
120
+
121
+ test('measures the y-axis gap', () => {
122
+ const a = r(0, 0, 50, 60);
123
+ const b = r(10, 150, 50, 60);
124
+ const out = computePairGap(a, b, 'y');
125
+ expect(out).toEqual({ axis: 'y', gap: 90, from: 60, cross: 30 });
126
+ });
127
+
128
+ test('overlapping on the axis → null (no gap to show)', () => {
129
+ const a = r(0, 0, 100, 50);
130
+ const b = r(50, 0, 100, 50); // overlaps a on x
131
+ expect(computePairGap(a, b, 'x')).toBeNull();
132
+ });
133
+
134
+ test('touching (gap === 0) → null', () => {
135
+ const a = r(0, 0, 100, 50);
136
+ const b = r(100, 0, 100, 50);
137
+ expect(computePairGap(a, b, 'x')).toBeNull();
138
+ });
139
+ });
140
+
141
+ // Task L5 — distribute / align / tidy-up pure math.
142
+
143
+ describe('computeAlign', () => {
144
+ test('fewer than 2 rects → []', () => {
145
+ expect(computeAlign([], 'left')).toEqual([]);
146
+ expect(computeAlign([kr('a', 0, 0)], 'left')).toEqual([]);
147
+ });
148
+
149
+ test('left/right/top/bottom hold the union-bbox edge', () => {
150
+ const rects = [kr('a', 0, 0, 100, 50), kr('b', 40, 100, 60, 50), kr('c', 200, 30, 100, 50)];
151
+ const left = computeAlign(rects, 'left');
152
+ // union xMin = 0 → 'a' already at x=0 is a no-op (skipped); b/c move.
153
+ expect(left).toEqual(
154
+ expect.arrayContaining([
155
+ { key: 'b', x: 0, y: 100 },
156
+ { key: 'c', x: 0, y: 30 },
157
+ ])
158
+ );
159
+ expect(left.find((m) => m.key === 'a')).toBeUndefined();
160
+
161
+ const right = computeAlign(rects, 'right');
162
+ // union xMax = 300 (c: 200+100)
163
+ expect(right.find((m) => m.key === 'a')).toEqual({ key: 'a', x: 200, y: 0 });
164
+ expect(right.find((m) => m.key === 'b')).toEqual({ key: 'b', x: 240, y: 100 });
165
+ expect(right.find((m) => m.key === 'c')).toBeUndefined(); // already at right edge
166
+
167
+ const top = computeAlign(rects, 'top');
168
+ expect(top.find((m) => m.key === 'a')).toBeUndefined(); // already yMin=0
169
+ expect(top.find((m) => m.key === 'b')).toEqual({ key: 'b', x: 40, y: 0 });
170
+ expect(top.find((m) => m.key === 'c')).toEqual({ key: 'c', x: 200, y: 0 });
171
+ });
172
+
173
+ test('center-x / center-y align to the union-bbox midpoint', () => {
174
+ const rects = [kr('a', 0, 0, 100, 100), kr('b', 300, 0, 100, 100)];
175
+ // union bbox x: 0..400, cx=200
176
+ const centerX = computeAlign(rects, 'center-x');
177
+ expect(centerX).toEqual(
178
+ expect.arrayContaining([
179
+ { key: 'a', x: 150, y: 0 },
180
+ { key: 'b', x: 150, y: 0 },
181
+ ])
182
+ );
183
+ });
184
+
185
+ test('no-op rects (already aligned) are omitted from the result', () => {
186
+ const rects = [kr('a', 0, 0), kr('b', 0, 50)];
187
+ expect(computeAlign(rects, 'left')).toEqual([]);
188
+ });
189
+ });
190
+
191
+ describe('computeDistribute', () => {
192
+ test('fewer than 3 rects → []', () => {
193
+ expect(computeDistribute([kr('a', 0, 0), kr('b', 200, 0)], 'x')).toEqual([]);
194
+ });
195
+
196
+ test('holds first + last, spaces the middle evenly on x', () => {
197
+ // 0..100, then a bunched pair at 110/130, then 300..400 — total span
198
+ // first.x(0) to last.x+w(400) = 400, minus side-lengths(100+80+90+100=370) = 30 → gap 10
199
+ const rects = [
200
+ kr('a', 0, 0, 100, 50),
201
+ kr('b', 110, 0, 80, 50),
202
+ kr('c', 130, 0, 90, 50),
203
+ kr('d', 300, 0, 100, 50),
204
+ ];
205
+ const out = computeDistribute(rects, 'x');
206
+ expect(out).toHaveLength(2); // only the 2 middle rects move
207
+ expect(out.find((m) => m.key === 'b')).toEqual({ key: 'b', x: 110, y: 0 });
208
+ expect(out.find((m) => m.key === 'c')).toEqual({ key: 'c', x: 200, y: 0 });
209
+ });
210
+
211
+ test('already-equal gaps still returns the (unchanged) middle positions', () => {
212
+ const rects = [kr('a', 0, 0), kr('b', 120, 0), kr('c', 240, 0)];
213
+ const out = computeDistribute(rects, 'x');
214
+ expect(out).toEqual([{ key: 'b', x: 120, y: 0 }]);
215
+ });
216
+
217
+ test('distributes on y', () => {
218
+ // y: 0..60, 90..150, 300..360 — total sides 180, span 360-180=180, gap 90
219
+ const rects = [kr('a', 0, 0, 50, 60), kr('b', 0, 90, 50, 60), kr('c', 0, 300, 50, 60)];
220
+ const out = computeDistribute(rects, 'y');
221
+ expect(out).toHaveLength(1);
222
+ expect(out[0]).toEqual({ key: 'b', x: 0, y: 150 });
223
+ });
224
+ });
225
+
226
+ describe('computeTidyGrid', () => {
227
+ test('fewer than 2 rects → []', () => {
228
+ expect(computeTidyGrid([kr('a', 5, 5)])).toEqual([]);
229
+ });
230
+
231
+ test('4 rects → 2×2 grid, reading-order sorted, from the union-bbox origin', () => {
232
+ // scattered, deliberately out of reading order
233
+ const rects = [
234
+ kr('br', 500, 500, 50, 50),
235
+ kr('tl', 0, 0, 50, 50),
236
+ kr('tr', 400, 10, 50, 50),
237
+ kr('bl', 20, 490, 50, 50),
238
+ ];
239
+ const out = computeTidyGrid(rects, { gap: 10 });
240
+ expect(out).toHaveLength(4);
241
+ const byKey = Object.fromEntries(out.map((m) => [m.key, m]));
242
+ // reading order (y then x): tl(0,0) → tr(400,10) → bl(20,490) → br(500,500)
243
+ // columns = round(sqrt(4)) = 2 → row0: tl,tr · row1: bl,br
244
+ expect(byKey.tl).toEqual({ key: 'tl', x: 0, y: 0 });
245
+ expect(byKey.tr).toEqual({ key: 'tr', x: 60, y: 0 }); // col0 width 50 + gap 10
246
+ expect(byKey.bl).toEqual({ key: 'bl', x: 0, y: 60 });
247
+ expect(byKey.br).toEqual({ key: 'br', x: 60, y: 60 });
248
+ });
249
+
250
+ test('honors an explicit column count', () => {
251
+ const rects = [kr('a', 0, 0, 40, 20), kr('b', 0, 0, 40, 20), kr('c', 0, 0, 40, 20)];
252
+ const out = computeTidyGrid(rects, { gap: 5, columns: 1 });
253
+ // 1 column → stacked vertically
254
+ expect(out.map((m) => m.x)).toEqual([0, 0, 0]);
255
+ expect(out.map((m) => m.y)).toEqual([0, 25, 50]);
256
+ });
257
+ });
@@ -45,6 +45,32 @@ describe('history.ts', () => {
45
45
  });
46
46
  });
47
47
 
48
+ test('prunes to the newest MAX_SNAPSHOTS_PER_SLUG pairs (G3 disk-fill bound)', async () => {
49
+ const prev = process.env.MAUDE_MAX_SNAPSHOTS;
50
+ process.env.MAUDE_MAX_SNAPSHOTS = '3';
51
+ try {
52
+ await withSandbox(async (ctx) => {
53
+ const hist = createHistory(ctx);
54
+ for (let i = 0; i < 6; i++) {
55
+ // Space the writes so Date.now() advances between them — real
56
+ // structural edits are seconds apart, so each snapshot gets a distinct
57
+ // increasing ts (a same-millisecond burst is not a production shape).
58
+ await hist.writeSnapshot('.design/ui/a.html', `<doc>v${i}</doc>`, `edit-${i}`);
59
+ await Bun.sleep(2);
60
+ }
61
+ const list = await hist.listSnapshots('.design/ui/a.html');
62
+ // Only the 3 newest survive; the newest content is intact.
63
+ expect(list.length).toBe(3);
64
+ const newest = list[list.length - 1];
65
+ const read = await hist.readSnapshot('.design/ui/a.html', newest?.ts ?? '');
66
+ expect(new TextDecoder().decode(read?.content)).toBe('<doc>v5</doc>');
67
+ });
68
+ } finally {
69
+ if (prev === undefined) delete process.env.MAUDE_MAX_SNAPSHOTS;
70
+ else process.env.MAUDE_MAX_SNAPSHOTS = prev;
71
+ }
72
+ });
73
+
48
74
  test('rollback overwrites the target file', async () => {
49
75
  await withSandbox(async (ctx) => {
50
76
  const hist = createHistory(ctx);
@@ -0,0 +1,87 @@
1
+ // KNOB_PROPS round-trip — feature-element-editing-robustness Stage B (Task B1
2
+ // + G2 item b). Promoting a property into `KNOB_PROPS` (dom-selection.ts) does
3
+ // two things: `styleMapsFor` captures its authored/computed value into the
4
+ // `Selection` payload (so the new Position/Transform/Typography/Media panel
5
+ // rows have something to render), AND it moves the prop OUT of the Advanced
6
+ // "customStyles" escape hatch — a canvas that had one of these as a raw custom
7
+ // style no longer double-surfaces it there.
8
+ //
9
+ // `hoverTargetToSelection` (the exported, already-tested entry point) spreads
10
+ // `...styleMapsFor(el)` into its return, so this exercises the real promoted
11
+ // list end-to-end without needing to export the private `styleMapsFor`/
12
+ // `KNOB_PROPS` internals.
13
+
14
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
15
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
16
+
17
+ beforeAll(() => {
18
+ GlobalRegistrator.register();
19
+ });
20
+ afterAll(() => {
21
+ GlobalRegistrator.unregister();
22
+ });
23
+
24
+ import { hoverTargetToSelection } from '../dom-selection.ts';
25
+ import type { HoverTarget } from '../input-router.tsx';
26
+
27
+ function selectionFor(styleText: string) {
28
+ document.body.innerHTML = `<div data-cd-id="aaaaaaaa" style="${styleText}">x</div>`;
29
+ const el = document.querySelector('[data-cd-id]') as HTMLElement;
30
+ const target: HoverTarget = { el, cdId: 'aaaaaaaa', artboardId: null };
31
+ return hoverTargetToSelection(target);
32
+ }
33
+
34
+ describe('KNOB_PROPS — the Stage-B promoted (formerly DDR-104 OUT-list) properties', () => {
35
+ test('position + inset (top/right/bottom/left) + z-index round-trip into authored', () => {
36
+ const sel = selectionFor(
37
+ 'position: absolute; top: 10px; right: 20px; bottom: 30px; left: 40px; z-index: 5;'
38
+ );
39
+ expect(sel.authored?.position).toBe('absolute');
40
+ expect(sel.authored?.top).toBe('10px');
41
+ expect(sel.authored?.right).toBe('20px');
42
+ expect(sel.authored?.bottom).toBe('30px');
43
+ expect(sel.authored?.left).toBe('40px');
44
+ expect(sel.authored?.['z-index']).toBe('5');
45
+ });
46
+
47
+ test('transform + transform-origin round-trip', () => {
48
+ const sel = selectionFor('transform: rotate(12deg); transform-origin: center;');
49
+ expect(sel.authored?.transform).toBe('rotate(12deg)');
50
+ expect(sel.authored?.['transform-origin']).toBe('center');
51
+ });
52
+
53
+ test('font-style / text-transform / text-decoration / white-space round-trip', () => {
54
+ const sel = selectionFor(
55
+ 'font-style: italic; text-transform: uppercase; text-decoration: underline; white-space: nowrap;'
56
+ );
57
+ expect(sel.authored?.['font-style']).toBe('italic');
58
+ expect(sel.authored?.['text-transform']).toBe('uppercase');
59
+ expect(sel.authored?.['text-decoration']).toBe('underline');
60
+ expect(sel.authored?.['white-space']).toBe('nowrap');
61
+ });
62
+
63
+ test('overflow + media framing (object-fit/aspect-ratio/object-position) round-trip', () => {
64
+ const sel = selectionFor(
65
+ 'overflow: hidden; object-fit: cover; aspect-ratio: 16 / 9; object-position: top;'
66
+ );
67
+ expect(sel.authored?.overflow).toBe('hidden');
68
+ expect(sel.authored?.['object-fit']).toBe('cover');
69
+ expect(sel.authored?.['aspect-ratio']).toBe('16 / 9');
70
+ expect(sel.authored?.['object-position']).toBe('top');
71
+ });
72
+
73
+ test('a promoted prop never leaks into customStyles (the B1 exclusion gotcha)', () => {
74
+ const sel = selectionFor('position: absolute; cursor: pointer;');
75
+ // `position` is curated now → excluded from the Advanced hatch.
76
+ expect(sel.customStyles?.position).toBeUndefined();
77
+ // A genuinely uncurated prop (not in KNOB_PROPS) still lands in
78
+ // customStyles as before — proves the exclusion is targeted, not a
79
+ // blanket "nothing goes to customStyles anymore" regression.
80
+ expect(sel.customStyles?.cursor).toBe('pointer');
81
+ });
82
+
83
+ test('computed values are also captured (placeholder-hint source)', () => {
84
+ const sel = selectionFor('position: relative;');
85
+ expect(sel.computed?.position).toBe('relative');
86
+ });
87
+ });
@@ -40,6 +40,16 @@ interface Annotation {
40
40
  color: string | null;
41
41
  anchorId?: string;
42
42
  artboard?: string | null;
43
+ element?: {
44
+ cdId: string | null;
45
+ selector: string;
46
+ index: number;
47
+ artboard: string | null;
48
+ rect: { x: number; y: number; w: number; h: number };
49
+ tag: string;
50
+ text: string;
51
+ } | null;
52
+ target?: { source: string; selector: { type: string; value: string }; geometry: unknown };
43
53
  }
44
54
 
45
55
  // Mirror api.ts fileSlug for the common (design-root-relative .tsx) case so the
@@ -407,3 +417,147 @@ describe('read-annotations / --canvas-state overlap tagging', () => {
407
417
  expect(a && 'artboard' in a).toBe(false);
408
418
  });
409
419
  });
420
+
421
+ // feature-whiteboard-ai-toolkit — --rects (a `maude design canvas-rects`
422
+ // geometry manifest) adds element-level context on top of the existing
423
+ // artboard tagging.
424
+ describe('read-annotations / --rects element-level context', () => {
425
+ const manifest = JSON.stringify({
426
+ artboards: [{ id: 'hero', x: 0, y: 0, w: 400, h: 300 }],
427
+ elements: [
428
+ // A wrapping card AND the button inside it both contain "over-button"'s
429
+ // center — the button (smaller area) must win (deepest heuristic).
430
+ {
431
+ cdId: 'card1',
432
+ selector: '[data-dc-screen="hero"] [data-cd-id="card1"]',
433
+ index: 0,
434
+ artboard: 'hero',
435
+ x: 40,
436
+ y: 40,
437
+ w: 300,
438
+ h: 200,
439
+ tag: 'div',
440
+ text: '',
441
+ },
442
+ {
443
+ cdId: 'btn1',
444
+ selector: '[data-dc-screen="hero"] [data-cd-id="btn1"]',
445
+ index: 0,
446
+ artboard: 'hero',
447
+ x: 60,
448
+ y: 60,
449
+ w: 100,
450
+ h: 32,
451
+ tag: 'button',
452
+ text: 'Continue',
453
+ },
454
+ ],
455
+ elementsTruncated: false,
456
+ });
457
+
458
+ test('a note whose center sits over an element resolves the DEEPEST (smallest) match', () => {
459
+ const t: TextStroke = {
460
+ id: 'over-button',
461
+ tool: 'text',
462
+ color: '#000',
463
+ fontSize: 14,
464
+ text: 'shrink this',
465
+ x: 80,
466
+ y: 70, // inside both card1 (40,40,300,200) and btn1 (60,60,100,32)
467
+ };
468
+ const { annotations } = read(REL, strokesToSvg([t]), ['--rects', 'rects.json'], {
469
+ 'rects.json': manifest,
470
+ });
471
+ const [a] = annotations;
472
+ expect(a?.element?.cdId).toBe('btn1');
473
+ expect(a?.element?.tag).toBe('button');
474
+ expect(a?.element?.text).toBe('Continue');
475
+ expect(a?.element?.selector).toBe('[data-dc-screen="hero"] [data-cd-id="btn1"]');
476
+ expect(a?.element?.rect).toEqual({ x: 60, y: 60, w: 100, h: 32 });
477
+ });
478
+
479
+ test('a note over no element resolves element: null', () => {
480
+ const t: TextStroke = {
481
+ id: 'floating',
482
+ tool: 'text',
483
+ color: '#000',
484
+ fontSize: 14,
485
+ text: 'nowhere near an element',
486
+ x: 5000,
487
+ y: 5000,
488
+ };
489
+ const { annotations } = read(REL, strokesToSvg([t]), ['--rects', 'rects.json'], {
490
+ 'rects.json': manifest,
491
+ });
492
+ expect(annotations[0]?.element).toBeNull();
493
+ });
494
+
495
+ test('--rects also supplies artboard tagging when --canvas-state is absent', () => {
496
+ const t: TextStroke = {
497
+ id: 'on-hero',
498
+ tool: 'text',
499
+ color: '#000',
500
+ fontSize: 14,
501
+ text: 'x',
502
+ x: 80,
503
+ y: 70,
504
+ };
505
+ const { annotations } = read(REL, strokesToSvg([t]), ['--rects', 'rects.json'], {
506
+ 'rects.json': manifest,
507
+ });
508
+ expect(annotations[0]?.artboard).toBe('hero');
509
+ });
510
+
511
+ test('an element resolution upgrades the W3C target.selector to a CssSelector', () => {
512
+ const t: TextStroke = {
513
+ id: 'over-button-2',
514
+ tool: 'text',
515
+ color: '#000',
516
+ fontSize: 14,
517
+ text: 'x',
518
+ x: 80,
519
+ y: 70,
520
+ };
521
+ const { annotations } = read(REL, strokesToSvg([t]), ['--rects', 'rects.json'], {
522
+ 'rects.json': manifest,
523
+ });
524
+ expect(annotations[0]?.target?.selector).toEqual({
525
+ type: 'CssSelector',
526
+ value: '[data-dc-screen="hero"] [data-cd-id="btn1"]',
527
+ });
528
+ });
529
+
530
+ test('a floating note (no artboard) keeps AnnotationIdSelector', () => {
531
+ const t: TextStroke = {
532
+ id: 'far-away',
533
+ tool: 'text',
534
+ color: '#000',
535
+ fontSize: 14,
536
+ text: 'x',
537
+ x: 9000,
538
+ y: 9000,
539
+ };
540
+ const { annotations } = read(REL, strokesToSvg([t]), ['--rects', 'rects.json'], {
541
+ 'rects.json': manifest,
542
+ });
543
+ // Outside every artboard — anchorToArtboard returns early, no `target` at all.
544
+ expect(annotations[0]?.target).toBeUndefined();
545
+ expect(annotations[0]?.element).toBeNull();
546
+ });
547
+
548
+ test('without --rects, no element field is emitted (byte-for-byte preserved)', () => {
549
+ const t: TextStroke = {
550
+ id: 't',
551
+ tool: 'text',
552
+ color: '#000',
553
+ fontSize: 14,
554
+ text: 'x',
555
+ x: 80,
556
+ y: 70,
557
+ };
558
+ const [a] = read(REL, strokesToSvg([t]), ['--canvas-state', 'state.json'], {
559
+ 'state.json': JSON.stringify({ artboards: [{ id: 'hero', x: 0, y: 0, w: 400, h: 300 }] }),
560
+ }).annotations;
561
+ expect(a && 'element' in a).toBe(false);
562
+ });
563
+ });
@@ -0,0 +1,102 @@
1
+ // sizing-mode.ts — Stage M1 (feature-element-editing-robustness). The pure
2
+ // Fixed / Hug / Fill mode classification + the deterministic CSS patch it writes,
3
+ // including the context-aware Fill (flex main axis → flex-grow, cross axis →
4
+ // align-self, block/grid → 100%).
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { isMainAxis, sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
9
+
10
+ const flexRow = { display: 'flex', flexDirection: 'row' };
11
+ const flexCol = { display: 'flex', flexDirection: 'column' };
12
+ const block = { display: 'block' };
13
+
14
+ describe('sizing-mode / isMainAxis', () => {
15
+ test('row parent → width is main, height is cross', () => {
16
+ expect(isMainAxis('width', flexRow)).toBe(true);
17
+ expect(isMainAxis('height', flexRow)).toBe(false);
18
+ });
19
+ test('column parent → height is main, width is cross', () => {
20
+ expect(isMainAxis('height', flexCol)).toBe(true);
21
+ expect(isMainAxis('width', flexCol)).toBe(false);
22
+ });
23
+ test('missing direction defaults to row', () => {
24
+ expect(isMainAxis('width', { display: 'flex' })).toBe(true);
25
+ });
26
+ });
27
+
28
+ describe('sizing-mode / sizingModeOf', () => {
29
+ test('numeric authored value → fixed', () => {
30
+ expect(sizingModeOf('width', { width: '200px' })).toBe('fixed');
31
+ });
32
+ test('fit-content → hug', () => {
33
+ expect(sizingModeOf('width', { width: 'fit-content' })).toBe('hug');
34
+ });
35
+ test('100% → fill', () => {
36
+ expect(sizingModeOf('width', { width: '100%' })).toBe('fill');
37
+ });
38
+ test('flex-grow ≥ 1 on the main axis → fill', () => {
39
+ expect(sizingModeOf('width', { 'flex-grow': '1' }, {}, flexRow)).toBe('fill');
40
+ });
41
+ test('flex-grow on the cross axis is NOT fill (grow is a main-axis property)', () => {
42
+ expect(sizingModeOf('height', { 'flex-grow': '1' }, {}, flexRow)).toBe('fixed');
43
+ });
44
+ test('align-self stretch on the cross axis → fill', () => {
45
+ expect(sizingModeOf('height', { 'align-self': 'stretch' }, {}, flexRow)).toBe('fill');
46
+ });
47
+ test('nothing authored → fixed default', () => {
48
+ expect(sizingModeOf('width', {}, { width: '640px' })).toBe('fixed');
49
+ });
50
+ });
51
+
52
+ describe('sizing-mode / sizingModePatch', () => {
53
+ test('fixed writes an explicit px from the current rendered size', () => {
54
+ const p = sizingModePatch('width', 'fixed', block, 321.6);
55
+ expect(p.set).toEqual([['width', '322px']]);
56
+ expect(p.reset).toEqual([]);
57
+ });
58
+
59
+ test('hug writes fit-content', () => {
60
+ const p = sizingModePatch('height', 'hug', block, 100);
61
+ expect(p.set).toEqual([['height', 'fit-content']]);
62
+ });
63
+
64
+ test('fill on a block child writes 100%', () => {
65
+ const p = sizingModePatch('width', 'fill', block, 100);
66
+ expect(p.set).toEqual([['width', '100%']]);
67
+ });
68
+
69
+ test('fill on a flex MAIN axis uses flex-grow + basis, releases the dimension', () => {
70
+ const p = sizingModePatch('width', 'fill', flexRow, 100);
71
+ expect(p.set).toEqual([
72
+ ['flex-grow', '1'],
73
+ ['flex-basis', '0%'],
74
+ ['width', 'auto'],
75
+ ]);
76
+ });
77
+
78
+ test('fill on a flex CROSS axis uses align-self: stretch', () => {
79
+ const p = sizingModePatch('height', 'fill', flexRow, 100);
80
+ expect(p.set).toEqual([
81
+ ['align-self', 'stretch'],
82
+ ['height', 'auto'],
83
+ ]);
84
+ });
85
+
86
+ test('switching a flex-main child back to fixed clears the fill props', () => {
87
+ const p = sizingModePatch('width', 'fixed', flexRow, 240);
88
+ expect(p.set).toEqual([['width', '240px']]);
89
+ expect(p.reset).toEqual(['flex-grow', 'flex-basis']);
90
+ });
91
+
92
+ test('switching a flex-cross child to hug clears align-self', () => {
93
+ const p = sizingModePatch('height', 'hug', flexRow, 240);
94
+ expect(p.set).toEqual([['height', 'fit-content']]);
95
+ expect(p.reset).toEqual(['align-self']);
96
+ });
97
+
98
+ test('non-positive / non-finite currentPx clamps to 0px for fixed', () => {
99
+ expect(sizingModePatch('width', 'fixed', block, 0).set).toEqual([['width', '0px']]);
100
+ expect(sizingModePatch('width', 'fixed', block, Number.NaN).set).toEqual([['width', '0px']]);
101
+ });
102
+ });