@1agh/maude 0.46.0 → 0.48.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 (81) hide show
  1. package/README.md +7 -6
  2. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  3. package/apps/studio/acp/bridge.ts +121 -6
  4. package/apps/studio/acp/plugin-bootstrap.ts +15 -1
  5. package/apps/studio/annotations-layer.tsx +42 -0
  6. package/apps/studio/api.ts +417 -2
  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/agent-browser-safe.sh +29 -0
  13. package/apps/studio/bin/curl-local.sh +28 -0
  14. package/apps/studio/build.ts +1 -1
  15. package/apps/studio/canvas-cursors.ts +6 -0
  16. package/apps/studio/canvas-edit.ts +678 -11
  17. package/apps/studio/canvas-icons.tsx +13 -0
  18. package/apps/studio/canvas-lib.tsx +3 -0
  19. package/apps/studio/canvas-shell.tsx +646 -26
  20. package/apps/studio/client/app.jsx +898 -52
  21. package/apps/studio/client/export-center.jsx +7 -6
  22. package/apps/studio/client/github.js +11 -4
  23. package/apps/studio/client/panels/ChatPanel.jsx +47 -2
  24. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  25. package/apps/studio/contextual-toolbar.tsx +5 -3
  26. package/apps/studio/dist/client.bundle.js +1103 -1103
  27. package/apps/studio/dist/comment-mount.js +2 -2
  28. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
  29. package/apps/studio/dist/styles.css +1 -1
  30. package/apps/studio/examples/perf-100-artboards.tsx +1 -1
  31. package/apps/studio/exporters/pdf.ts +35 -1
  32. package/apps/studio/git/service.ts +4 -1
  33. package/apps/studio/grid-track-handles.ts +179 -0
  34. package/apps/studio/handoff.ts +35 -0
  35. package/apps/studio/http.ts +118 -0
  36. package/apps/studio/input-router.tsx +73 -17
  37. package/apps/studio/paths.ts +37 -1
  38. package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
  39. package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
  40. package/apps/studio/test/acp-session-plugins.test.ts +6 -0
  41. package/apps/studio/test/browse-posture.test.tsx +107 -0
  42. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  43. package/apps/studio/test/canvas-meta-api.test.ts +70 -0
  44. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  45. package/apps/studio/test/comment-mount.test.ts +2 -1
  46. package/apps/studio/test/component-map.test.ts +48 -0
  47. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  48. package/apps/studio/test/detach-component.test.ts +94 -0
  49. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  50. package/apps/studio/test/element-structural-api.test.ts +74 -0
  51. package/apps/studio/test/element-structural-edit.test.ts +113 -0
  52. package/apps/studio/test/exporters/pdf.test.ts +33 -1
  53. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  54. package/apps/studio/test/handoff.test.ts +48 -0
  55. package/apps/studio/test/input-router.test.ts +82 -8
  56. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  57. package/apps/studio/test/pdf-print-boxes.test.ts +54 -0
  58. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  59. package/apps/studio/tool-palette.tsx +3 -1
  60. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  61. package/apps/studio/use-element-resize.tsx +3 -1
  62. package/apps/studio/use-grid-track-handles.tsx +364 -0
  63. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  64. package/apps/studio/use-tool-mode.tsx +30 -3
  65. package/apps/studio/web-overlay-content.tsx +52 -0
  66. package/apps/studio/whats-new.json +27 -0
  67. package/cli/bin/maude.mjs +1 -0
  68. package/cli/commands/design.mjs +16 -0
  69. package/cli/commands/init.mjs +80 -3
  70. package/cli/commands/kg.mjs +368 -0
  71. package/cli/commands/kg.test.mjs +118 -0
  72. package/cli/lib/ddr-to-kgai.mjs +648 -0
  73. package/cli/lib/ddr-to-kgai.test.mjs +99 -0
  74. package/cli/lib/flow-design-integration.test.mjs +2 -2
  75. package/cli/lib/gitignore-block.mjs +4 -0
  76. package/cli/lib/plugin-name-namespace.test.mjs +71 -0
  77. package/package.json +8 -8
  78. package/plugins/design/dependencies.json +17 -0
  79. package/plugins/design/templates/_shell.html +4 -0
  80. package/plugins/flow/.claude-plugin/config.schema.json +66 -0
  81. package/plugins/flow/dependencies.json +17 -0
