@1agh/maude 0.30.0 → 0.31.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.
- package/README.md +5 -5
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/annotations-layer.tsx +6 -1
- package/apps/studio/api.ts +225 -66
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +22 -2
- package/apps/studio/client/app.jsx +781 -26
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +613 -3
- package/apps/studio/client/styles/5-maude-overrides.css +25 -0
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +11 -1
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/cursors-overlay.tsx +17 -1
- package/apps/studio/dist/client.bundle.js +28780 -1534
- package/apps/studio/dist/comment-mount.js +4 -2
- package/apps/studio/dist/styles.css +5633 -1617
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +384 -1
- package/apps/studio/participants-chrome.tsx +69 -9
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +65 -2
- package/apps/studio/sync/agent.ts +81 -1
- package/apps/studio/sync/codec.ts +24 -0
- package/apps/studio/sync/cold-start.ts +40 -0
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csrf-write-guard.test.ts +26 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +61 -1
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/tool-palette.tsx +18 -9
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +414 -187
- package/apps/studio/whats-new.json +73 -0
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design.mjs +1 -0
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +30 -8
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
+
}
|