@1agh/maude 0.30.0 → 0.32.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 (93) hide show
  1. package/README.md +5 -5
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +788 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +796 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +598 -0
  23. package/apps/studio/client/panels/ReadinessList.jsx +189 -0
  24. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  25. package/apps/studio/client/panels/acp-runtime.js +286 -0
  26. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  27. package/apps/studio/client/panels/git-grouping.js +86 -0
  28. package/apps/studio/client/styles/0-reset.css +4 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +625 -3
  30. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +821 -0
  32. package/apps/studio/client/styles/_index.css +2 -0
  33. package/apps/studio/client/tour/collab-tour.js +61 -0
  34. package/apps/studio/client/tour/overlay.jsx +11 -1
  35. package/apps/studio/client/whats-new.jsx +25 -10
  36. package/apps/studio/collab/registry.ts +13 -0
  37. package/apps/studio/collab/room.ts +36 -0
  38. package/apps/studio/cursors-overlay.tsx +17 -1
  39. package/apps/studio/dist/client.bundle.js +29097 -1534
  40. package/apps/studio/dist/comment-mount.js +4 -2
  41. package/apps/studio/dist/styles.css +5816 -1614
  42. package/apps/studio/git/endpoints.ts +338 -0
  43. package/apps/studio/git/service.ts +1334 -0
  44. package/apps/studio/git/watch.ts +97 -0
  45. package/apps/studio/github/endpoints.ts +358 -0
  46. package/apps/studio/github/service.ts +231 -0
  47. package/apps/studio/github/token.ts +53 -0
  48. package/apps/studio/hmr-broadcast.ts +9 -2
  49. package/apps/studio/http.ts +399 -1
  50. package/apps/studio/participants-chrome.tsx +69 -9
  51. package/apps/studio/paths.ts +12 -0
  52. package/apps/studio/readiness.ts +220 -0
  53. package/apps/studio/scaffold-design.ts +57 -0
  54. package/apps/studio/server.ts +65 -2
  55. package/apps/studio/sync/agent.ts +81 -1
  56. package/apps/studio/sync/codec.ts +24 -0
  57. package/apps/studio/sync/cold-start.ts +40 -0
  58. package/apps/studio/sync/hub-link.ts +137 -0
  59. package/apps/studio/test/acp-bridge.test.ts +127 -0
  60. package/apps/studio/test/acp-env.test.ts +65 -0
  61. package/apps/studio/test/acp-origin-gate.test.ts +95 -0
  62. package/apps/studio/test/acp-transcript.test.ts +112 -0
  63. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  64. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  65. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  66. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  67. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  68. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  69. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  70. package/apps/studio/test/editing-presence.test.ts +103 -0
  71. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  72. package/apps/studio/test/git-api.test.ts +0 -0
  73. package/apps/studio/test/git-branches.test.ts +106 -0
  74. package/apps/studio/test/git-grouping.test.ts +106 -0
  75. package/apps/studio/test/git-watch.test.ts +97 -0
  76. package/apps/studio/test/github-api.test.ts +465 -0
  77. package/apps/studio/test/hub-link.test.ts +69 -0
  78. package/apps/studio/test/participants-chrome.test.ts +36 -1
  79. package/apps/studio/test/readiness.test.ts +127 -0
  80. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  81. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  82. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  83. package/apps/studio/tool-palette.tsx +18 -9
  84. package/apps/studio/use-chrome-visibility.tsx +66 -0
  85. package/apps/studio/use-collab.tsx +414 -187
  86. package/apps/studio/whats-new.json +82 -0
  87. package/apps/studio/ws.ts +44 -1
  88. package/cli/commands/design.mjs +1 -0
  89. package/cli/lib/gitignore-block.mjs +16 -3
  90. package/cli/lib/gitignore-block.test.mjs +13 -1
  91. package/package.json +11 -9
  92. package/plugins/design/dependencies.json +17 -0
  93. package/plugins/design/templates/_shell.html +30 -8