@@ -16,6 +16,7 @@ import { computeSessionPlugins } from '../acp/plugin-bootstrap.ts';
16
16
 
17
17
  const DESIGN = '/bundle/plugins/design';
18
18
  const FLOW = '/bundle/plugins/flow';
19
+ const KGAI = '/bundle/plugins/kgai';
19
20
 
20
21
  describe('computeSessionPlugins — gates', () => {
21
22
  // `/flow` auto-load is intentionally OFF for now (2026-07-03) — only `design`
@@ -52,4 +53,37 @@ describe('computeSessionPlugins — gates', () => {
52
53
  const out = computeSessionPlugins({ native: true, designDir: DESIGN, flowDir: FLOW });
53
54
  expect(out.every((c) => c.skipMcpDiscovery === true && c.type === 'local')).toBe(true);
54
55
  });
56
+
57
+ // feature-kgai-ecosystem-integration Phase 8 — the third-party kgai plugin
58
+ // carries the `Stop` hook that IS autonomous decision capture. Without
59
+ // injection the packaged app records nothing (settingSources:['user'], and a
60
+ // terminal-less DDR-177 user never marketplace-installs it).
61
+ test('kgai bundled ⇒ injected alongside design (autonomous capture in the .app)', () => {
62
+ const out = computeSessionPlugins({
63
+ native: true,
64
+ designDir: DESIGN,
65
+ flowDir: FLOW,
66
+ kgaiDir: KGAI,
67
+ });
68
+ expect(out).toEqual([
69
+ { type: 'local', path: DESIGN, skipMcpDiscovery: true },
70
+ { type: 'local', path: KGAI, skipMcpDiscovery: true },
71
+ ]);
72
+ });
73
+
74
+ test('kgai absent (dev tree / npm layout) ⇒ design only, no crash', () => {
75
+ expect(
76
+ computeSessionPlugins({ native: true, designDir: DESIGN, flowDir: FLOW, kgaiDir: null })
77
+ ).toEqual([{ type: 'local', path: DESIGN, skipMcpDiscovery: true }]);
78
+ // omitted entirely (optional field) behaves the same
79
+ expect(computeSessionPlugins({ native: true, designDir: DESIGN, flowDir: FLOW })).toEqual([
80
+ { type: 'local', path: DESIGN, skipMcpDiscovery: true },
81
+ ]);
82
+ });
83
+
84
+ test('not native ⇒ kgai is NOT injected either (web-serve gate holds)', () => {
85
+ expect(
86
+ computeSessionPlugins({ native: false, designDir: DESIGN, flowDir: FLOW, kgaiDir: KGAI })
87
+ ).toEqual([]);
88
+ });
55
89
  });
@@ -1,16 +1,27 @@
1
- // DDR-184 — the curated tool allow-list the bridge auto-approves so the design
2
- // workflow never stalls on a per-edit / per-`maude` permission prompt (the
3
- // "Manual mode blocks every edit" complaint). Two things under test:
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:
4
5
  //
5
6
  // (1) WIRE contract — the list lands on `_meta.claudeCode.options.allowedTools`
6
7
  // (SDK `allowedTools`, sdk.d.ts:1331), coexisting with the DDR-144
7
8
  // `settingSources` narrowing and the DDR-143 `plugins` carrier. Same
8
9
  // adapter/SDK-INTERNAL `_meta` path as acp-session-plugins.test.ts — a
9
10
  // bump that stops forwarding it must fail HERE, not silently re-prompt.
10
- // (2) SOURCE-OF-TRUTH guard — the single `Bash(maude:*)` rule only covers the
11
- // whole design-helper surface because DDR-062 routes every helper through
12
- // `maude design <verb>`. If that stops being true, or someone widens the
13
- // Bash scope, this test fails loudly.
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.
14
25
 
15
26
  import { describe, expect, test } from 'bun:test';
16
27
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
@@ -68,14 +79,40 @@ describe('MAUDE_DEFAULT_ALLOWED_TOOLS — source-of-truth guard (DDR-184 / DDR-0
68
79
  }
69
80
  });
70
81
 
