@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
@@ -0,0 +1,263 @@
1
+ // feature-background-export-notification-center — job queue concurrency,
2
+ // history persistence under concurrency, and byte retrieval/eviction.
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+ import { randomBytes } from 'node:crypto';
6
+ import { mkdirSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ import { bootServer, killProc, makeSandbox, nextPort } from '../_helpers.ts';
10
+
11
+ interface JobShape {
12
+ id: string;
13
+ status: 'queued' | 'running' | 'done' | 'failed';
14
+ createdAt: string;
15
+ }
16
+
17
+ async function listJobs(port: number): Promise<JobShape[]> {
18
+ const r = await fetch(`http://localhost:${port}/_api/export-jobs`);
19
+ const body = (await r.json()) as { jobs: JobShape[] };
20
+ return body.jobs;
21
+ }
22
+
23
+ /** A `project-raw` zip export takes real, measurable time when the sandbox is
24
+ * large enough (DEFLATE-compressing several MB) — no browser/Playwright
25
+ * needed, so this stays a fast, deterministic-enough integration test. */
26
+ function seedLargeSandbox(designRoot: string, fileCount = 5000): void {
27
+ const dir = join(designRoot, 'ui', 'big');
28
+ mkdirSync(dir, { recursive: true });
29
+ for (let i = 0; i < fileCount; i += 1) {
30
+ writeFileSync(join(dir, `f${i}.txt`), randomBytes(2000).toString('base64'));
31
+ }
32
+ }
33
+
34
+ describe('exporters/jobs — concurrency gate', () => {
35
+ test('caps concurrent render-heavy jobs at MAUDE_EXPORT_MAX_CONCURRENT', async () => {
36
+ const { root, designRoot } = makeSandbox();
37
+ seedLargeSandbox(designRoot);
38
+ const port = nextPort();
39
+ const proc = await bootServer(root, port, { MAUDE_EXPORT_MAX_CONCURRENT: '2' });
40
+ try {
41
+ const jobIds: string[] = [];
42
+ for (let i = 0; i < 3; i += 1) {
43
+ const r = await fetch(`http://localhost:${port}/_api/export-jobs`, {
44
+ method: 'POST',
45
+ headers: { 'content-type': 'application/json' },
46
+ body: JSON.stringify({ format: 'zip', scope: 'project-raw' }),
47
+ });
48
+ expect(r.status).toBe(202);
49
+ const { jobId } = (await r.json()) as { jobId: string };
50
+ jobIds.push(jobId);
51
+ }
52
+
53
+ let sawThirdQueued = false;
54
+ let maxRunningObserved = 0;
55
+ let allDone = false;
56
+ const deadline = Date.now() + 4000;
57
+ while (Date.now() < deadline && !allDone) {
58
+ const jobs = await listJobs(port);
59
+ const byId = new Map(jobs.map((j) => [j.id, j]));
60
+ const running = jobs.filter((j) => j.status === 'running').length;
61
+ maxRunningObserved = Math.max(maxRunningObserved, running);
62
+ if (byId.get(jobIds[2])?.status === 'queued') sawThirdQueued = true;
63
+ allDone = jobIds.every((id) => byId.get(id)?.status === 'done');
64
+ if (!allDone) await Bun.sleep(15);
65
+ }
66
+
67
+ expect(allDone).toBe(true);
68
+ // Never more than the configured cap ran at once.
69
+ expect(maxRunningObserved).toBeLessThanOrEqual(2);
70
+ // The 3rd job was genuinely gated behind the semaphore at some point,
71
+ // not just "coincidentally" always running — proves the queue, not just
72
+ // that everything happened to finish fast.
73
+ expect(sawThirdQueued).toBe(true);
74
+ } finally {
75
+ await killProc(proc);
76
+ }
77
+ });
78
+ });
79
+
80
+ describe('exporters/jobs — pending-queue cap (security fan-out, /flow:done)', () => {
81
+ test('enqueue() 429s past MAUDE_EXPORT_MAX_QUEUED, then accepts more once jobs drain', async () => {
82
+ const { root, designRoot } = makeSandbox();
83
+ seedLargeSandbox(designRoot); // keeps jobs 'queued'/'running' long enough to observe
84
+ const port = nextPort();
85
+ const proc = await bootServer(root, port, {
86
+ MAUDE_EXPORT_MAX_CONCURRENT: '1',
87
+ MAUDE_EXPORT_MAX_QUEUED: '2',
88
+ });
89
+ try {
90
+ const post = () =>
91
+ fetch(`http://localhost:${port}/_api/export-jobs`, {
92
+ method: 'POST',
93
+ headers: { 'content-type': 'application/json' },
94
+ body: JSON.stringify({ format: 'zip', scope: 'project-raw' }),
95
+ });
96
+
97
+ const r1 = await post();
98
+ const r2 = await post();
99
+ expect(r1.status).toBe(202);
100
+ expect(r2.status).toBe(202);
101
+
102
+ // A 3rd job while the first 2 are still queued/running must be rejected
103
+ // with 429, not silently accepted (the DoS-protection cap).
104
+ const r3 = await post();
105
+ expect(r3.status).toBe(429);
106
+ const text = await r3.text();
107
+ expect(text.toLowerCase()).toContain('queue');
108
+
109
+ // Once jobs drain below the cap, enqueue works again — this is a
110
+ // capacity limit, not a permanent lockout.
111
+ let drained = false;
112
+ const deadline = Date.now() + 5000;
113
+ while (Date.now() < deadline && !drained) {
114
+ const jobs = await listJobs(port);
115
+ drained = jobs.every((j) => j.status === 'done' || j.status === 'failed');
116
+ if (!drained) await Bun.sleep(15);
117
+ }
118
+ expect(drained).toBe(true);
119
+ const r4 = await post();
120
+ expect(r4.status).toBe(202);
121
+ } finally {
122
+ await killProc(proc);
123
+ }
124
+ });
125
+ });
126
+
127
+ describe('exporters/jobs — list() + history under concurrency', () => {
128
+ test('list() returns newest-first', async () => {
129
+ const { root } = makeSandbox();
130
+ const port = nextPort();
131
+ const proc = await bootServer(root, port);
132
+ try {
133
+ // `canvas-as-separate` against an empty sandbox (`active: null`)
134
+ // resolves to zero targets → the adapter's empty-input branch, no
135
+ // Playwright spawn — fast + deterministic (mirrors endpoint.test.ts).
136
+ for (let i = 0; i < 3; i += 1) {
137
+ const r = await fetch(`http://localhost:${port}/_api/export-jobs`, {
138
+ method: 'POST',
139
+ headers: { 'content-type': 'application/json' },
140
+ body: JSON.stringify({ format: 'png', scope: 'canvas-as-separate' }),
141
+ });
142
+ expect(r.status).toBe(202);
143
+ }
144
+ // Give the fire-and-forget jobs a moment to land.
145
+ await Bun.sleep(200);
146
+ const jobs = await listJobs(port);
147
+ expect(jobs.length).toBeGreaterThanOrEqual(3);
148
+ const createdAts = jobs.map((j) => j.createdAt);
149
+ const sorted = [...createdAts].sort((a, b) => b.localeCompare(a));
150
+ expect(createdAts).toEqual(sorted);
151
+ } finally {
152
+ await killProc(proc);
153
+ }
154
+ });
155
+
156
+ test('history ledger caps at 20 without dropping entries under concurrent completions', async () => {
157
+ const { root } = makeSandbox();
158
+ const port = nextPort();
159
+ // MAUDE_EXPORT_MAX_QUEUED override — this test deliberately fires 25 jobs
160
+ // in one burst (the concurrent-completion race itself), well above the
161
+ // production default's DoS-protection cap; a real flood is hundreds of
162
+ // requests, not a legitimate test burst.
163
+ const proc = await bootServer(root, port, { MAUDE_EXPORT_MAX_QUEUED: '30' });
164
+ try {
165
+ // Fire 25 jobs concurrently (not sequentially) — the exact race the old
166
+ // read-modify-write appendExportHistory had.
167
+ await Promise.all(
168
+ Array.from({ length: 25 }, () =>
169
+ fetch(`http://localhost:${port}/_api/export-jobs`, {
170
+ method: 'POST',
171
+ headers: { 'content-type': 'application/json' },
172
+ body: JSON.stringify({ format: 'png', scope: 'canvas-as-separate' }),
173
+ }).then((r) => expect(r.status).toBe(202))
174
+ )
175
+ );
176
+ // Poll until the ledger settles at the cap.
177
+ let historyLen = 0;
178
+ const deadline = Date.now() + 4000;
179
+ while (Date.now() < deadline && historyLen < 20) {
180
+ const r = await fetch(`http://localhost:${port}/_api/export-history`);
181
+ const body = (await r.json()) as { history: unknown[] };
182
+ historyLen = body.history.length;
183
+ if (historyLen < 20) await Bun.sleep(20);
184
+ }
185
+ expect(historyLen).toBe(20);
186
+ } finally {
187
+ await killProc(proc);
188
+ }
189
+ });
190
+ });
191
+
192
+ describe('exporters/jobs — byte retrieval + eviction', () => {
193
+ test("a finished job's bytes are downloadable, then gone once evicted past the cap", async () => {
194
+ const { root } = makeSandbox();
195
+ const port = nextPort();
196
+ // MAUDE_EXPORT_MAX_QUEUED override — this test pushes 26 jobs through in
197
+ // one burst (deliberately, to force eviction), above the production
198
+ // default's DoS-protection cap.
199
+ const proc = await bootServer(root, port, { MAUDE_EXPORT_MAX_QUEUED: '30' });
200
+ try {
201
+ const enqueue = async () => {
202
+ const r = await fetch(`http://localhost:${port}/_api/export-jobs`, {
203
+ method: 'POST',
204
+ headers: { 'content-type': 'application/json' },
205
+ body: JSON.stringify({ format: 'zip', scope: 'project-raw' }),
206
+ });
207
+ expect(r.status).toBe(202);
208
+ const { jobId } = (await r.json()) as { jobId: string };
209
+ return jobId;
210
+ };
211
+
212
+ const firstId = await enqueue();
213
+ // Wait for it to finish.
214
+ let done = false;
215
+ const deadline = Date.now() + 3000;
216
+ while (Date.now() < deadline && !done) {
217
+ const jobs = await listJobs(port);
218
+ done = jobs.find((j) => j.id === firstId)?.status === 'done';
219
+ if (!done) await Bun.sleep(15);
220
+ }
221
+ expect(done).toBe(true);
222
+
223
+ const dl = await fetch(`http://localhost:${port}/_api/export-jobs/download?id=${firstId}`);
224
+ expect(dl.status).toBe(200);
225
+ expect(dl.headers.get('Content-Type')).toBe('application/zip');
226
+ const bytes = await dl.arrayBuffer();
227
+ expect(bytes.byteLength).toBeGreaterThan(0);
228
+
229
+ // 404 for an unknown id.
230
+ const missing = await fetch(`http://localhost:${port}/_api/export-jobs/download?id=nope`);
231
+ expect(missing.status).toBe(404);
232
+
233
+ // Push 25 more jobs through so the first one rolls past the 20-cap and
234
+ // gets evicted (bytes removed from disk + dropped from the in-memory
235
+ // map). Eviction runs on EVERY completion, so the oldest jobs drop out
236
+ // of list() entirely well before the last of the 25 finishes — poll for
237
+ // "nothing left pending" rather than tracking specific (possibly
238
+ // already-evicted) ids.
239
+ for (let i = 0; i < 25; i += 1) {
240
+ await enqueue();
241
+ }
242
+ let settled = false;
243
+ const deadline2 = Date.now() + 4000;
244
+ while (Date.now() < deadline2 && !settled) {
245
+ const jobs = await listJobs(port);
246
+ settled = jobs.every((j) => j.status === 'done' || j.status === 'failed');
247
+ if (!settled) await Bun.sleep(15);
248
+ }
249
+ expect(settled).toBe(true);
250
+
251
+ const r = await fetch(`http://localhost:${port}/_api/export-history`);
252
+ const body = (await r.json()) as { history: unknown[] };
253
+ expect(body.history.length).toBe(20);
254
+
255
+ const evicted = await fetch(
256
+ `http://localhost:${port}/_api/export-jobs/download?id=${firstId}`
257
+ );
258
+ expect(evicted.status).toBe(404);
259
+ } finally {
260
+ await killProc(proc);
261
+ }
262
+ });
263
+ });
@@ -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
+ });