@1agh/maude 0.45.2 → 0.47.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 (91) hide show
  1. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  2. package/apps/studio/acp/bridge.ts +151 -1
  3. package/apps/studio/annotations-layer.tsx +42 -0
  4. package/apps/studio/annotations-snap.ts +50 -10
  5. package/apps/studio/api.ts +760 -15
  6. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  7. package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
  8. package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
  9. package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
  10. package/apps/studio/bin/_curl-local.mjs +349 -0
  11. package/apps/studio/bin/_curl-local.test.mjs +280 -0
  12. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  13. package/apps/studio/bin/_png-playwright.mjs +38 -4
  14. package/apps/studio/bin/_pw-launch.mjs +52 -0
  15. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  16. package/apps/studio/bin/_smart-frames.mjs +419 -0
  17. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  18. package/apps/studio/bin/agent-browser-safe.sh +29 -0
  19. package/apps/studio/bin/curl-local.sh +28 -0
  20. package/apps/studio/bin/smart-frames.sh +30 -0
  21. package/apps/studio/canvas-cursors.ts +6 -0
  22. package/apps/studio/canvas-edit.ts +972 -6
  23. package/apps/studio/canvas-icons.tsx +13 -0
  24. package/apps/studio/canvas-lib.tsx +206 -11
  25. package/apps/studio/canvas-shell.tsx +809 -50
  26. package/apps/studio/client/app.jsx +1554 -206
  27. package/apps/studio/client/panels/ChatPanel.jsx +92 -3
  28. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  29. package/apps/studio/client/panels/acp-capabilities.js +11 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +41 -0
  32. package/apps/studio/contextual-toolbar.tsx +5 -3
  33. package/apps/studio/dist/client.bundle.js +1627 -1627
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/grid-track-handles.ts +179 -0
  44. package/apps/studio/handoff.ts +35 -0
  45. package/apps/studio/http.ts +331 -7
  46. package/apps/studio/input-router.tsx +73 -17
  47. package/apps/studio/print/marks.ts +113 -0
  48. package/apps/studio/print/units.ts +269 -0
  49. package/apps/studio/print-overlay-content.tsx +132 -0
  50. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  51. package/apps/studio/test/acp-session-allowed-tools.test.ts +187 -0
  52. package/apps/studio/test/annotations-snap.test.ts +56 -0
  53. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  54. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  55. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  56. package/apps/studio/test/browse-posture.test.tsx +107 -0
  57. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  58. package/apps/studio/test/canvas-meta-api.test.ts +237 -0
  59. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  60. package/apps/studio/test/comment-mount.test.ts +2 -1
  61. package/apps/studio/test/component-map.test.ts +48 -0
  62. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  63. package/apps/studio/test/detach-component.test.ts +94 -0
  64. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  65. package/apps/studio/test/element-structural-api.test.ts +74 -0
  66. package/apps/studio/test/element-structural-edit.test.ts +363 -0
  67. package/apps/studio/test/exporters/png.test.ts +49 -1
  68. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  69. package/apps/studio/test/handoff.test.ts +48 -0
  70. package/apps/studio/test/input-router.test.ts +82 -8
  71. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  72. package/apps/studio/test/pdf-print-boxes.test.ts +326 -0
  73. package/apps/studio/test/print-marks.test.ts +113 -0
  74. package/apps/studio/test/print-units.test.ts +173 -0
  75. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  76. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  77. package/apps/studio/tool-palette.tsx +3 -1
  78. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  79. package/apps/studio/use-chrome-visibility.tsx +19 -0
  80. package/apps/studio/use-element-resize.tsx +24 -3
  81. package/apps/studio/use-grid-track-handles.tsx +364 -0
  82. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  83. package/apps/studio/use-snap-guides.tsx +73 -5
  84. package/apps/studio/use-spacing-handles.tsx +9 -5
  85. package/apps/studio/use-tool-mode.tsx +30 -3
  86. package/apps/studio/web-overlay-content.tsx +52 -0
  87. package/apps/studio/whats-new.json +55 -0
  88. package/cli/commands/design.mjs +25 -1
  89. package/package.json +8 -8
  90. package/plugins/design/dependencies.json +35 -0
  91. package/plugins/design/templates/_shell.html +4 -0