71
- test('the ONLY Bash rule is prefix-scoped to `maude` — never a bare/un-scoped Bash', () => {
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', () => {
72
89
  // A bare `Bash` (or `Bash(*)`) would auto-approve arbitrary command execution
73
90
  // — exactly the blanket surface DDR-179 kept behind the prompt. Guard against
74
- // anyone widening it: the list may auto-approve `maude` and nothing else.
75
- expect(bashRules).toEqual(['Bash(maude:*)']);
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
+ ]);
76
113
  });
77
114
 
78
- test('the single Bash rule is load-bearing on DDR-062: every design helper is a `maude design <verb>`', () => {
115
+ test('the `maude` Bash rule is load-bearing on DDR-062: every design helper is a `maude design <verb>`', () => {
79
116
  // Resolve cli/commands/design.mjs relative to this test file's real path
80
117
  // (mirrors acp-session-plugins.test.ts's realpath dance for compiled roots).
81
118
  const here = dirname(realpathSync(import.meta.url.replace('file://', '')));
@@ -88,4 +125,63 @@ describe('MAUDE_DEFAULT_ALLOWED_TOOLS — source-of-truth guard (DDR-184 / DDR-0
88
125
  expect(src).toContain('BIN_VERBS');
89
126
  expect(src).toMatch(/maude design <verb>/);
90
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
+ });
91
187
  });
@@ -94,6 +94,12 @@ describe('newSessionParams — plugin carrier shape', () => {
94
94
  const p = newSessionParams('/repo', undefined, PLUGINS);
95
95
  expect((p._meta as Meta).claudeCode?.options?.settings?.enabledPlugins).toEqual({
96
96
  'design@maude': false,
97
+ // kgai (third-party, injected by the desktop bundle for autonomous capture —
98
+ // feature-kgai-ecosystem-integration Phase 8). Suppressing a user-installed
99
+ // copy matters MORE here than for our own plugins: it would run its own
100
+ // SessionStart install.sh and point at a different engine version than the
101
+ // pinned, signed sidecar we ship.
102
+ kgai: false,
97
103
  });
98
104
  });
99
105
 
@@ -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
+ });
@@ -0,0 +1,58 @@
1
+ // canvas-hide-chrome.test.ts — regression guard for RCA
2
+ // issue-pdf-print-export-marks-missing (Bug A). `_shell.html`'s
3
+ // `#canvas-hide-chrome` style block is the sanctioned "never leaks into an
4
+ // export" list every exporter relies on (`?hide-chrome=1` flips it from
5
+ // `media="not all"` to `media="all"` — see exporters/index.ts
6
+ // canvasShellUrl()). `.dc-artboard-guides` (the print bleed/trim/margin guide
7
+ // overlay, artboard-guides-overlay.tsx) was never added to this list when
8
+ // feature-2-print-artboards shipped, so it rendered straight into every PDF/
9
+ // PNG/SVG capture whenever a canvas had it toggled on. Grep-based, mirrors
10
+ // shell-importmap.test.ts's own style for the same file.
11
+
12
+ import { describe, expect, test } from 'bun:test';
13
+ import { readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ import { DEV_SERVER_ROOT } from '../paths.ts';
17
+
18
+ const SHELL_HTML_PATH = join(
19
+ DEV_SERVER_ROOT,
20
+ '..',
21
+ '..',
22
+ 'plugins',
23
+ 'design',
24
+ 'templates',
25
+ '_shell.html'
26
+ );
27
+
28
+ describe('_shell.html #canvas-hide-chrome covers every overlay mount class', () => {
29
+ const shellHtml = readFileSync(SHELL_HTML_PATH, 'utf8');
30
+ const blockMatch = shellHtml.match(/<style id="canvas-hide-chrome"[^>]*>([\s\S]*?)<\/style>/);
31
+
32
+ test('the #canvas-hide-chrome style block exists', () => {
33
+ expect(blockMatch).not.toBeNull();
34
+ });
35
+
36
+ const block = blockMatch?.[1] ?? '';
37
+
38
+ // Every editor-chrome overlay class that must never leak into a capture
39
+ // render. `.dc-artboard-guides` is the one this RCA adds; the rest already
40
+ // guard against the same class of regression for their own overlays.
41
+ const REQUIRED_SELECTORS = [
42
+ '.dc-mm',
43
+ '.dc-tool-palette',
44
+ '.dc-participants',
45
+ '.dc-cv-halo',
46
+ '.dc-snap-guide',
47
+ '.dc-annot-svg',
48
+ '.cm-layer',
49
+ '.dc-artboard-label',
50
+ '.dc-artboard-guides',
51
+ ];
52
+
53
+ for (const selector of REQUIRED_SELECTORS) {
54
+ test(`"${selector}" is hidden during capture`, () => {
55
+ expect(block.includes(selector)).toBe(true);
56
+ });
57
+ }
58
+ });
@@ -524,3 +524,73 @@ describe('/_api/canvas-meta — overlays lane (T6)', () => {
524
524
  }
525
525
  });