@@ -0,0 +1,127 @@
1
+ // Readiness probe (DDR-128) — plugin-registry scan + PATH resolution. The
2
+ // login-shell fallback's full correctness is proven on the packaged `.app`
3
+ // (native-verification ceiling); here we cover the registry-scan branches, the
4
+ // no-throw contracts, and the report shape. The probe is async (the fallback shells
5
+ // out off the event loop — DDR-128 hardening), so everything awaits.
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ import { probeReadiness, resolveOnPath } from '../readiness.ts';
13
+
14
+ const MARKET_OK = {
15
+ maude: { source: { source: 'github', repo: '1aGh/maude' }, installLocation: '/x' },
16
+ };
17
+ const INSTALLED_BOTH = {
18
+ version: 2,
19
+ plugins: {
20
+ 'design@maude': [{ scope: 'user', version: '0.29.0' }],
21
+ 'flow@maude': [{ scope: 'user', version: '0.29.0' }],
22
+ },
23
+ };
24
+ const INSTALLED_DESIGN_ONLY = { version: 2, plugins: { 'design@maude': [{ scope: 'user' }] } };
25
+
26
+ function fixtureClaudeDir(opts: { markets?: unknown; installed?: unknown }): string {
27
+ const dir = mkdtempSync(join(tmpdir(), 'maude-readiness-'));
28
+ const pluginsDir = join(dir, 'plugins');
29
+ mkdirSync(pluginsDir, { recursive: true });
30
+ if (opts.markets !== undefined)
31
+ writeFileSync(join(pluginsDir, 'known_marketplaces.json'), JSON.stringify(opts.markets));
32
+ if (opts.installed !== undefined)
33
+ writeFileSync(join(pluginsDir, 'installed_plugins.json'), JSON.stringify(opts.installed));
34
+ return dir;
35
+ }
36
+
37
+ async function withClaudeDir<T>(dir: string, fn: () => Promise<T> | T): Promise<T> {
38
+ const saved = process.env.CLAUDE_CONFIG_DIR;
39
+ process.env.CLAUDE_CONFIG_DIR = dir;
40
+ try {
41
+ return await fn();
42
+ } finally {
43
+ if (saved === undefined) delete process.env.CLAUDE_CONFIG_DIR;
44
+ else process.env.CLAUDE_CONFIG_DIR = saved;
45
+ }
46
+ }
47
+
48
+ const pluginsItem = async (dir: string) =>
49
+ (await withClaudeDir(dir, () => probeReadiness())).items.find((i) => i.id === 'plugins')!;
50
+
51
+ describe('readiness — plugin registry scan', () => {
52
+ test('both plugins + marketplace present → plugins item is present, no remediation', async () => {
53
+ const item = await pluginsItem(
54
+ fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_BOTH })
55
+ );
56
+ expect(item.status).toBe('present');
57
+ expect(item.remediation).toBeUndefined();
58
+ });
59
+
60
+ test('only design@maude installed → missing, names the absent flow@maude + offers the fix', async () => {
61
+ const item = await pluginsItem(
62
+ fixtureClaudeDir({ markets: MARKET_OK, installed: INSTALLED_DESIGN_ONLY })
63
+ );
64
+ expect(item.status).toBe('missing');
65
+ expect(item.detail).toContain('flow@maude');
66
+ expect(item.remediation).toContain('/plugin install');
67
+ });
68
+
69
+ test('registry absent → unknown (Claude Code internal contract; never throws)', async () => {
70
+ const item = await pluginsItem(fixtureClaudeDir({}));
71
+ expect(item.status).toBe('unknown');
72
+ });
73
+
74
+ test('plugin presence is the gate — a foreign-repo marketplace does not change a both-installed verdict', async () => {
75
+ const item = await pluginsItem(
76
+ fixtureClaudeDir({
77
+ markets: { other: { source: { repo: 'someone/else' } } },
78
+ installed: INSTALLED_BOTH,
79
+ })
80
+ );
81
+ expect(item.status).toBe('present');
82
+ });
83
+ });
84
+
85
+ describe('readiness — resolveOnPath', () => {
86
+ test('finds a binary that is on PATH', async () => {
87
+ const hit = await resolveOnPath('sh');
88
+ expect(hit).not.toBeNull();
89
+ expect(existsSync(hit!)).toBe(true);
90
+ });
91
+
92
+ test('returns null for a bogus binary without throwing', async () => {
93
+ expect(await resolveOnPath('maude-definitely-not-real-xyz')).toBeNull();
94
+ });
95
+
96
+ test('login-shell fallback recovers a binary hidden from the app PATH', async () => {
97
+ const saved = process.env.PATH;
98
+ try {
99
+ // Simulate the truncated/empty GUI env so Bun.which misses and the fallback fires.
100
+ process.env.PATH = '';
101
+ const hit = await resolveOnPath('sh');
102
+ // When a login shell is usable it must recover an absolute, real path. In a
103
+ // sandbox with no usable login shell the fallback returns null — we don't fail
104
+ // on that, but a non-null result must be a real absolute path.
105
+ if (hit !== null) {
106
+ expect(hit.startsWith('/')).toBe(true);
107
+ expect(existsSync(hit)).toBe(true);
108
+ }
109
+ } finally {
110
+ if (saved === undefined) delete process.env.PATH;
111
+ else process.env.PATH = saved;
112
+ }
113
+ });
114
+ });
115
+
116
+ describe('readiness — report shape', () => {
117
+ test('always returns the four items with stable ids and a boolean ready', async () => {
118
+ const report = await probeReadiness();
119
+ expect(report.items.map((i) => i.id)).toEqual(['claude', 'maude', 'plugins', 'agent-browser']);
120
+ expect(typeof report.ready).toBe('boolean');
121
+ });
122
+
123
+ test('agent-browser is optional and never gates ready', async () => {
124
+ const ab = (await probeReadiness()).items.find((i) => i.id === 'agent-browser')!;
125
+ expect(ab.required).toBe(false);
126
+ });
127
+ });
@@ -0,0 +1,187 @@
1
+ // Concurrent cold-seed de-duplication — F1 regression.
2
+ //
3
+ // RCA: `.ai/logs/rca/issue-collab-cold-seed-tsx-duplication.md`. When TWO
4
+ // dev-servers seed the SAME canvas slug into an EMPTY hub at the SAME instant,
5
+ // each reads `docBody === empty` before the other's `seed-local-up` write has
6
+ // propagated, so both apply their (identical) body. Two `applyHtmlToDoc`
7
+ // insertions from two Yjs clientIDs into one Y.Text are NOT content-deduped by
8
+ // the CRDT — the body doubled (two `export default`), failing the canvas build.
9
+ //
10
+ // The fix elects a single writer (`syncMeta.seededBy` — Y.Map LWW converges it
11
+ // to ONE clientID on every peer) which, inside a bounded post-seed window,
12
+ // re-applies the canonical body so `applyHtmlToDoc`'s diff deletes the trailing
13
+ // duplicate. These tests pin: (1) simultaneous seed → one copy, (2) a fresh peer
14
+ // booting against an already-doubled hub recovers, (3) the sequential control
15
+ // stays clean (no over-eager collapse of legitimately-adopted state).
16
+
17
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
18
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import * as Y from 'yjs';
22
+
23
+ import { type CanvasSyncAgent, createCanvasSyncAgent } from '../sync/agent.ts';
24
+ import { htmlFromDoc } from '../sync/codec.ts';
25
+ import { createEchoGuard } from '../sync/echo-guard.ts';
26
+
27
+ // A realistic canvas body — the un-buildable failure is `export default` × 2.
28
+ const BODY = [
29
+ 'import { DesignCanvas } from "@maude/canvas-lib";',
30
+ 'export default function Screen() {',
31
+ ' return <DesignCanvas><h1>Shared draft</h1></DesignCanvas>;',
32
+ '}',
33
+ '',
34
+ ].join('\n');
35
+
36
+ const TRANSPORT = Symbol('transport');
37
+
38
+ let dirA: string;
39
+ let dirB: string;
40
+ let docA: Y.Doc;
41
+ let docB: Y.Doc;
42
+ let agentA: CanvasSyncAgent | undefined;
43
+ let agentB: CanvasSyncAgent | undefined;
44
+
45
+ function pathsFor(dir: string) {
46
+ return {
47
+ html: join(dir, 'screen.tsx'),
48
+ comments: join(dir, '_comments', 'screen.json'),
49
+ annotations: join(dir, 'screen.annotations.svg'),
50
+ };
51
+ }
52
+
53
+ function writeLocalBody(dir: string, body: string): void {
54
+ // Seed disk before the agent reconciles (the local side of the cold start).
55
+ writeFileSync(pathsFor(dir).html, body);
56
+ }
57
+
58
+ function makeAgent(doc: Y.Doc, dir: string): CanvasSyncAgent {
59
+ const a = createCanvasSyncAgent({
60
+ slug: 'screen',
61
+ doc,
62
+ paths: pathsFor(dir),
63
+ echoGuard: createEchoGuard(),
64
+ flushMs: 0, // synchronous flush via queueMicrotask
65
+ });
66
+ a.start();
67
+ return a;
68
+ }
69
+
70
+ /** Bidirectional full-state exchange, looped until the de-dup repair (which
71
+ * fires synchronously inside applyUpdate) has propagated + settled. */
72
+ function syncDocs(d1: Y.Doc, d2: Y.Doc, rounds = 6): void {
73
+ for (let i = 0; i < rounds; i++) {
74
+ Y.applyUpdate(d2, Y.encodeStateAsUpdate(d1), TRANSPORT);
75
+ Y.applyUpdate(d1, Y.encodeStateAsUpdate(d2), TRANSPORT);
76
+ }
77
+ }
78
+
79
+ beforeEach(() => {
80
+ dirA = mkdtempSync(join(tmpdir(), 'cold-seed-a-'));
81
+ dirB = mkdtempSync(join(tmpdir(), 'cold-seed-b-'));
82
+ docA = new Y.Doc();
83
+ docB = new Y.Doc();
84
+ });
85
+
86
+ afterEach(() => {
87
+ agentA?.stop();
88
+ agentB?.stop();
89
+ agentA = undefined;
90
+ agentB = undefined;
91
+ rmSync(dirA, { recursive: true, force: true });
92
+ rmSync(dirB, { recursive: true, force: true });
93
+ });
94
+
95
+ describe('concurrent cold-seed → single body', () => {
96
+ test('two peers seed identical bodies into an empty hub at once → ONE copy', async () => {
97
+ writeLocalBody(dirA, BODY);
98
+ writeLocalBody(dirB, BODY);
99
+ agentA = makeAgent(docA, dirA);
100
+ agentB = makeAgent(docB, dirB);
101
+
102
+ // The race: BOTH reconcile against an empty doc before either propagates.
103
+ await agentA.reconcile();
104
+ await agentB.reconcile();
105
+
106
+ // Hub merges both seeds — the moment the duplication would surface.
107
+ syncDocs(docA, docB);
108
+
109
+ // Both docs converge to exactly ONE copy (no doubled `export default`).
110
+ expect(htmlFromDoc(docA)).toBe(BODY);
111
+ expect(htmlFromDoc(docB)).toBe(BODY);
112
+ // And the count of `export default` is 1 — the build-breaking signature gone.
113
+ expect(htmlFromDoc(docA).match(/export default/g)?.length).toBe(1);
114
+
115
+ // Disk on both peers mirrors the single copy.
116
+ await agentA.flush();
117
+ await agentB.flush();
118
+ expect(readFileSync(pathsFor(dirA).html, 'utf8')).toBe(BODY);
119
+ expect(readFileSync(pathsFor(dirB).html, 'utf8')).toBe(BODY);
120
+ });
121
+
122
+ test('adopt-mode concurrent seed also self-heals to one copy', async () => {
123
+ writeLocalBody(dirA, BODY);
124
+ writeLocalBody(dirB, BODY);
125
+ agentA = createCanvasSyncAgent({
126
+ slug: 'screen',
127
+ doc: docA,
128
+ paths: pathsFor(dirA),
129
+ echoGuard: createEchoGuard(),
130
+ flushMs: 0,
131
+ adopt: true,
132
+ });
133
+ agentB = createCanvasSyncAgent({
134
+ slug: 'screen',
135
+ doc: docB,
136
+ paths: pathsFor(dirB),
137
+ echoGuard: createEchoGuard(),
138
+ flushMs: 0,
139
+ adopt: true,
140
+ });
141
+ agentA.start();
142
+ agentB.start();
143
+
144
+ await agentA.reconcile();
145
+ await agentB.reconcile();
146
+ syncDocs(docA, docB);
147
+
148
+ expect(htmlFromDoc(docA)).toBe(BODY);
149
+ expect(htmlFromDoc(docB)).toBe(BODY);
150
+ });
151
+ });
152
+
153
+ describe('recovery of an already-duplicated hub (requirement #3)', () => {
154
+ test('a peer booting against a doubled doc collapses it on reconcile', async () => {
155
+ writeLocalBody(dirA, BODY);
156
+ // Simulate a hub that already concatenated two identical seeds.
157
+ const t = docA.getText('html');
158
+ t.insert(0, BODY);
159
+ t.insert(BODY.length, BODY);
160
+ expect(htmlFromDoc(docA)).toBe(BODY + BODY);
161
+
162
+ agentA = makeAgent(docA, dirA);
163
+ await agentA.reconcile();
164
+
165
+ expect(htmlFromDoc(docA)).toBe(BODY);
166
+ expect(htmlFromDoc(docA).match(/export default/g)?.length).toBe(1);
167
+ });
168
+ });
169
+
170
+ describe('sequential control — no over-eager collapse', () => {
171
+ test('B joining after A seeded adopts the single copy (no duplication)', async () => {
172
+ writeLocalBody(dirA, BODY);
173
+ writeLocalBody(dirB, BODY);
174
+ agentA = makeAgent(docA, dirA);
175
+ agentB = makeAgent(docB, dirB);
176
+
177
+ // A seeds first and FULLY propagates before B reconciles.
178
+ await agentA.reconcile();
179
+ syncDocs(docA, docB);
180
+ // B now sees the hub already holds the body → noop (adopt), no second seed.
181
+ await agentB.reconcile();
182
+ syncDocs(docA, docB);
183
+
184
+ expect(htmlFromDoc(docA)).toBe(BODY);
185
+ expect(htmlFromDoc(docB)).toBe(BODY);
186
+ });
187
+ });
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { describe, expect, test } from 'bun:test';
10
10
 