@@ -0,0 +1,187 @@
1
+ // DDR-184 (+ DDR-185, hardened per its security addendum) — the curated tool
2
+ // allow-list the bridge auto-approves so the design workflow never stalls on
3
+ // a per-edit / per-`maude` permission prompt (the "Manual mode blocks every
4
+ // edit" complaint). Three things under test:
5
+ //
6
+ // (1) WIRE contract — the list lands on `_meta.claudeCode.options.allowedTools`
7
+ // (SDK `allowedTools`, sdk.d.ts:1331), coexisting with the DDR-144
8
+ // `settingSources` narrowing and the DDR-143 `plugins` carrier. Same
9
+ // adapter/SDK-INTERNAL `_meta` path as acp-session-plugins.test.ts — a
10
+ // bump that stops forwarding it must fail HERE, not silently re-prompt.
11
+ // (2) SOURCE-OF-TRUTH guard — the Bash rules are EXACTLY `Bash(maude:*)`
12
+ // plus a closed read-only fs verb set (DDR-185) — nothing else. A
13
+ // security fan-out found the ORIGINAL DDR-185 cut's `Bash(agent-browser:*)`
14
+ // entry was a zero-confirmation session-hijack primitive (agent-browser's
15
+ // own bundled skill recommends a persistent, real-login Chrome profile)
16
+ // and `Bash(find:*)` was unrestricted command execution via `-exec`, not
17
+ // "mutation" as first characterized — both are cut, not patched, because
18
+ // neither residual is fixable via prefix-matching. `agent-browser` is
19
+ // now reached ONLY through the hardened `agent-browser-safe` verb
20
+ // (`Bash(maude:*)` covers it — no separate allow-list entry). If any of
21
+ // this drifts, this test fails loudly.
22
+ // (3) `WebSearch`/`WebFetch` (DDR-185) are present as bare native tool names
23
+ // — no prefix-matching involved, so no source-of-truth guard needed the
24
+ // way the Bash rules need one.
25
+
26
+ import { describe, expect, test } from 'bun:test';
27
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
28
+ import { dirname, join } from 'node:path';
29
+
30
+ import { MAUDE_DEFAULT_ALLOWED_TOOLS, newSessionParams } from '../acp/bridge.ts';
31
+ import type { SdkPluginConfig } from '../acp/plugin-bootstrap.ts';
32
+
33
+ type Meta = {
34
+ systemPrompt?: { append?: string };
35
+ claudeCode?: {
36
+ options?: {
37
+ allowedTools?: string[];
38
+ plugins?: SdkPluginConfig[];
39
+ settingSources?: string[];
40
+ };
41
+ };
42
+ };
43
+
44
+ const PLUGINS: SdkPluginConfig[] = [
45
+ { type: 'local', path: '/bundle/plugins/design', skipMcpDiscovery: true },
46
+ ];
47
+
48
+ describe('newSessionParams — allowedTools carrier shape (DDR-184)', () => {
49
+ test('the allow-list rides _meta.claudeCode.options.allowedTools', () => {
50
+ const opts = (newSessionParams('/repo', undefined)._meta as Meta).claudeCode?.options;
51
+ expect(opts?.allowedTools).toEqual([...MAUDE_DEFAULT_ALLOWED_TOOLS]);
52
+ });
53
+
54
+ test('allowedTools is a COPY, not the shared module constant (no accidental mutation)', () => {
55
+ const opts = (newSessionParams('/repo')._meta as Meta).claudeCode?.options;
56
+ expect(opts?.allowedTools).not.toBe(MAUDE_DEFAULT_ALLOWED_TOOLS);
57
+ });
58
+
59
+ test('coexists with settingSources (DDR-144) and plugins (DDR-143) under one options', () => {
60
+ const opts = (newSessionParams('/repo', 'BRIEF', PLUGINS)._meta as Meta).claudeCode?.options;
61
+ expect(opts?.settingSources).toEqual(['user']);
62
+ expect(opts?.plugins).toEqual(PLUGINS);
63
+ expect(opts?.allowedTools).toEqual([...MAUDE_DEFAULT_ALLOWED_TOOLS]);
64
+ });
65
+
66
+ test('present even with no plugins (npm / web / power-user path)', () => {
67
+ const opts = (newSessionParams('/repo', 'BRIEF', [])._meta as Meta).claudeCode?.options;
68
+ expect(opts?.plugins).toBeUndefined();
69
+ expect(opts?.allowedTools).toEqual([...MAUDE_DEFAULT_ALLOWED_TOOLS]);
70
+ });
71
+ });
72
+
73
+ describe('MAUDE_DEFAULT_ALLOWED_TOOLS — source-of-truth guard (DDR-184 / DDR-062)', () => {
74
+ const bashRules = MAUDE_DEFAULT_ALLOWED_TOOLS.filter((t) => t.startsWith('Bash'));
75
+
76
+ test('the canvas-editing file tools are all present', () => {
77
+ for (const t of ['Read', 'Edit', 'Write', 'Glob', 'Grep', 'NotebookEdit']) {
78
+ expect(MAUDE_DEFAULT_ALLOWED_TOOLS).toContain(t);
79
+ }
80
+ });
81
+
82
+ test('WebSearch and WebFetch are present (DDR-185)', () => {
83
+ for (const t of ['WebSearch', 'WebFetch']) {
84
+ expect(MAUDE_DEFAULT_ALLOWED_TOOLS).toContain(t);
85
+ }
86
+ });
87
+
88
+ test('the Bash rules are EXACTLY this closed set — never a bare/un-scoped Bash', () => {
89
+ // A bare `Bash` (or `Bash(*)`) would auto-approve arbitrary command execution
90
+ // — exactly the blanket surface DDR-179 kept behind the prompt. Guard against
91
+ // anyone widening it: the list may auto-approve `maude` and the closed
92
+ // read-only fs verb set (DDR-185) — nothing else. In particular:
93
+ // - `Bash(curl:*)` must NEVER appear — curl is routed through the
94
+ // `curl-local` verb (covered by `Bash(maude:*)`) instead.
95
+ // - `Bash(agent-browser:*)` must NEVER appear — a security fan-out found
96
+ // it was a zero-confirmation session-hijack primitive; agent-browser is
97
+ // routed through the `agent-browser-safe` verb instead.
98
+ // - `Bash(find:*)` must NEVER appear — `find -exec` is unrestricted
99
+ // command execution, not "mutation"; there is no verb substitute, it
100
+ // is simply cut.
101
+ expect(bashRules).toEqual([
102
+ 'Bash(maude:*)',
103
+ 'Bash(ls:*)',
104
+ 'Bash(cat:*)',
105
+ 'Bash(pwd:*)',
106
+ 'Bash(head:*)',
107
+ 'Bash(tail:*)',
108
+ 'Bash(wc:*)',
109
+ 'Bash(tree:*)',
110
+ 'Bash(file:*)',
111
+ 'Bash(stat:*)',
112
+ ]);
113
+ });
114
+
115
+ test('the `maude` Bash rule is load-bearing on DDR-062: every design helper is a `maude design <verb>`', () => {
116
+ // Resolve cli/commands/design.mjs relative to this test file's real path
117
+ // (mirrors acp-session-plugins.test.ts's realpath dance for compiled roots).
118
+ const here = dirname(realpathSync(import.meta.url.replace('file://', '')));
119
+ const designCli = join(here, '..', '..', '..', 'cli', 'commands', 'design.mjs');
120
+ expect(existsSync(designCli)).toBe(true);
121
+ const src = readFileSync(designCli, 'utf8');
122
+ // The dispatch table the `Bash(maude:*)` scope rests on — helpers reached via
123
+ // `maude design <verb>`, not raw `bash <path>.sh` (DDR-062). If this marker
124
+ // disappears, the one-rule assumption needs re-checking.
125
+ expect(src).toContain('BIN_VERBS');
126
+ expect(src).toMatch(/maude design <verb>/);
127
+ });
128
+
129
+ test('the `curl-local` verb exists and is reached via `maude design <verb>` (DDR-185)', () => {
130
+ // The mechanism the `Bash(agent-browser:*)`/fs-list comment in bridge.ts
131
+ // promises for curl: real host enforcement via a first-party verb, not a
132
+ // Bash prefix rule. If this verb disappears from BIN_VERBS, the DDR-185
133
+ // curl story (and the bootstrap-brief nudge pointing at it) goes stale.
134
+ const here = dirname(realpathSync(import.meta.url.replace('file://', '')));
135
+ const designCli = join(here, '..', '..', '..', 'cli', 'commands', 'design.mjs');
136
+ const src = readFileSync(designCli, 'utf8');
137
+ expect(src).toMatch(/'curl-local'/);
138
+ });
139
+
140
+ test('the `agent-browser-safe` verb exists and is reached via `maude design <verb>` (DDR-185 security addendum)', () => {
141
+ // The replacement for the reverted `Bash(agent-browser:*)` entry — real
142
+ // subcommand/flag enforcement via a first-party verb, not a Bash prefix
143
+ // rule. If this verb disappears from BIN_VERBS, motion-critic.md/edit.md's
144
+ // documented raw agent-browser calls go back to prompting (not silently
145
+ // insecure — but the friction-reduction this DDR promises goes stale).
146
+ const here = dirname(realpathSync(import.meta.url.replace('file://', '')));
147
+ const designCli = join(here, '..', '..', '..', 'cli', 'commands', 'design.mjs');
148
+ const src = readFileSync(designCli, 'utf8');
149
+ expect(src).toMatch(/'agent-browser-safe'/);
150
+ });
151
+
152
+ test('plugin markdown no longer documents a RAW agent-browser SCREENSHOT/OPEN/NAVIGATE call (DDR-185 security addendum)', () => {
153
+ // motion-critic.md and edit.md are the two files that originally justified
154
+ // `Bash(agent-browser:*)` (raw calls not reached via `maude design <verb>`).
155
+ // screenshot/open/navigate now go through the hardened wrapper — a
156
+ // regression here means either those calls silently went back to
157
+ // prompting every time, or (worse) someone re-introduced a raw call the
158
+ // allow-list no longer covers. `eval` is the deliberate exception (round
159
+ // 3: removed from the wrapper's own allow-list entirely — arbitrary JS
160
+ // execution can't be argv-constrained — so it's EXPECTED to stay a raw,
161
+ // prompting `agent-browser eval` call; see both files' own comments).
162
+ const here = dirname(realpathSync(import.meta.url.replace('file://', '')));
163
+ const pluginsDir = join(here, '..', '..', '..', 'plugins', 'design');
164
+ for (const rel of ['agents/motion-critic.md', 'commands/edit.md']) {
165
+ const src = readFileSync(join(pluginsDir, rel), 'utf8');
166
+ expect(src).not.toMatch(/(?<!maude design )agent-browser (screenshot|open|navigate)\b/);
167
+ expect(src).toContain('agent-browser eval'); // deliberately raw — see comment above
168
+ }
169
+ // Only motion-critic.md actually calls the wrapper (for `screenshot`) —
170
+ // edit.md's sole agent-browser use was `eval`, which reverted fully to
171
+ // raw, so it has no `agent-browser-safe` invocation left at all.
172
+ const motionCritic = readFileSync(join(pluginsDir, 'agents/motion-critic.md'), 'utf8');
173
+ expect(motionCritic).toContain('maude design agent-browser-safe');
174
+ });
175
+
176
+ test('agent-browser-safe does NOT support eval (DDR-185 round-3 security addendum)', () => {
177
+ // A future accidental re-add of eval to the wrapper's own allow-list
178
+ // would silently reopen the zero-click session-hijack chain (ethical-
179
+ // hacker Finding 1: eval's fetch()/window.location defeat
180
+ // --allowed-domains entirely, and no argv check can constrain arbitrary
181
+ // JS execution). Source-of-truth guard against bridge.ts's own wrapper.
182
+ const here = dirname(realpathSync(import.meta.url.replace('file://', '')));
183
+ const wrapperPath = join(here, '..', 'bin', '_agent-browser-safe.mjs');
184
+ const src = readFileSync(wrapperPath, 'utf8');
185
+ expect(src).not.toMatch(/^\s*eval:\s*\[/m);
186
+ });
187
+ });
@@ -5,6 +5,7 @@ import { describe, expect, test } from 'bun:test';
5
5
  import {
6
6
  computeSnap,
7
7
  GRID_PITCH_PX,
8
+ type GuideLineCandidate,
8
9
  SNAP_THRESHOLD_PX,
9
10
  type SnapBox,
10
11
  } from '../annotations-snap.ts';
@@ -77,3 +78,58 @@ describe('computeSnap', () => {
77
78
  expect(res.dy).toBe(0);
78
79
  });
79
80
  });