526
526
  });
527
+
528
+ // feature-4 T7b — `locked` (per-user locked layer keys) is the third view-file
529
+ // lane: same split contract as viewport/overlays (never the versioned meta),
530
+ // REPLACE semantics (the client sends the full set), bounded shape.
531
+ describe('/_api/canvas-meta — locked lane (feature-4 T7b)', () => {
532
+ test('PATCH locked leaves meta byte-unchanged + writes the view file; GET merges it back', async () => {
533
+ const { root, designRoot } = makeSandbox();
534
+ const port = nextPort();
535
+ const proc = await bootServer(root, port);
536
+ try {
537
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
538
+ const tsxAbs = join(designRoot, 'ui', 'Locked.tsx');
539
+ writeFileSync(tsxAbs, 'export default function L(){return <main/>}\n');
540
+ const metaAbs = tsxAbs.replace(/\.tsx$/, '.meta.json');
541
+ writeFileSync(metaAbs, JSON.stringify({ title: 'Locked', sections: [] }));
542
+ const before = readFileSync(metaAbs, 'utf8');
543
+ const file = repoRel(designRoot, tsxAbs);
544
+
545
+ const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
546
+ method: 'PATCH',
547
+ headers: { 'content-type': 'application/json' },
548
+ body: JSON.stringify({ file, patch: { locked: ['aabbccdd:0', 'deadbeef:2'] } }),
549
+ });
550
+ expect(r.status).toBe(200);
551
+ const merged = (await r.json()) as MetaShape;
552
+ expect(merged.locked).toEqual(['aabbccdd:0', 'deadbeef:2']);
553
+ // Versioned meta byte-identical (runtime state never lands there).
554
+ expect(readFileSync(metaAbs, 'utf8')).toBe(before);
555
+ const view = JSON.parse(readFileSync(viewPath(designRoot, 'ui-locked'), 'utf8'));
556
+ expect(view.locked).toEqual(['aabbccdd:0', 'deadbeef:2']);
557
+
558
+ // REPLACE semantics — a smaller set unlocks the dropped key.
559
+ await fetch(`http://localhost:${port}/_api/canvas-meta`, {
560
+ method: 'PATCH',
561
+ headers: { 'content-type': 'application/json' },
562
+ body: JSON.stringify({ file, patch: { locked: ['aabbccdd:0'] } }),
563
+ });
564
+ const g = await fetch(
565
+ `http://localhost:${port}/_api/canvas-meta?file=${encodeURIComponent(file)}`
566
+ );
567
+ expect(((await g.json()) as MetaShape).locked).toEqual(['aabbccdd:0']);
568
+ } finally {
569
+ await killProc(proc);
570
+ }
571
+ });
572
+
573
+ test('malformed locked keys are a silent no-op (shape-gated)', async () => {
574
+ const { root, designRoot } = makeSandbox();
575
+ const port = nextPort();
576
+ const proc = await bootServer(root, port);
577
+ try {
578
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
579
+ const tsxAbs = join(designRoot, 'ui', 'BadLock.tsx');
580
+ writeFileSync(tsxAbs, 'export default function B(){return <main/>}\n');
581
+ writeFileSync(tsxAbs.replace(/\.tsx$/, '.meta.json'), JSON.stringify({ title: 'B' }));
582
+ const file = repoRel(designRoot, tsxAbs);
583
+ await fetch(`http://localhost:${port}/_api/canvas-meta`, {
584
+ method: 'PATCH',
585
+ headers: { 'content-type': 'application/json' },
586
+ body: JSON.stringify({ file, patch: { locked: ['../../etc/passwd', 'not-a-key'] } }),
587
+ });
588
+ const g = await fetch(
589
+ `http://localhost:${port}/_api/canvas-meta?file=${encodeURIComponent(file)}`
590
+ );
591
+ expect(((await g.json()) as MetaShape).locked).toBeUndefined();
592
+ } finally {
593
+ await killProc(proc);
594
+ }
595
+ });
596
+ });
@@ -111,6 +111,10 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
111
111
  '/_api/insert-artboard',