11
- import { type ColdStartInput, decideColdStart } from '../sync/cold-start.ts';
11
+ import { type ColdStartInput, decideColdStart, isExactRepeat } from '../sync/cold-start.ts';
12
12
  import { hashBytes } from '../sync/echo-guard.ts';
13
13
 
14
14
  const BODY_LOCAL = '<div>local work — a day of mascot edits</div>';
@@ -82,6 +82,66 @@ describe('decideColdStart — identical sides', () => {
82
82
  });
83
83
  });
84
84
 
85
+ describe('decideColdStart — concurrent cold-seed duplication (F1)', () => {
86
+ const BODY = '<div>export default fn — one copy</div>';
87
+
88
+ test('doc == local repeated twice → recover-seed-dup (collapse, not conflict)', () => {
89
+ const d = decideColdStart(input({ localBody: BODY, docBody: BODY + BODY }));
90
+ expect(d.action).toBe('recover-seed-dup');
91
+ });
92
+
93
+ test('doc == local repeated three times (3-way seed) → recover-seed-dup', () => {
94
+ const d = decideColdStart(input({ localBody: BODY, docBody: BODY.repeat(3) }));
95
+ expect(d.action).toBe('recover-seed-dup');
96
+ });
97
+
98
+ test('duplication recovery beats the conflict path even with divergent timestamps', () => {
99
+ // A doubled body is NOT new content — newest-wins must not get to pick it.
100
+ const d = decideColdStart(
101
+ input({
102
+ localBody: BODY,
103
+ docBody: BODY + BODY,
104
+ localMtimeMs: 1_000,
105
+ docBodyEditAtMs: 9_999,
106
+ })
107
+ );
108
+ expect(d.action).toBe('recover-seed-dup');
109
+ });
110
+
111
+ test('duplication recovery fires even with a stale journal', () => {
112
+ const d = decideColdStart(
113
+ input({ localBody: BODY, docBody: BODY + BODY, journalHash: 'stale' })
114
+ );
115
+ expect(d.action).toBe('recover-seed-dup');
116
+ });
117
+
118
+ test('doc = local + genuinely different bytes is NOT a repeat → conflict (snapshots both)', () => {
119
+ const d = decideColdStart(
120
+ input({
121
+ localBody: BODY,
122
+ docBody: `${BODY}<div>a real concurrent edit</div>`,
123
+ localMtimeMs: 2_000,
124
+ docBodyEditAtMs: 1_000,
125
+ })
126
+ );
127
+ expect(d.action).toBe('conflict');
128
+ });
129
+
130
+ test('exact equality (×1) stays a noop, never recover-seed-dup', () => {
131
+ expect(decideColdStart(input({ localBody: BODY, docBody: BODY })).action).toBe('noop');
132
+ });
133
+
134
+ test('isExactRepeat — exact multiples only', () => {
135
+ expect(isExactRepeat(BODY + BODY, BODY)).toBe(true);
136
+ expect(isExactRepeat(BODY.repeat(4), BODY)).toBe(true);
137
+ expect(isExactRepeat(BODY, BODY)).toBe(false); // ×1 is equality, not duplication
138
+ expect(isExactRepeat(`${BODY}x`, BODY)).toBe(false); // not a clean multiple
139
+ expect(isExactRepeat(`${BODY}${BODY}x`, BODY)).toBe(false);
140
+ expect(isExactRepeat('', BODY)).toBe(false);
141
+ expect(isExactRepeat(BODY, '')).toBe(false);
142
+ });
143
+ });
144
+
85
145
  describe('decideColdStart — journal-gated fast-forward', () => {
86
146
  test('local ≠ doc, journal matches hash(local) → fast-forward-hub (no conflict)', () => {
87
147
  const d = decideColdStart(
@@ -43,6 +43,24 @@ describe('TourOverlay', () => {
43
43
  expect(html).not.toContain('mdcc-tour__back'); // i === 0
44
44
  });
45
45
 
46
+ test('a step with a render graphic widens the card and draws the graphic (Phase 29 / E4)', () => {
47
+ function Graphic() {
48
+ return <div className="cm-info">TWO-LAYER-DIAGRAM</div>;
49
+ }
50
+ const html = renderToStaticMarkup(
51
+ <TourOverlay
52
+ open
53
+ steps={[{ render: Graphic, title: 'Working together', body: 'Two layers.' }]}
54
+ onClose={() => {}}
55
+ onComplete={() => {}}
56
+ />
57
+ );
58
+ expect(html).toContain('mdcc-tour__card--graphic');
59
+ expect(html).toContain('mdcc-tour__graphic');
60
+ expect(html).toContain('TWO-LAYER-DIAGRAM');
61
+ expect(html).toContain('Working together');
62
+ });
63
+
46
64
  test('closed or empty renders nothing', () => {
47
65
  expect(
48
66
  renderToStaticMarkup(
@@ -20,7 +20,7 @@ import {
20
20
  SHAPE_KIND_ICONS,
21
21
  TOOL_ICONS,
22
22
  } from './canvas-icons.tsx';
23
- import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
23
+ import { useChromeVisibility } from './use-chrome-visibility.tsx';
24
24
  import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
25
25
 
26
26
  // ─────────────────────────────────────────────────────────────────────────────
@@ -256,7 +256,7 @@ const SHAPE_KINDS: ReadonlyArray<{ kind: ShapeKind; label: string }> = [
256
256
  export function ToolPalette() {
257
257
  ensurePaletteStyles();
258
258
  const { tool, setTool, tools, sticky, toggleSticky, shapeKind, setShapeKind } = useToolMode();
259
- const visibilityCtx = useAnnotationsVisibility();
259
+ const chrome = useChromeVisibility();
260
260
  const [mounted, setMounted] = useState(false);
261
261
  const [shapeOpen, setShapeOpen] = useState(false);
262
262
  const containerRef = useRef<HTMLDivElement | null>(null);
@@ -287,11 +287,12 @@ export function ToolPalette() {
287
287
  }, [tool]);
288
288
 
289
289
  if (!mounted) return null;
290
+ // Presentation Mode hides the whole tool palette (clean artboards-only view).
291
+ if (chrome?.present) return null;
290
292
 
291
293
  const byId = new Map(tools.map((t) => [t.id, t]));
292
294
  const navList = NAV_TOOLS.map((id) => byId.get(id)).filter(Boolean);
293
295
  const drawList = DRAW_TOOLS.map((id) => byId.get(id)).filter(Boolean);
294
- const annotationsHidden = visibilityCtx ? !visibilityCtx.visible : false;
295
296
 
296
297
  const renderToolButton = (id: string, label: string, shortcut: string) => {
297
298
  const Icon = TOOL_ICONS[id];
@@ -412,12 +413,20 @@ export function ToolPalette() {
412
413
  </button>
413
414
  <button
414
415
  type="button"
415
- aria-label={
416
- annotationsHidden ? 'Show annotations (Shift+P)' : 'Hide annotations (Shift+P)'
417
- }
418
- aria-pressed={annotationsHidden}
419
- title="Presentation (Shift+P)"
420
- onClick={() => visibilityCtx?.setVisible(!visibilityCtx.visible)}
416
+ aria-label="Presentation mode — hide all chrome"
417
+ title="Presentation mode hide all chrome (Esc to exit)"
418
+ onClick={() => {
419
+ // Present Mode is a SHELL-level state (it hides the menubar /
420
+ // sidebar / panels too), so the canvas iframe requests it from the
421
+ // parent shell, which flips on `.is-present` + broadcasts
422
+ // dgn:'view-chrome' back down. Enter-only — the palette is hidden
423
+ // while presenting; exit is Esc or the floating pill.
424
+ try {
425
+ window.parent.postMessage({ dgn: 'present-enter' }, '*');
426
+ } catch {
427
+ /* detached / cross-origin */
428
+ }
429
+ }}
421
430
  >
422
431
  <IconPresentation />
423
432
  </button>
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @file use-chrome-visibility.tsx — canvas-chrome visibility store
3
+ * @scope apps/studio/use-chrome-visibility.tsx
4
+ * @purpose Tiny store shared by the in-canvas floating chrome — DCMiniMap +
5
+ * DCZoomToolbar (canvas-lib), ToolPalette, and AnnotationsLayer —
6
+ * so the dev-server menubar can hide/show the minimap and zoom
7
+ * controls, and drive a non-destructive "Presentation Mode" that
8
+ * overlays-hides ALL chrome (minimap, zoom, tool palette,
9
+ * annotations) without mutating the user's individual toggles.
10
+ * Mounted by CanvasShell (next to AnnotationsVisibilityProvider) so
11
+ * the user canvas (which renders the minimap/zoom) AND its overlay
12
+ * siblings under CanvasRouter all consume the same value. Updated
13
+ * by the `dgn:'view-chrome'` postMessage handler in CanvasRouter.
14
+ *
15
+ * `present` is a master overlay-hide that supersedes `minimap`/
16
+ * `zoom`: while it is on, every chrome consumer renders nothing,
17
+ * and exiting restores whatever the individual toggles were.
18
+ */
19
+
20
+ import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react';
21
+
22
+ export interface ChromeVisibilityState {
23
+ /** Floating world mini-map (DCMiniMap). */
24
+ minimap: boolean;
25
+ /** Bottom zoom pill (DCZoomToolbar). */
26
+ zoom: boolean;
27
+ /** Presentation Mode — hides ALL chrome regardless of the two above. */
28
+ present: boolean;
29
+ }
30
+
31
+ export interface ChromeVisibilityValue extends ChromeVisibilityState {
32
+ /** Merge a partial patch (only the provided keys change). */
33
+ setChrome: (patch: Partial<ChromeVisibilityState>) => void;
34
+ }
35
+
36
+ const ChromeVisibilityContext = createContext<ChromeVisibilityValue | null>(null);
37
+
38
+ export function ChromeVisibilityProvider({
39
+ children,
40
+ initial,
41
+ }: {
42
+ children: ReactNode;
43
+ initial?: Partial<ChromeVisibilityState>;
44
+ }) {
45
+ const [state, setState] = useState<ChromeVisibilityState>({
46
+ minimap: initial?.minimap ?? true,
47
+ zoom: initial?.zoom ?? true,
48
+ present: initial?.present ?? false,
49
+ });
50
+ const setChrome = useCallback((patch: Partial<ChromeVisibilityState>) => {
51
+ setState((prev) => ({ ...prev, ...patch }));
52
+ }, []);
53
+ const value = useMemo<ChromeVisibilityValue>(() => ({ ...state, setChrome }), [state, setChrome]);
54
+ return (
55
+ <ChromeVisibilityContext.Provider value={value}>{children}</ChromeVisibilityContext.Provider>
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Returns the chrome-visibility value, or null when rendered outside a
61
+ * provider (e.g. a bare DS specimen). Consumers treat null as "all visible"
62
+ * so the default behaviour is unchanged.
63
+ */
64
+ export function useChromeVisibility(): ChromeVisibilityValue | null {
65
+ return useContext(ChromeVisibilityContext);
66
+ }