81
+
82
+ // feature-1-artboard-kinds-foundation, T7 — generic-layout-guide lines feed
83
+ // the same candidate pool as stroke/artboard bboxes.
84
+ describe('computeSnap / guide lines (T7)', () => {
85
+ test('leading edge snaps to a guide line within threshold', () => {
86
+ const guideLines: GuideLineCandidate[] = [{ axis: 'x', at: 100, from: 0, to: 1000 }];
87
+ const res = computeSnap(box(104, 300), [], SNAP_THRESHOLD_PX, { guideLines });
88
+ expect(res.dx).toBe(-4);
89
+ expect(res.guides).toHaveLength(1);
90
+ expect(res.guides[0]).toMatchObject({ axis: 'x', at: 100, from: 0, to: 1000 });
91
+ });
92
+
93
+ test('center also tests against a guide line', () => {
94
+ // moving center x = 153, guide at 150 → dx -3.
95
+ const guideLines: GuideLineCandidate[] = [{ axis: 'y', at: 350, from: 0, to: 1000 }];
96
+ const res = computeSnap(box(103, 300), [], SNAP_THRESHOLD_PX, { guideLines });
97
+ // center y = 350, guide at 350 → dy 0 (exact), still emits a guide.
98
+ expect(res.dy).toBe(0);
99
+ expect(res.guides.find((g) => g.axis === 'y')).toMatchObject({ at: 350 });
100
+ });
101
+
102
+ test('nearest-wins applies across BOTH sources — a closer guide beats a farther candidate', () => {
103
+ const guideLines: GuideLineCandidate[] = [{ axis: 'x', at: 105, from: 0, to: 1000 }];
104
+ const res = computeSnap(box(104, 300), [box(100, 0)], SNAP_THRESHOLD_PX, { guideLines });
105
+ // candidate edge (100) is 4 away; guide (105) is 1 away — guide wins.
106
+ expect(res.dx).toBe(1);
107
+ });
108
+
109
+ test('a closer stroke/artboard candidate beats a farther guide line', () => {
110
+ // candidate edge (100) is 4 away from x=104; guide (110) is 6 away — the
111
+ // candidate is strictly closer, so it wins even though the guide is
112
+ // still within the 6px threshold.
113
+ const guideLines: GuideLineCandidate[] = [{ axis: 'x', at: 110, from: 0, to: 1000 }];
114
+ const res = computeSnap(box(104, 300), [box(100, 0)], SNAP_THRESHOLD_PX, { guideLines });
115
+ expect(res.dx).toBe(-4);
116
+ });
117
+
118
+ test('outside threshold, a guide line is ignored', () => {
119
+ const guideLines: GuideLineCandidate[] = [{ axis: 'x', at: 500, from: 0, to: 1000 }];
120
+ const res = computeSnap(box(104, 300), [], SNAP_THRESHOLD_PX, { guideLines });
121
+ expect(res.dx).toBe(0);
122
+ expect(res.guides).toHaveLength(0);
123
+ });
124
+
125
+ test('guide-line snap composes with the grid fallback on the OTHER axis', () => {
126
+ // x snaps to the guide line; y has no candidate → grid fallback fires.
127
+ const guideLines: GuideLineCandidate[] = [{ axis: 'x', at: 100, from: 0, to: 1000 }];
128
+ const res = computeSnap(box(104, 50), [], SNAP_THRESHOLD_PX, {
129
+ guideLines,
130
+ grid: GRID_PITCH_PX,
131
+ });
132
+ expect(res.dx).toBe(-4);
133
+ expect(res.dy).toBe(-2);
134
+ });
135
+ });
@@ -0,0 +1,152 @@
1
+ // artboard-guides-overlay — feature-1-artboard-kinds-foundation, T4.
2
+ // Registry contract + generic guides rendering. Pure renderToStaticMarkup —
3
+ // this component has no hooks/context, so (unlike DCArtboard's own overlays)
4
+ // it renders fully outside a live DesignCanvas.
5
+
6
+ import { afterEach, describe, expect, test } from 'bun:test';
7
+
8
+ import { renderToStaticMarkup } from 'react-dom/server';
9
+
10
+ import {
11
+ __resetKindOverlayRegistryForTests,
12
+ ArtboardGuidesOverlay,
13
+ registerKindOverlay,
14
+ } from '../artboard-guides-overlay.tsx';
15
+
16
+ const RECT = { x: 10, y: 20, w: 400, h: 200 };
17
+
18
+ afterEach(() => {
19
+ __resetKindOverlayRegistryForTests();
20
+ });
21
+
22
+ describe('ArtboardGuidesOverlay — generic guides', () => {
23
+ test('renders nothing with no guides and no registered kind content', () => {
24
+ const html = renderToStaticMarkup(
25
+ <ArtboardGuidesOverlay rect={RECT} kind="digital" visibility={{}} />
26
+ );
27
+ expect(html).toBe('');
28
+ });
29
+
30
+ test('positions the overlay at the artboard world rect', () => {
31
+ const html = renderToStaticMarkup(
32
+ <ArtboardGuidesOverlay
33
+ rect={RECT}
34
+ kind="print"
35
+ guides={{ grid: { size: 40 } }}
36
+ visibility={{ guides: true }}
37
+ />
38
+ );
39
+ expect(html).toMatch(/left:\s*10px/);
40
+ expect(html).toMatch(/top:\s*20px/);
41
+ expect(html).toMatch(/width:\s*400px/);
42
+ expect(html).toMatch(/height:\s*200px/);
43
+ expect(html).toContain('aria-hidden="true"');
44
+ expect(html).toContain('pointer-events:none');
45
+ });
46
+
47
+ test('visibility.guides === false suppresses the generic guides', () => {
48
+ const html = renderToStaticMarkup(
49
+ <ArtboardGuidesOverlay
50
+ rect={RECT}
51
+ kind="print"
52
+ guides={{ grid: { size: 40 } }}
53
+ visibility={{ guides: false }}
54
+ />
55
+ );
56
+ expect(html).toBe('');
57
+ });
58
+
59
+ test('columns render as one band per column, respecting margin/gutter', () => {
60
+ const html = renderToStaticMarkup(
61
+ <ArtboardGuidesOverlay
62
+ rect={RECT}
63
+ kind="web"
64
+ guides={{ columns: { count: 4, gutter: 10, margin: 20 } }}
65
+ visibility={{ guides: true }}
66
+ />
67
+ );
68
+ // usable = 400 - 2*20 = 360; (360 - 3*10) / 4 = 82.5px per column, 4 bands.
69
+ const bandCount = (html.match(/width:82\.5px/g) || []).length;
70
+ expect(bandCount).toBe(4);
71
+ });
72
+
73
+ // Security review (defender + adversarial pass, /flow:done on 0e001da7) —
74
+ // columns/rows `count` had no cap, unlike the grid path: a malicious/
75
+ // poisoned canvas (guides is an authored JSX prop, part of the DDR-054
76
+ // untrusted-canvas surface) could set `columns.count` to a huge number and
77
+ // mount one <div> per band, freezing the viewer's tab. Regression-tests the
78
+ // MAX_BANDS_PER_AXIS clamp both independent reviews confirmed as the fix.
79
+ test('a pathologically large column count is clamped, never a runaway render', () => {
80
+ const html = renderToStaticMarkup(
81
+ <ArtboardGuidesOverlay
82
+ rect={RECT}
83
+ kind="web"
84
+ guides={{ columns: { count: 1_000_000_000, gutter: 0, margin: 0 } }}
85
+ visibility={{ guides: true }}
86
+ />
87
+ );
88
+ const bandCount = (html.match(/position:absolute/g) || []).length - 1; // minus the wrapper div
89
+ expect(bandCount).toBeLessThanOrEqual(64);
90
+ expect(bandCount).toBeGreaterThan(0);
91
+ });
92
+
93
+ test('a pathologically large row count is clamped the same way', () => {
94
+ const html = renderToStaticMarkup(
95
+ <ArtboardGuidesOverlay
96
+ rect={RECT}
97
+ kind="web"
98
+ guides={{ rows: { count: 999_999_999, gutter: 0, margin: 0 } }}
99
+ visibility={{ guides: true }}
100
+ />
101
+ );
102
+ const bandCount = (html.match(/position:absolute/g) || []).length - 1;
103
+ expect(bandCount).toBeLessThanOrEqual(64);
104
+ expect(bandCount).toBeGreaterThan(0);
105
+ });
106
+
107
+ test('grid lines are capped, never runaway for a degenerate small size', () => {
108
+ const html = renderToStaticMarkup(
109
+ <ArtboardGuidesOverlay
110
+ rect={{ x: 0, y: 0, w: 5000, h: 5000 }}
111
+ kind="digital"
112
+ guides={{ grid: { size: 2 } }}
113
+ visibility={{ guides: true }}
114
+ />
115
+ );
116
+ const lineCount = (html.match(/width:1px/g) || []).length;
117
+ expect(lineCount).toBeLessThanOrEqual(400);
118
+ });
119
+ });
120
+
121
+ describe('ArtboardGuidesOverlay — per-kind registry', () => {
122
+ test('registered kind renderer is invoked with rect/kind/guides/visibility', () => {
123
+ let seen: unknown;
124
+ registerKindOverlay('print', (props) => {
125
+ seen = props;
126
+ return <div data-testid="print-bleed" />;
127
+ });
128
+ const html = renderToStaticMarkup(
129
+ <ArtboardGuidesOverlay rect={RECT} kind="print" visibility={{ bleed: true }} />
130
+ );
131
+ expect(html).toContain('data-testid="print-bleed"');
132
+ expect(seen).toMatchObject({ rect: RECT, kind: 'print', visibility: { bleed: true } });
133
+ });
134
+
135
+ test('a renderer registered for one kind does not fire for another', () => {
136
+ registerKindOverlay('web', () => <div data-testid="breakpoint-band" />);
137
+ const html = renderToStaticMarkup(
138
+ <ArtboardGuidesOverlay rect={RECT} kind="print" visibility={{}} />
139
+ );
140
+ expect(html).not.toContain('breakpoint-band');
141
+ });
142
+
143
+ test('re-registering the same kind replaces the previous renderer (HMR-safe)', () => {
144
+ registerKindOverlay('print', () => <div data-testid="v1" />);
145
+ registerKindOverlay('print', () => <div data-testid="v2" />);
146
+ const html = renderToStaticMarkup(
147
+ <ArtboardGuidesOverlay rect={RECT} kind="print" visibility={{}} />
148
+ );
149
+ expect(html).not.toContain('v1');
150
+ expect(html).toContain('v2');
151
+ });
152
+ });
@@ -0,0 +1,83 @@
1
+ // artboard-kinds — feature-1-artboard-kinds-foundation, T1/T2.
2
+ //
3
+ // DCArtboard rendered OUTSIDE a DesignCanvas (no WorldContext, no rect) takes
4
+ // the specimen/legacy early-return branch — it still computes + emits the
5
+ // resolved `data-dc-kind` attribute (readBackAttrs is built before the
6
+ // `!ctx || !rect` check), so that resolution logic is testable via
7
+ // renderToStaticMarkup without a live DOM/layout pass. The label-strip kind
8
+ // chip (T3) only renders in the full DesignCanvas layout path (like the
9
+ // video corner badge it sits beside) — covered by /design:smoke, not here
10
+ // (see use-canvas-activity.test.tsx's header comment for the same split).
11
+
12
+ import { describe, expect, test } from 'bun:test';
13
+
14
+ import { renderToStaticMarkup } from 'react-dom/server';
15
+
16
+ import { DCArtboard } from '../canvas-lib.tsx';
17
+
18
+ // Minimal displayName-only stand-in for VideoComp — subtreeHasVideoComp
19
+ // identity-matches OR falls back to `displayName === 'VideoComp'` (the same
20
+ // fallback minified builds rely on), so this exercises the fallback path
21
+ // without pulling in the real VideoComp's rendering/registration machinery.
22
+ function FakeVideoComp() {
23
+ return null;
24
+ }
25
+ FakeVideoComp.displayName = 'VideoComp';
26
+
27
+ describe('DCArtboard kind resolution (data-dc-kind readback)', () => {
28
+ test('absent kind, no video subtree ⇒ digital', () => {
29
+ const html = renderToStaticMarkup(
30
+ <DCArtboard id="a" label="A" width={100} height={100}>
31
+ hi
32
+ </DCArtboard>
33
+ );
34
+ expect(html).toContain('data-dc-kind="digital"');
35
+ });
36
+
37
+ test('explicit kind="print" is emitted verbatim', () => {
38
+ const html = renderToStaticMarkup(
39
+ <DCArtboard id="a" label="A" width={100} height={100} kind="print">
40
+ hi
41
+ </DCArtboard>
42
+ );
43
+ expect(html).toContain('data-dc-kind="print"');
44
+ });
45
+
46
+ test('explicit kind="web" is emitted verbatim', () => {
47
+ const html = renderToStaticMarkup(
48
+ <DCArtboard id="a" label="A" width={100} height={100} kind="web">
49
+ hi
50
+ </DCArtboard>
51
+ );
52
+ expect(html).toContain('data-dc-kind="web"');
53
+ });
54
+
55
+ test('absent kind + VideoComp subtree ⇒ video fallback (Design Decision 1)', () => {
56
+ const html = renderToStaticMarkup(
57
+ <DCArtboard id="a" label="A" width={100} height={100}>
58
+ <FakeVideoComp />
59
+ </DCArtboard>
60
+ );
61
+ expect(html).toContain('data-dc-kind="video"');
62
+ });
63
+
64
+ test('explicit kind="digital" supersedes a VideoComp subtree', () => {
65
+ const html = renderToStaticMarkup(
66
+ <DCArtboard id="a" label="A" width={100} height={100} kind="digital">
67
+ <FakeVideoComp />
68
+ </DCArtboard>
69
+ );
70
+ expect(html).toContain('data-dc-kind="digital"');
71
+ });
72
+
73
+ test('digital kind (default) carries no other resolved-kind attribute value', () => {
74
+ const html = renderToStaticMarkup(
75
+ <DCArtboard id="a" label="A" width={100} height={100}>
76
+ hi
77
+ </DCArtboard>
78
+ );
79
+ expect(html).not.toContain('data-dc-kind="print"');
80
+ expect(html).not.toContain('data-dc-kind="web"');
81
+ expect(html).not.toContain('data-dc-kind="video"');
82
+ });
83
+ });
@@ -0,0 +1,69 @@
1
+ // Dogfood follow-up (print artboards) — whole-ARTBOARD selections must carry
2
+ // the DCArtboard readBack attrs (`data-dc-kind`, `data-dc-print`, `data-dc-
3
+ // fixed`, `data-dc-bg`, …) in `Selection.attrs`, because the Inspector's
4
+ // ArtboardKnobs panel pre-fills its Kind picker / paper preset / Hug toggle /
5
+ // Style fields from exactly these. ATTR_SKIP deliberately hides `data-dc-*`
6
+ // from the generic custom-attributes surface on ORDINARY elements — the bug
7
+ // was that the blanket skip also applied to the artboard element itself, so
8
+ // every readback silently fell back to its default ('digital', screen
9
+ // presets, Hug, empty Bg) no matter what the artboard actually was.
10
+ //
11
+ // Same happy-dom + hoverTargetToSelection harness as knob-props-authored.
12
+
13
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
14
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
15
+
16
+ beforeAll(() => {
17
+ GlobalRegistrator.register();
18
+ });
19
+ afterAll(() => {
20
+ GlobalRegistrator.unregister();
21
+ });
22
+
23
+ import { hoverTargetToSelection } from '../dom-selection.ts';
24
+ import type { HoverTarget } from '../input-router.tsx';
25
+
26
+ function artboardSelection(attrsHtml: string) {
27
+ document.body.innerHTML = `<article class="dc-artboard" data-dc-screen="ab-1" ${attrsHtml}><div>content</div></article>`;
28
+ const el = document.querySelector('[data-dc-screen]') as HTMLElement;
29
+ const target: HoverTarget = { el, cdId: null, artboardId: 'ab-1' };
30
+ return hoverTargetToSelection(target);
31
+ }
32
+
33
+ describe('whole-artboard selection — data-dc-* readback attrs', () => {
34
+ test('data-dc-kind + data-dc-print reach Selection.attrs (the ArtboardKnobs payload)', () => {
35
+ const sel = artboardSelection(
36
+ 'data-dc-kind="print" data-dc-print=\'{"paper":"a4","bleedMm":3}\''
37
+ );
38
+ expect(sel.attrs?.['data-dc-kind']).toBe('print');
39
+ expect(sel.attrs?.['data-dc-print']).toBe('{"paper":"a4","bleedMm":3}');
40
+ });
41
+
42
+ test('the other readback attrs come through too (fixed/bg/padding/layout/gap)', () => {
43
+ const sel = artboardSelection(
44
+ 'data-dc-kind="digital" data-dc-fixed="true" data-dc-bg="var(--bg-1)" data-dc-padding="16" data-dc-layout="flex-col" data-dc-gap="8"'
45
+ );
46
+ expect(sel.attrs?.['data-dc-fixed']).toBe('true');
47
+ expect(sel.attrs?.['data-dc-bg']).toBe('var(--bg-1)');
48
+ expect(sel.attrs?.['data-dc-padding']).toBe('16');
49
+ expect(sel.attrs?.['data-dc-layout']).toBe('flex-col');
50
+ expect(sel.attrs?.['data-dc-gap']).toBe('8');
51
+ });
52
+
53
+ test('an ORDINARY element (no data-dc-screen) keeps the data-dc-* filter', () => {
54
+ document.body.innerHTML =
55
+ '<div data-cd-id="bbbbbbbb" data-dc-kind="print" data-custom="yes">x</div>';
56
+ const el = document.querySelector('[data-cd-id]') as HTMLElement;
57
+ const target: HoverTarget = { el, cdId: 'bbbbbbbb', artboardId: null };
58
+ const sel = hoverTargetToSelection(target);
59
+ // Engine plumbing stays hidden from the generic custom-attributes panel…
60
+ expect(sel.attrs?.['data-dc-kind']).toBeUndefined();
61
+ // …while genuinely custom attributes still surface.
62
+ expect(sel.attrs?.['data-custom']).toBe('yes');
63
+ });
64
+
65
+ test('data-dc-screen itself is included for the artboard (harmless, self-describing)', () => {
66
+ const sel = artboardSelection('data-dc-kind="print"');
67
+ expect(sel.attrs?.['data-dc-screen']).toBe('ab-1');
68
+ });
69
+ });
@@ -0,0 +1,107 @@
1
+ // browse-posture — feature-4 (browse/move split, DDR-187). The LOAD-BEARING
2
+ // gate: a DOM-level proof that the boot-default `browse` tool is a pure native
3
+ // pass-through (a real click handler under the cursor fires, nothing is
4
+ // claimed), while the `move` (select) tool CLAIMS the same bare click (native
5
+ // handler suppressed, onSelect dispatched). This promotes the input-router
6
+ // comment-invariant ("bare clicks pass through") into an enforced test — the
7
+ // canvas-origin-gate equivalent for the input posture.
8
+ //
9
+ // Needs a live DOM (capture-phase listeners + event dispatch + a real React
10
+ // effect) — register happy-dom + drive a createRoot, same convention as
11
+ // canvas-hmr-runtime.test.tsx / specimen-select.test.ts.
12
+
13
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
14
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
15
+
16
+ beforeAll(() => {
17
+ GlobalRegistrator.register();
18
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
19
+ });
20
+ afterAll(() => {
21
+ GlobalRegistrator.unregister();
22
+ });
23
+
24
+ import { act, useRef } from 'react';
25
+ import { createRoot, type Root } from 'react-dom/client';
26
+
27
+ import { type RouterCallbacks, type Tool, useInputRouter } from '../input-router.tsx';
28
+
29
+ // A minimal host that mounts the router over a single native <button>. The
30
+ // active tool is read fresh at event time via `getActiveTool` (a ref-backed
31
+ // getter), so a test can flip the tool without re-rendering.
32
+ function Harness({ getTool, callbacks }: { getTool: () => Tool; callbacks: RouterCallbacks }) {
33
+ const hostRef = useRef<HTMLDivElement | null>(null);
34
+ useInputRouter({ hostRef, getActiveTool: getTool, callbacks });
35
+ return (
36
+ <div ref={hostRef} className="dc-canvas">
37
+ <button type="button" id="native-btn">
38
+ Click me
39
+ </button>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ function fireBareLeftClick(el: Element): void {
45
+ // The full native sequence the router gates: pointerdown → mousedown → click.
46
+ el.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true, button: 0 }));
47
+ el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }));
48
+ el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }));
49
+ }
50
+
51
+ describe('browse tool — native pass-through (boot default)', () => {
52
+ test('bare click: native button handler FIRES, onSelect NOT dispatched', () => {
53
+ const el = document.createElement('div');
54
+ document.body.appendChild(el);
55
+ const root: Root = createRoot(el);
56
+ let selectCalls = 0;
57
+ let nativeClicks = 0;
58
+
59
+ act(() => {
60
+ root.render(
61
+ <Harness getTool={() => 'browse'} callbacks={{ onSelect: () => (selectCalls += 1) }} />
62
+ );
63
+ });
64
+
65
+ const btn = el.querySelector('#native-btn') as HTMLButtonElement;
66
+ btn.addEventListener('click', () => (nativeClicks += 1));
67
+ act(() => {
68
+ fireBareLeftClick(btn);
69
+ });
70
+
71
+ expect(selectCalls).toBe(0); // browse never selects
72
+ expect(nativeClicks).toBe(1); // the mock stays alive
73
+
74
+ act(() => root.unmount());
75
+ el.remove();
76
+ });
77
+ });
78
+
79
+ describe('move (select) tool — claims the bare click', () => {
80
+ test('bare click: native button handler SUPPRESSED, onSelect dispatched', () => {
81
+ const el = document.createElement('div');
82
+ document.body.appendChild(el);
83
+ const root: Root = createRoot(el);
84
+ let selectCalls = 0;
85
+ let nativeClicks = 0;
86
+
87
+ act(() => {
88
+ root.render(
89
+ <Harness getTool={() => 'move'} callbacks={{ onSelect: () => (selectCalls += 1) }} />
90
+ );
91
+ });
92
+
93
+ const btn = el.querySelector('#native-btn') as HTMLButtonElement;
94
+ btn.addEventListener('click', () => (nativeClicks += 1));
95
+ act(() => {
96
+ fireBareLeftClick(btn);
97
+ });
98
+
99
+ // Router claims the pointerdown (dispatches select) and swallows the
100
+ // synthetic click so the button under the cursor never activates.
101
+ expect(selectCalls).toBe(1);
102
+ expect(nativeClicks).toBe(0);
103
+
104
+ act(() => root.unmount());
105
+ el.remove();
106
+ });
107
+ });