112
112
  '/_api/resize-artboard',
113
113
  '/_api/delete-artboard',
114
+ // feature-3-web-artboards T3 — "Duplicate at width…" is a source-write,
115
+ // same MAIN-ORIGIN-ONLY posture as the other artboard structural ops
116
+ // right above.
117
+ '/_api/duplicate-artboard',
114
118
  // Stage F1 — the AssetPicker's asset-listing GET is a shell (main-origin)
115
119
  // concern; the untrusted canvas iframe must not enumerate project media.
116
120
  '/_api/assets',
@@ -39,7 +39,8 @@ describe('comment-mount / MaybeToolProvider dedup', () => {
39
39
  const html = renderToStaticMarkup(
40
40
  createElement(MaybeToolProvider, null, createElement(ToolReader))
41
41
  );
42
- expect(html).toContain('data-tool="move"');
42
+ // feature-4 (browse/move split) — the default boot tool is `browse`.
43
+ expect(html).toContain('data-tool="browse"');
43
44
  });
44
45
  });
45
46
 
@@ -0,0 +1,48 @@
1
+ // component-map — feature-4 T7a. The Layers panel's purple-instance source:
2
+ // `componentMapForCanvas` reports every element that renders through an
3
+ // INSTANTIATED component (`<Card/>` referenced as JSX in the file), keyed by
4
+ // cd-id. The top-level canvas component (exported/mounted, never referenced as
5
+ // JSX here) must NOT mark its elements as instances.
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+
9
+ import { componentMapForCanvas } from '../canvas-edit.ts';
10
+ import { transpileCanvasSource } from '../canvas-pipeline.ts';
11
+
12
+ const CANVAS = '/abs/Canvas.tsx';
13
+
14
+ function idsOf(source: string): Record<string, string> {
15
+ const { withIds } = transpileCanvasSource(CANVAS, source);
16
+ const out: Record<string, string> = {};
17
+ for (const m of withIds.matchAll(/<(\w+)([^>]*?)data-cd-id="([0-9a-f]{8})"/g)) {
18
+ if (!out[m[1] as string]) out[m[1] as string] = m[3] as string;
19
+ }
20
+ return out;
21
+ }
22
+
23
+ describe('canvas-edit / componentMapForCanvas', () => {
24
+ test('elements of an instantiated component are instances; the canvas root is not', () => {
25
+ const src = `
26
+ function Card() { return <article className="card"><h2>Hi</h2></article>; }
27
+ function Demo() { return <div><Card /><Card /><p>plain</p></div>; }
28
+ `;
29
+ const ids = idsOf(src);
30
+ const map = componentMapForCanvas(CANVAS, src);
31
+ // Card's own elements → instances of Card (2 usages); article is the root.
32
+ expect(map[ids.article as string]).toEqual({ component: 'Card', root: true, usages: 2 });
33
+ expect(map[ids.h2 as string]).toEqual({ component: 'Card', root: false, usages: 2 });
34
+ // Demo (the canvas component, never referenced as JSX) → NOT in the map.
35
+ expect(map[ids.div as string]).toBeUndefined();
36
+ expect(map[ids.p as string]).toBeUndefined();
37
+ });
38
+
39
+ test('no components → empty map', () => {
40
+ const src = `function Demo() { return <div><h1>T</h1></div>; }`;
41
+ const map = componentMapForCanvas(CANVAS, src);
42
+ expect(Object.keys(map)).toHaveLength(0);
43
+ });
44
+
45
+ test('unparsable source → empty map (no throw)', () => {
46
+ expect(componentMapForCanvas(CANVAS, 'function {{{')).toEqual({});
47
+ });
48
+ });