@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,130 @@
1
+ // dom-selection — resolveByDomPath, the structural fallback comments-overlay
2
+ // (and future consumers) reach for when a stored `data-cd-id` selector no
3
+ // longer identifies the intended element. This is the DDR-019 renumbering
4
+ // scenario: a canvas rewrite (/design:edit regenerating JSX) shifts every
5
+ // subsequent element's positional id within a component, so the id can end up
6
+ // on an unrelated element, or on none at all. See
7
+ // .ai/logs/rca/issue-comment-anchor-drift-and-popup-edge-clamp.md.
8
+ //
9
+ // Needs a live DOM (querySelectorAll / getAttribute walk) — register happy-dom
10
+ // for THIS file only, like annotations-roundtrip.test.ts.
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
13
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
14
+
15
+ beforeAll(() => {
16
+ GlobalRegistrator.register();
17
+ });
18
+ afterAll(() => {
19
+ GlobalRegistrator.unregister();
20
+ });
21
+
22
+ import { resolveByDomPath } from '../dom-selection.ts';
23
+
24
+ function setArtboard(html: string): void {
25
+ document.body.innerHTML = `<div data-dc-screen="art1">${html}</div>`;
26
+ }
27
+
28
+ describe('resolveByDomPath', () => {
29
+ test('finds the structurally-matching element when a same-id decoy has the wrong tag', () => {
30
+ // Simulates the renumbering bug: the stored data-cd-id ("bbb22222") now
31
+ // belongs to an unrelated <div>, while the ORIGINAL <button> the comment
32
+ // was anchored to survives under a different id ("ccc33333"). The
33
+ // fallback must find the button by structure, not by the stale id.
34
+ setArtboard(`
35
+ <div data-cd-id="bbb22222" class="decoy">Wrong element now has the old id</div>
36
+ <section class="hero">
37
+ <button class="cta primary" data-cd-id="ccc33333">Buy now</button>
38
+ </section>
39
+ `);
40
+ const found = resolveByDomPath(document, {
41
+ artboardId: 'art1',
42
+ tag: 'button',
43
+ classes: 'cta primary',
44
+ dom_path: [
45
+ 'html',
46
+ 'body',
47
+ 'div[data-dc-screen="art1"]',
48
+ 'section.hero',
49
+ 'button.cta.primary',
50
+ ],
51
+ });
52
+ expect(found).not.toBeNull();
53
+ expect(found?.getAttribute('data-cd-id')).toBe('ccc33333');
54
+ });
55
+
56
+ test('prefers the candidate with the longer matching ancestor suffix over a shallower same-tag sibling', () => {
57
+ setArtboard(`
58
+ <button class="cta primary" data-cd-id="aaa11111">Outer decoy button</button>
59
+ <section class="hero">
60
+ <button class="cta primary" data-cd-id="bbb22222">Nested target</button>
61
+ </section>
62
+ `);
63
+ const found = resolveByDomPath(document, {
64
+ artboardId: 'art1',
65
+ tag: 'button',
66
+ classes: 'cta primary',
67
+ dom_path: [
68
+ 'html',
69
+ 'body',
70
+ 'div[data-dc-screen="art1"]',
71
+ 'section.hero',
72
+ 'button.cta.primary',
73
+ ],
74
+ });
75
+ expect(found?.getAttribute('data-cd-id')).toBe('bbb22222');
76
+ });
77
+
78
+ test('returns null when no element in the artboard shares the stored tag', () => {
79
+ setArtboard(`<div data-cd-id="aaa11111" class="cta primary">Not a button</div>`);
80
+ const found = resolveByDomPath(document, {
81
+ artboardId: 'art1',
82
+ tag: 'button',
83
+ classes: 'cta primary',
84
+ dom_path: ['html', 'body', 'div[data-dc-screen="art1"]', 'button.cta.primary'],
85
+ });
86
+ expect(found).toBeNull();
87
+ });
88
+
89
+ test('returns null with no dom_path or no tag (nothing to match against)', () => {
90
+ setArtboard(`<button class="cta primary" data-cd-id="aaa11111">Buy now</button>`);
91
+ expect(
92
+ resolveByDomPath(document, { artboardId: 'art1', tag: 'button', classes: 'cta primary' })
93
+ ).toBeNull();
94
+ expect(
95
+ resolveByDomPath(document, {
96
+ artboardId: 'art1',
97
+ classes: 'cta primary',
98
+ dom_path: ['button.cta.primary'],
99
+ })
100
+ ).toBeNull();
101
+ });
102
+
103
+ test('scopes the search to the given artboard — a matching element in a different artboard is ignored', () => {
104
+ document.body.innerHTML = `
105
+ <div data-dc-screen="art1">
106
+ <button class="cta primary" data-cd-id="aaa11111">Art1 button</button>
107
+ </div>
108
+ <div data-dc-screen="art2">
109
+ <section class="hero">
110
+ <button class="cta primary" data-cd-id="bbb22222">Art2 button</button>
111
+ </section>
112
+ </div>
113
+ `;
114
+ const found = resolveByDomPath(document, {
115
+ artboardId: 'art1',
116
+ tag: 'button',
117
+ classes: 'cta primary',
118
+ dom_path: [
119
+ 'html',
120
+ 'body',
121
+ 'div[data-dc-screen="art2"]',
122
+ 'section.hero',
123
+ 'button.cta.primary',
124
+ ],
125
+ });
126
+ // The art2 button structurally matches perfectly, but art1 is the requested
127
+ // scope — the only art1 candidate is a shallower, shorter-suffix match.
128
+ expect(found?.getAttribute('data-cd-id')).toBe('aaa11111');
129
+ });
130
+ });
@@ -0,0 +1,81 @@
1
+ // canvas-edit.ts — Stage H3 (feature-element-editing-robustness). A whole-
2
+ // component-instance move/resize passes a DOM-occurrence index into edit-css so
3
+ // `applyEdit` routes the write to that instance's `<Component/>` USAGE (local to
4
+ // the dragged instance) instead of the shared inner definition (global). The knob
5
+ // / paste-style paths pass NO occurrence, so inner-shared-element styling stays
6
+ // global-and-labeled — the H2 badge is the answer there (the chosen model).
7
+ // Positional ids are round-tripped through transpileCanvasSource like the sibling
8
+ // structural test, so the arithmetic stays in lockstep with the pipeline.
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+
12
+ import { applyEdit } from '../canvas-edit.ts';
13
+ import { transpileCanvasSource } from '../canvas-pipeline.ts';
14
+
15
+ const CANVAS = '/abs/Canvas.tsx';
16
+
17
+ /** Map data-cd-id by element-type name (first occurrence), like canvas-edit.test.ts. */
18
+ function idsOf(source: string): Record<string, string> {
19
+ const { withIds } = transpileCanvasSource(CANVAS, source);
20
+ const out: Record<string, string> = {};
21
+ for (const m of withIds.matchAll(/<(\w+)([^>]*?)data-cd-id="([0-9a-f]{8})"/g)) {
22
+ if (!out[m[1] as string]) out[m[1] as string] = m[3] as string;
23
+ }
24
+ return out;
25
+ }
26
+
27
+ const SHARED = [
28
+ 'function Card() { return <article>card</article>; }',
29
+ 'function Demo() {',
30
+ ' return (',
31
+ ' <section>',
32
+ ' <Card />',
33
+ ' <Card />',
34
+ ' <Card />',
35
+ ' </section>',
36
+ ' );',
37
+ '}',
38
+ ].join('\n');
39
+
40
+ describe('canvas-edit / applyEdit — Stage H3 occurrence routing', () => {
41
+ test('with an occurrence, a style edit routes to that <Component/> usage (local)', () => {
42
+ // The inner <article> id is shared across all three <Card/> usages.
43
+ const innerId = idsOf(SHARED).article as string;
44
+ const out = applyEdit(CANVAS, SHARED, innerId, 'style.left', '"40px"', 1); // 2nd instance
45
+ // The 2nd (index 1) <Card/> usage gains the inline style; the shared inner
46
+ // <article> definition is untouched, so the other two instances don't move.
47
+ expect(out.source).toContain('<Card style={{ left: "40px" }}');
48
+ expect(out.source.match(/<Card \/>/g)?.length).toBe(2);
49
+ expect(out.source).toContain('<article>card</article>');
50
+ });
51
+
52
+ test('without an occurrence, the same edit stays on the shared inner element (global)', () => {
53
+ const innerId = idsOf(SHARED).article as string;
54
+ const out = applyEdit(CANVAS, SHARED, innerId, 'style.left', '"40px"');
55
+ // No occurrence → knob / paste-style behavior: the write lands on the inner
56
+ // <article>, changing every rendered instance (the "inner styling is global"
57
+ // model the H2 badge labels).
58
+ expect(out.source).toContain('<article style={{ left: "40px" }}>card</article>');
59
+ expect(out.source).not.toContain('<Card style=');
60
+ });
61
+
62
+ test('a normal (non-instance) element ignores the occurrence (no-op remap)', () => {
63
+ const src = 'function Demo() { return <div>x</div>; }';
64
+ const id = idsOf(src).div as string;
65
+ const out = applyEdit(CANVAS, src, id, 'style.padding', '14', 3);
66
+ expect(out.source).toContain('<div style={{ padding: 14 }}>x</div>');
67
+ });
68
+
69
+ test('an element in a single-usage component ignores the occurrence (not splittable)', () => {
70
+ const src = [
71
+ 'function Card() { return <article>card</article>; }',
72
+ 'function Demo() { return <section><Card /></section>; }',
73
+ ].join('\n');
74
+ const innerId = idsOf(src).article as string;
75
+ // One usage → resolveUsageId returns the inner id (can't split) — the edit
76
+ // lands on <article>, never fabricates a phantom usage index.
77
+ const out = applyEdit(CANVAS, src, innerId, 'style.left', '"40px"', 0);
78
+ expect(out.source).toContain('<article style={{ left: "40px" }}>card</article>');
79
+ expect(out.source).not.toContain('<Card style=');
80
+ });
81
+ });
@@ -0,0 +1,115 @@
1
+ // GET /_api/edit-scope — the INV-3 local-vs-shared predictability verdict
2
+ // (feature-element-editing-robustness Stage H). Boots a real server, serves a
3
+ // canvas with a `Card` component reused 3×, then asserts:
4
+ // • an element INSIDE the reused component → scope 'shared', affects 3
5
+ // • a top-level artboard element → scope 'local'
6
+ // • the `.map()` caveat (DDR-139) — a local element rendered N× (client-
7
+ // supplied `rendered`) → scope 'shared', reason 'mapped'
8
+ // • the route is main-origin only (proven separately in canvas-origin-gate).
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
15
+
16
+ const CANVAS_SRC = `const Card = ({ title }) => (
17
+ <div className="card">
18
+ <h3>{title}</h3>
19
+ </div>
20
+ );
21
+
22
+ export default function Gallery() {
23
+ return (
24
+ <DesignCanvas>
25
+ <DCArtboard id="home" label="Home" width={1440} height={1024}>
26
+ <section className="grid">
27
+ <Card title="A" />
28
+ <Card title="B" />
29
+ <Card title="C" />
30
+ </section>
31
+ </DCArtboard>
32
+ </DesignCanvas>
33
+ );
34
+ }
35
+ `;
36
+
37
+ interface LocEntry {
38
+ line: number;
39
+ jsxPath: string[];
40
+ componentName: string;
41
+ }
42
+
43
+ async function boot() {
44
+ const { root, designRoot } = makeSandbox();
45
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
46
+ writeFileSync(join(designRoot, 'ui', 'Gallery.tsx'), CANVAS_SRC);
47
+ const port = nextPort();
48
+ const proc = await bootServer(root, port);
49
+ return { designRoot, main: `http://localhost:${port}`, proc };
50
+ }
51
+
52
+ /** Warm the pipeline (stamps cd-ids + writes _locator.json), return the map. */
53
+ async function locatorMap(main: string, designRoot: string): Promise<Record<string, LocEntry>> {
54
+ const r = await fetch(`${main}/.design/ui/Gallery.tsx`, { signal: AbortSignal.timeout(2000) });
55
+ expect(r.status).toBe(200);
56
+ const locator = JSON.parse(readFileSync(join(designRoot, '_locator.json'), 'utf8'));
57
+ return locator['ui/Gallery'] as Record<string, LocEntry>;
58
+ }
59
+
60
+ function findId(map: Record<string, LocEntry>, pred: (e: LocEntry) => boolean): string {
61
+ const hit = Object.entries(map).find(([, e]) => pred(e));
62
+ if (!hit) throw new Error('no matching locator entry');
63
+ return hit[0];
64
+ }
65
+
66
+ async function scope(main: string, id: string, rendered?: number) {
67
+ const q = new URLSearchParams({ canvas: 'ui/Gallery', id });
68
+ if (rendered != null) q.set('rendered', String(rendered));
69
+ const res = await fetch(`${main}/_api/edit-scope?${q}`, { signal: AbortSignal.timeout(2000) });
70
+ return { status: res.status, body: (await res.json()) as Record<string, unknown> };
71
+ }
72
+
73
+ describe('GET /_api/edit-scope', () => {
74
+ test('reports shared for a reused-component inner element, local for a top-level one', async () => {
75
+ const { designRoot, main, proc } = await boot();
76
+ try {
77
+ const map = await locatorMap(main, designRoot);
78
+
79
+ // An element inside the reused `Card` component (componentName === 'Card').
80
+ const innerId = findId(map, (e) => e.componentName === 'Card');
81
+ const shared = await scope(main, innerId);
82
+ expect(shared.status).toBe(200);
83
+ expect(shared.body.scope).toBe('shared');
84
+ expect(shared.body.componentName).toBe('Card');
85
+ expect(shared.body.affects).toBe(3);
86
+ expect(shared.body.reason).toBe('component');
87
+
88
+ // A top-level artboard element (the <section>) — not inside a reused comp.
89
+ const topId = findId(
90
+ map,
91
+ (e) => e.componentName !== 'Card' && e.jsxPath[e.jsxPath.length - 1] === 'section'
92
+ );
93
+ const local = await scope(main, topId);
94
+ expect(local.status).toBe(200);
95
+ expect(local.body.scope).toBe('local');
96
+ expect(local.body.affects).toBe(1);
97
+ expect(local.body.reason).toBe('single');
98
+
99
+ // The .map() caveat (DDR-139): a local element the client says renders N×
100
+ // is 'shared' ('mapped') even though its source usage count is 1.
101
+ const mapped = await scope(main, topId, 4);
102
+ expect(mapped.body.scope).toBe('shared');
103
+ expect(mapped.body.reason).toBe('mapped');
104
+ expect(mapped.body.affects).toBe(4);
105
+
106
+ // A bogus id is a 400 (invalid data-cd-id shape).
107
+ const bad = await fetch(`${main}/_api/edit-scope?canvas=ui/Gallery&id=zzz`, {
108
+ signal: AbortSignal.timeout(2000),
109
+ });
110
+ expect(bad.status).toBe(400);
111
+ } finally {
112
+ await killProc(proc as never);
113
+ }
114
+ });
115
+ });
@@ -0,0 +1,136 @@
1
+ // element-resize — feature-element-editing-robustness Stage D (Task D1 + G2).
2
+ // Pure resize math: `computeElementResize(corner, start, dxW, dyW, mods, flags)`.
3
+ // World-unit deltas → target box, deriving left/top only when the moved edge
4
+ // permits it (out-of-flow element with an inline value), holding the opposite
5
+ // edge fixed (Figma grammar).
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+ import {
9
+ computeElementResize,
10
+ type ElResizeStart,
11
+ pointerAngleRad,
12
+ rotateDeltaDeg,
13
+ rotatePointDeg,
14
+ rotationDegFromMatrix,
15
+ scaleFromMatrix,
16
+ } from '../use-element-resize.tsx';
17
+
18
+ const NO_MODS = { aspect: false, center: false };
19
+ const NO_MOVE = { canMoveLeft: false, canMoveTop: false };
20
+ const CAN_MOVE = { canMoveLeft: true, canMoveTop: true };
21
+
22
+ // A 200×100 box at left:50 top:30 (out-of-flow when CAN_MOVE).
23
+ const START: ElResizeStart = { w: 200, h: 100, left: 50, top: 30 };
24
+
25
+ describe('computeElementResize — plain drags', () => {
26
+ test('SE corner grows width + height, never moves origin', () => {
27
+ const r = computeElementResize('se', START, 40, 20, NO_MODS, CAN_MOVE);
28
+ expect(r.width).toBe(240);
29
+ expect(r.height).toBe(120);
30
+ expect(r.left).toBeUndefined();
31
+ expect(r.top).toBeUndefined();
32
+ });
33
+
34
+ test('E edge changes width only', () => {
35
+ const r = computeElementResize('e', START, 30, 999, NO_MODS, CAN_MOVE);
36
+ expect(r.width).toBe(230);
37
+ expect(r.height).toBe(100);
38
+ });
39
+
40
+ test('S edge changes height only', () => {
41
+ const r = computeElementResize('s', START, 999, -25, NO_MODS, CAN_MOVE);
42
+ expect(r.width).toBe(200);
43
+ expect(r.height).toBe(75);
44
+ });
45
+
46
+ test('NW corner (out-of-flow) moves left+top, SE corner stays fixed', () => {
47
+ // Drag NW by (+10, +10): width 190, height 90; left 60, top 40.
48
+ const r = computeElementResize('nw', START, 10, 10, NO_MODS, CAN_MOVE);
49
+ expect(r.width).toBe(190);
50
+ expect(r.height).toBe(90);
51
+ expect(r.left).toBe(60); // start.left + start.w - width = 50 + 200 - 190
52
+ expect(r.top).toBe(40); // start.top + start.h - height = 30 + 100 - 90
53
+ // Opposite (SE) corner is invariant: left+width and top+height unchanged.
54
+ expect((r.left as number) + r.width).toBe(START.left + START.w);
55
+ expect((r.top as number) + r.height).toBe(START.top + START.h);
56
+ });
57
+
58
+ test('NW corner IN-FLOW resizes width/height but never writes left/top', () => {
59
+ const r = computeElementResize('nw', START, 10, 10, NO_MODS, NO_MOVE);
60
+ expect(r.width).toBe(190);
61
+ expect(r.height).toBe(90);
62
+ expect(r.left).toBeUndefined();
63
+ expect(r.top).toBeUndefined();
64
+ });
65
+
66
+ test('W edge (out-of-flow) moves left, keeps the east edge fixed', () => {
67
+ const r = computeElementResize('w', START, -20, 0, NO_MODS, CAN_MOVE);
68
+ expect(r.width).toBe(220); // start.w - dxW = 200 - (-20)
69
+ expect(r.left).toBe(30); // 50 + 200 - 220
70
+ expect(r.top).toBeUndefined();
71
+ });
72
+ });
73
+
74
+ describe('computeElementResize — modifiers', () => {
75
+ test('Shift on SE corner locks the start aspect ratio (2:1)', () => {
76
+ // Bigger horizontal change → height driven by width to keep 2:1.
77
+ const r = computeElementResize('se', START, 100, 5, { aspect: true, center: false }, CAN_MOVE);
78
+ expect(r.width).toBe(300);
79
+ expect(r.height).toBe(150); // 300 / (200/100)
80
+ expect(r.width / r.height).toBe(START.w / START.h);
81
+ });
82
+
83
+ test('Alt on E edge (out-of-flow) resizes from center: 2× delta, left shifts', () => {
84
+ const r = computeElementResize('e', START, 20, 0, { aspect: false, center: true }, CAN_MOVE);
85
+ expect(r.width).toBe(240); // 200 + 2*20
86
+ expect(r.left).toBe(30); // center-preserving: 50 + (200-240)/2
87
+ // Center is invariant: left + width/2 unchanged.
88
+ expect((r.left as number) + r.width / 2).toBe(START.left + START.w / 2);
89
+ });
90
+
91
+ test('shrinking past zero clamps to the 1px minimum', () => {
92
+ const r = computeElementResize('se', START, -500, -500, NO_MODS, CAN_MOVE);
93
+ expect(r.width).toBe(1);
94
+ expect(r.height).toBe(1);
95
+ });
96
+ });
97
+
98
+ describe('rotate math (Task L8)', () => {
99
+ test('rotationDegFromMatrix reads the angle', () => {
100
+ expect(rotationDegFromMatrix('none')).toBe(0);
101
+ expect(rotationDegFromMatrix(null)).toBe(0);
102
+ expect(rotationDegFromMatrix('matrix(1, 0, 0, 1, 0, 0)')).toBe(0);
103
+ expect(rotationDegFromMatrix('matrix(0, 1, -1, 0, 0, 0)')).toBe(90); // CW 90°
104
+ expect(Math.round(rotationDegFromMatrix('matrix(0.7071, 0.7071, -0.7071, 0.7071, 0, 0)'))).toBe(
105
+ 45
106
+ );
107
+ });
108
+
109
+ test('scaleFromMatrix reads the .dc-world zoom', () => {
110
+ expect(scaleFromMatrix('none')).toBe(1);
111
+ expect(scaleFromMatrix('matrix(2, 0, 0, 2, 100, 50)')).toBe(2);
112
+ // Scale survives a combined rotate+scale (magnitude of the first column).
113
+ expect(scaleFromMatrix('matrix(0, 1.5, -1.5, 0, 0, 0)')).toBe(1.5);
114
+ });
115
+
116
+ test('rotatePointDeg rotates a local offset (screen y-down, CW positive)', () => {
117
+ const [x0, y0] = rotatePointDeg(10, 0, 0);
118
+ expect(x0).toBeCloseTo(10);
119
+ expect(y0).toBeCloseTo(0);
120
+ const [x90, y90] = rotatePointDeg(10, 0, 90);
121
+ expect(x90).toBeCloseTo(0);
122
+ expect(y90).toBeCloseTo(10);
123
+ });
124
+
125
+ test('rotateDeltaDeg turns by the change in center→pointer angle; Shift snaps 15°', () => {
126
+ const start = pointerAngleRad(100, 100, 200, 100); // pointer right → 0 rad
127
+ const down = pointerAngleRad(100, 100, 100, 200); // pointer down → +π/2
128
+ // From 0°, dragging the pointer from right to down = +90°.
129
+ expect(rotateDeltaDeg(0, start, down, false)).toBe(90);
130
+ // No pointer change → angle unchanged (relative model).
131
+ expect(rotateDeltaDeg(30, start, start, false)).toBe(30);
132
+ // Shift snaps to a 15° multiple.
133
+ expect(rotateDeltaDeg(0, start, down, true) % 15).toBe(0);
134
+ expect(rotateDeltaDeg(0, start, down, true)).toBe(90);
135
+ });
136
+ });