@1agh/maude 0.60.5 → 0.60.7

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.
@@ -39,7 +39,16 @@ describe('comment-mount / MaybeToolProvider dedup', () => {
39
39
  const html = renderToStaticMarkup(
40
40
  createElement(MaybeToolProvider, null, createElement(ToolReader))
41
41
  );
42
- // feature-4 (browse/move split) — the default boot tool is `browse`.
42
+ // DDR-223 — the default boot tool is `move` (edit mode).
43
+ expect(html).toContain('data-tool="move"');
44
+ });
45
+
46
+ test('forwards initial="browse" — the comment-mount specimen posture (DDR-223)', () => {
47
+ // buildCanvasTree passes this explicitly so bare DS specimens keep the
48
+ // DDR-187 alive posture + native cursors.
49
+ const html = renderToStaticMarkup(
50
+ createElement(MaybeToolProvider, { initial: 'browse' }, createElement(ToolReader))
51
+ );
43
52
  expect(html).toContain('data-tool="browse"');
44
53
  });
45
54
  });
@@ -60,14 +60,16 @@ describe('hmr-broadcast / classification', () => {
60
60
  h.stop();
61
61
  });
62
62
 
63
- test('unrelated extensions are dropped', async () => {
63
+ test('unrelated extensions are dropped; media becomes an asset heal', async () => {
64
64
  const ctx = mkCtx();
65
65
  const got: HmrMessage[] = [];
66
66
  const h = createHmrBroadcaster(ctx, (m) => got.push(m));
67
- ctx.bus.emit('fs:any', 'ui/screenshot.png');
68
- ctx.bus.emit('fs:any', '_locator.json');
67
+ ctx.bus.emit('fs:any', 'ui/screenshot.png'); // media → asset heal (2026-08-15 RCA)
68
+ ctx.bus.emit('fs:any', '_locator.json'); // runtime state → dropped
69
69
  await awaitNextFlush();
70
- expect(got).toHaveLength(0);
70
+ expect(got).toHaveLength(1);
71
+ expect(got[0]?.mode).toBe('asset');
72
+ expect(got[0]?.file).toBe('ui/screenshot.png');
71
73
  h.stop();
72
74
  });
73
75
  });
@@ -59,7 +59,24 @@ describe('classifyChange', () => {
59
59
 
60
60
  test('unrelated extensions → null', () => {
61
61
  expect(classifyChange('ui/notes.md', noSibling)).toBeNull();
62
- expect(classifyChange('assets/logo.svg', noSibling)).toBeNull();
62
+ expect(classifyChange('assets/photo.png.part', noSibling)).toBeNull();
63
+ expect(classifyChange('system/x/assets/fonts/Gators-Bold.woff2', noSibling)).toBeNull();
64
+ });
65
+
66
+ test('media files → asset heal signal (2026-08-15 annotations-assets RCA)', () => {
67
+ // A synced-down media file landing on disk heals still-broken <img>/<image>
68
+ // elements in open canvases — no reload. Top-level assets/ and DS trees both.
69
+ for (const f of [
70
+ 'assets/65c3a940.png',
71
+ 'assets/807d7ba7.jpg',
72
+ 'assets/logo.svg',
73
+ 'assets/clip.mp4',
74
+ 'system/alligators/assets/photos-cut/frame-helmet.jpg',
75
+ ]) {
76
+ const msg = classifyChange(f, noSibling);
77
+ expect(msg?.mode).toBe('asset');
78
+ expect(msg?.file).toBe(f);
79
+ }
63
80
  });
64
81
 
65
82
  test('backslash paths are normalised before classification', () => {
@@ -0,0 +1,226 @@
1
+ // mode-toggle — DDR-223 (issue #93). The preview/edit mode layer over the
2
+ // DDR-187 browse/move tool split: boot posture, the mode⇄tool invariant
3
+ // (arming a resting tool moves the mode; annotation tools are mode-neutral),
4
+ // resetTool, read-only preview boot, and the palette's segmented toggle.
5
+ //
6
+ // Needs a live DOM (context callbacks fired via act) — register happy-dom +
7
+ // drive a createRoot, same convention as browse-posture.test.tsx.
8
+
9
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
10
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
11
+
12
+ beforeAll(() => {
13
+ GlobalRegistrator.register();
14
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
+ });
16
+ afterAll(() => {
17
+ GlobalRegistrator.unregister();
18
+ });
19
+
20
+ import { act, type ReactNode } from 'react';
21
+ import { createRoot, type Root } from 'react-dom/client';
22
+ import type { Tool } from '../input-router.tsx';
23
+ import { _resetReadOnlyCache } from '../read-only-mode.ts';
24
+ import { ToolPalette } from '../tool-palette.tsx';
25
+ import { type CanvasMode, ToolProvider, useToolMode } from '../use-tool-mode.tsx';
26
+
27
+ // Capture the live context value so tests can drive setMode/setTool/resetTool
28
+ // directly and assert the resulting (tool, mode) pair.
29
+ let ctx: ReturnType<typeof useToolMode> | null = null;
30
+ function Capture() {
31
+ ctx = useToolMode();
32
+ return null;
33
+ }
34
+
35
+ let root: Root | null = null;
36
+ let host: HTMLElement | null = null;
37
+
38
+ function mount(ui: ReactNode): void {
39
+ host = document.createElement('div');
40
+ document.body.appendChild(host);
41
+ root = createRoot(host);
42
+ act(() => {
43
+ root?.render(ui);
44
+ });
45
+ }
46
+
47
+ afterEach(() => {
48
+ act(() => {
49
+ root?.unmount();
50
+ });
51
+ host?.remove();
52
+ root = null;
53
+ host = null;
54
+ ctx = null;
55
+ _resetReadOnlyCache();
56
+ setPageUrl('http://localhost/');
57
+ });
58
+
59
+ // happy-dom: `history.replaceState` does not update `location.search`; the
60
+ // DetachedWindowAPI `setURL` is the supported seam.
61
+ function setPageUrl(url: string): void {
62
+ (window as unknown as { happyDOM?: { setURL?: (u: string) => void } }).happyDOM?.setURL?.(url);
63
+ }
64
+
65
+ const pair = (): { tool: Tool; mode: CanvasMode } => ({
66
+ tool: ctx?.tool as Tool,
67
+ mode: ctx?.mode as CanvasMode,
68
+ });
69
+
70
+ describe('mode-toggle / boot posture (DDR-223)', () => {
71
+ test('default boot = edit with move armed', () => {
72
+ mount(
73
+ <ToolProvider>
74
+ <Capture />
75
+ </ToolProvider>
76
+ );
77
+ expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
78
+ });
79
+
80
+ test('initial="browse" boots preview (comment-mount specimen posture)', () => {
81
+ mount(
82
+ <ToolProvider initial="browse">
83
+ <Capture />
84
+ </ToolProvider>
85
+ );
86
+ expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
87
+ });
88
+
89
+ test('read-only (?ro=1) boots preview/browse — the DDR-187 viewer posture', () => {
90
+ setPageUrl('http://localhost/?ro=1');
91
+ _resetReadOnlyCache();
92
+ mount(
93
+ <ToolProvider>
94
+ <Capture />
95
+ </ToolProvider>
96
+ );
97
+ expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
98
+ });
99
+ });
100
+
101
+ describe('mode-toggle / mode⇄tool invariant', () => {
102
+ test('setMode arms the resting tool both ways', () => {
103
+ mount(
104
+ <ToolProvider>
105
+ <Capture />
106
+ </ToolProvider>
107
+ );
108
+ act(() => ctx?.setMode('preview'));
109
+ expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
110
+ act(() => ctx?.setMode('edit'));
111
+ expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
112
+ });
113
+
114
+ test('arming a resting tool moves the mode (V / tool-set / escape hatch)', () => {
115
+ mount(
116
+ <ToolProvider>
117
+ <Capture />
118
+ </ToolProvider>
119
+ );
120
+ act(() => ctx?.setTool('browse'));
121
+ expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
122
+ // The Cmd+click-in-browse escape hatch is a plain setTool('move') at its
123
+ // call site — the invariant is what turns it into a coherent mode flip.
124
+ act(() => ctx?.setTool('move'));
125
+ expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
126
+ });
127
+
128
+ test('annotation tools are mode-neutral — pen in preview stays preview', () => {
129
+ mount(
130
+ <ToolProvider initial="browse">
131
+ <Capture />
132
+ </ToolProvider>
133
+ );
134
+ act(() => ctx?.setTool('pen'));
135
+ expect(pair()).toEqual({ tool: 'pen', mode: 'preview' });
136
+ act(() => ctx?.setTool('comment'));
137
+ expect(pair()).toEqual({ tool: 'comment', mode: 'preview' });
138
+ });
139
+
140
+ test('resetTool arms the CURRENT mode’s resting tool', () => {
141
+ mount(
142
+ <ToolProvider initial="browse">
143
+ <Capture />
144
+ </ToolProvider>
145
+ );
146
+ // Preview: drawing an annotation then resetting returns to browse (the
147
+ // alive posture) — never to move.
148
+ act(() => ctx?.setTool('pen'));
149
+ act(() => ctx?.resetTool());
150
+ expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
151
+ // Edit: byte-identical to the pre-DDR-223 hardcoded setTool('move').
152
+ act(() => ctx?.setMode('edit'));
153
+ act(() => ctx?.setTool('pen'));
154
+ act(() => ctx?.resetTool());
155
+ expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
156
+ });
157
+ });
158
+
159
+ describe('mode-toggle / palette segmented toggle', () => {
160
+ const mountPalette = () =>
161
+ mount(
162
+ <ToolProvider>
163
+ <Capture />
164
+ <ToolPalette />
165
+ </ToolProvider>
166
+ );
167
+ const seg = (which: 'preview' | 'edit' | 'present'): HTMLButtonElement | null =>
168
+ document.querySelector(`[data-testid="palette-mode-${which}"]`);
169
+
170
+ test('renders three icon segments; Edit is pressed on boot; no browse/move tool buttons', () => {
171
+ mountPalette();
172
+ expect(seg('preview')).not.toBeNull();
173
+ expect(seg('edit')?.getAttribute('aria-pressed')).toBe('true');
174
+ expect(seg('preview')?.getAttribute('aria-pressed')).toBe('false');
175
+ // Owner steer 2026-08-15 — icon-only segments (lucide eye / pencil-ruler /
176
+ // presentation); the words live in aria-label/title.
177
+ expect(seg('preview')?.querySelector('svg')).not.toBeNull();
178
+ expect(seg('edit')?.querySelector('svg')).not.toBeNull();
179
+ expect(seg('present')?.querySelector('svg')).not.toBeNull();
180
+ // The segments ARE the resting-tool affordances — no separate Select /
181
+ // Browse buttons in the nav group.
182
+ expect(document.querySelector('[aria-label^="Select (V)"]')).toBeNull();
183
+ expect(document.querySelector('[aria-label^="Browse ("]')).toBeNull();
184
+ // Present moved INTO the toggle — exactly one presentation affordance, and
185
+ // it is the segment (the palette's right-end button is gone).
186
+ const present = document.querySelectorAll('[aria-label^="Presentation mode"]');
187
+ expect(present.length).toBe(1);
188
+ expect(present[0]).toBe(seg('present') as Element);
189
+ expect(seg('present')?.getAttribute('aria-pressed')).toBe('false');
190
+ });
191
+
192
+ test('clicking Preview flips the posture; insert (+) is edit-only', () => {
193
+ mountPalette();
194
+ expect(
195
+ document.querySelector('[aria-label="Insert element — Div, Text, or Image"]')
196
+ ).not.toBeNull();
197
+ act(() => {
198
+ seg('preview')?.click();
199
+ });
200
+ expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
201
+ expect(seg('preview')?.getAttribute('aria-pressed')).toBe('true');
202
+ expect(
203
+ document.querySelector('[aria-label="Insert element — Div, Text, or Image"]')
204
+ ).toBeNull();
205
+ // Draw tools stay available in preview (issue #93: annotations in preview).
206
+ expect(document.querySelector('[aria-label^="Pen (B)"]')).not.toBeNull();
207
+ // And the Edit segment arms move again.
208
+ act(() => {
209
+ seg('edit')?.click();
210
+ });
211
+ expect(pair()).toEqual({ tool: 'move', mode: 'edit' });
212
+ });
213
+
214
+ test('clicking the ACTIVE segment re-arms the resting tool', () => {
215
+ mountPalette();
216
+ act(() => {
217
+ seg('preview')?.click();
218
+ });
219
+ act(() => ctx?.setTool('pen'));
220
+ expect(pair()).toEqual({ tool: 'pen', mode: 'preview' });
221
+ act(() => {
222
+ seg('preview')?.click();
223
+ });
224
+ expect(pair()).toEqual({ tool: 'browse', mode: 'preview' });
225
+ });
226
+ });
@@ -14,6 +14,7 @@ import {
14
14
  isPushableAssetRel,
15
15
  listPushableAssets,
16
16
  pushAssets,
17
+ pushOneAsset,
17
18
  putTimeoutMs,
18
19
  } from '../sync/asset-push.ts';
19
20
 
@@ -837,10 +838,11 @@ describe('the runtime sweeps on change, not only at boot', () => {
837
838
  test('the fs watcher decides with isPushableAssetRel and schedules a sweep', () => {
838
839
  // The predicate gets the project's OWN canvas groups — with the default
839
840
  // set a custom-group project's shared module would answer canvas-owned
840
- // and silently never schedule the sweep that uploads it.
841
- expect(SYNC).toContain(
842
- 'if (isPushableAssetRel(rel, ctx.cfg.canvasGroups)) scheduleAssetSweep('
843
- );
841
+ // and silently never schedule the sweep that uploads it. (Since the
842
+ // 2026-08-16 latency fix the same gate also feeds the fast lane; the
843
+ // sweep stays the reconciler.)
844
+ expect(SYNC).toContain('if (isPushableAssetRel(rel, ctx.cfg.canvasGroups)) {');
845
+ expect(SYNC).toContain('scheduleAssetSweep(linkedHub.url);');
844
846
  });
845
847
 
846
848
  test('bursts coalesce, and a change DURING a sweep is not lost', () => {
@@ -855,3 +857,112 @@ describe('the runtime sweeps on change, not only at boot', () => {
855
857
  expect(SYNC).toContain('if (assetSweepTimer !== null) clearTimeout(assetSweepTimer);');
856
858
  });
857
859
  });
860
+
861
+ describe('pushOneAsset — the fast lane (2026-08-16 latency fix)', () => {
862
+ test('probes once via /_asset-probe, then PUTs the single file', async () => {
863
+ const designRoot = scratchDesignRoot();
864
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
865
+ writeFileSync(join(designRoot, 'assets/aaaaaaaa.png'), 'fresh sticker');
866
+
867
+ const calls: Array<{ method: string; url: string }> = [];
868
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
869
+ const url = String(input);
870
+ const method = init?.method ?? 'GET';
871
+ calls.push({ method, url });
872
+ if (url.endsWith('/_asset-probe')) return Response.json({ present: [] });
873
+ return new Response('{"ok":true}', { status: 200 });
874
+ }) as typeof fetch;
875
+
876
+ const r = await pushOneAsset({
877
+ designRoot,
878
+ rel: 'assets/aaaaaaaa.png',
879
+ hubUrl: 'https://alligators.cloud.maude.sh/',
880
+ token: () => 'mau_tok',
881
+ fetchImpl,
882
+ });
883
+ expect(r).toEqual({ ok: true });
884
+ expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([
885
+ 'POST https://alligators.cloud.maude.sh/_asset-probe',
886
+ 'PUT https://alligators.cloud.maude.sh/assets/aaaaaaaa.png',
887
+ ]);
888
+ });
889
+
890
+ test('a file the hub already holds is skipped — the pull-echo guard', async () => {
891
+ // The fast lane fires off fs:any, and the asset PULL writes into assets/
892
+ // too — without this skip the lane would upload the hub's own bytes back.
893
+ const designRoot = scratchDesignRoot();
894
+ mkdirSync(join(designRoot, 'assets'), { recursive: true });
895
+ writeFileSync(join(designRoot, 'assets/aaaaaaaa.png'), 'pulled bytes');
896
+
897
+ let puts = 0;
898
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
899
+ const url = String(input);
900
+ if (url.endsWith('/_asset-probe')) return Response.json({ present: ['assets/aaaaaaaa.png'] });
901
+ if (init?.method === 'PUT') puts += 1;
902
+ return new Response('{"ok":true}', { status: 200 });
903
+ }) as typeof fetch;
904
+
905
+ const r = await pushOneAsset({
906
+ designRoot,
907
+ rel: 'assets/aaaaaaaa.png',
908
+ hubUrl: 'https://alligators.cloud.maude.sh',
909
+ token: () => 'mau_tok',
910
+ fetchImpl,
911
+ });
912
+ expect(r).toEqual({ ok: true, skipped: true });
913
+ expect(puts).toBe(0);
914
+ });
915
+
916
+ test('DS-tree asset routes to /_asset-file/; non-pushable rels refuse; failures report', async () => {
917
+ const designRoot = scratchDesignRoot();
918
+ mkdirSync(join(designRoot, 'system/ds/assets'), { recursive: true });
919
+ writeFileSync(join(designRoot, 'system/ds/assets/mark.svg'), 'brand');
920
+
921
+ const urls: string[] = [];
922
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
923
+ const url = String(input);
924
+ if (url.endsWith('/_asset-probe')) return Response.json({ present: [] });
925
+ if (init?.method === 'PUT') {
926
+ urls.push(url);
927
+ return new Response('refused', { status: 403 });
928
+ }
929
+ return new Response(null, { status: 404 });
930
+ }) as typeof fetch;
931
+
932
+ const r = await pushOneAsset({
933
+ designRoot,
934
+ rel: 'system/ds/assets/mark.svg',
935
+ hubUrl: 'https://hub.example.com',
936
+ token: () => 't',
937
+ fetchImpl,
938
+ });
939
+ expect(urls).toEqual(['https://hub.example.com/_asset-file/system/ds/assets/mark.svg']);
940
+ expect(r.ok).toBe(false);
941
+ expect(r.reason).toContain('403');
942
+
943
+ // Runtime state / config never enter the fast lane.
944
+ const refused = await pushOneAsset({
945
+ designRoot,
946
+ rel: '_history/x/old.png',
947
+ hubUrl: 'https://hub.example.com',
948
+ token: () => 't',
949
+ fetchImpl,
950
+ });
951
+ expect(refused.ok).toBe(false);
952
+ expect(refused.reason).toBe('not a pushable asset');
953
+ });
954
+
955
+ test('the runtime wires the fast lanes: push off fs:any, pull off reference files', () => {
956
+ // Same source-pin style as the sweep-trigger suite above.
957
+ expect(SYNC2).toContain('queueFastPush(rel');
958
+ expect(SYNC2).toMatch(/REFERENCE_FILE_RE\.test\(rel\)/);
959
+ expect(SYNC2).toContain('requestFastPull');
960
+ // stop() tears the lanes down.
961
+ expect(SYNC2).toContain('for (const t of fastPushTimers.values()) clearTimeout(t);');
962
+ expect(SYNC2).toContain('requestFastPull = null;');
963
+ // The cell never pushes — same guard as the sweep.
964
+ expect(SYNC2).toMatch(/if \(stopped \|\| cellPairing\) return; \/\/ the cell never pushes/);
965
+ });
966
+ });
967
+
968
+ const SYNC2 = readFileSync(join(import.meta.dir, '..', 'sync', 'index.ts'), 'utf8');
@@ -7,6 +7,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
7
7
  import {
8
8
  DEFAULT_TOOLS,
9
9
  filterToolsForReadOnly,
10
+ MODE_DEFAULT_TOOL,
10
11
  ToolProvider,
11
12
  useToolMode,
12
13
  useToolModeOptional,
@@ -49,6 +50,11 @@ describe('use-tool-mode / static', () => {
49
50
  expect(Object.isFrozen(DEFAULT_TOOLS)).toBe(true);
50
51
  });
51
52
 
53
+ test('MODE_DEFAULT_TOOL — DDR-223 resting tools: preview→browse, edit→move', () => {
54
+ expect(MODE_DEFAULT_TOOL).toEqual({ preview: 'browse', edit: 'move' });
55
+ expect(Object.isFrozen(MODE_DEFAULT_TOOL)).toBe(true);
56
+ });
57
+
52
58
  test('cursors per tool (Phase 24 — ONE Kenney library for every tool + native fallback)', () => {
53
59
  const byId = Object.fromEntries(DEFAULT_TOOLS.map((t) => [t.id, t.cursor]));
54
60
  // Phase 24 — EVERY tool (incl. move) ships a Kenney data-URI SVG cursor that
@@ -97,16 +103,40 @@ describe('use-tool-mode / useToolModeOptional', () => {
97
103
  describe('use-tool-mode / SSR render', () => {
98
104
  test('ToolProvider with consumer renders without throwing', () => {
99
105
  function Consumer() {
100
- const { tool } = useToolMode();
101
- return <span data-tool={tool}>{tool}</span>;
106
+ const { tool, mode } = useToolMode();
107
+ return (
108
+ <span data-tool={tool} data-mode={mode}>
109
+ {tool}
110
+ </span>
111
+ );
102
112
  }
103
113
  const html = renderToStaticMarkup(
104
114
  <ToolProvider>
105
115
  <Consumer />
106
116
  </ToolProvider>
107
117
  );
108
- // feature-4 — boot default is browse (the mock is alive until V is pressed).
118
+ // DDR-223 (issue #93) — boot posture is EDIT with the Move (select) tool
119
+ // armed; Preview (the DDR-187 alive posture) is one toggle away.
120
+ expect(html).toContain('data-tool="move"');
121
+ expect(html).toContain('data-mode="edit"');
122
+ });
123
+
124
+ test('ToolProvider initial="browse" boots the preview mode (comment-mount / specimens)', () => {
125
+ function Consumer() {
126
+ const { tool, mode } = useToolMode();
127
+ return (
128
+ <span data-tool={tool} data-mode={mode}>
129
+ {tool}
130
+ </span>
131
+ );
132
+ }
133
+ const html = renderToStaticMarkup(
134
+ <ToolProvider initial="browse">
135
+ <Consumer />
136
+ </ToolProvider>
137
+ );
109
138
  expect(html).toContain('data-tool="browse"');
139
+ expect(html).toContain('data-mode="preview"');
110
140
  });
111
141
 
112
142
  test('ToolProvider honors initial tool', () => {
@@ -16,7 +16,9 @@ import { useEffect, useRef, useState } from 'react';
16
16
 
17
17
  import {
18
18
  IconChevronDown,
19
+ IconEye,
19
20
  IconLetterA,
21
+ IconPencilRuler,
20
22
  IconPresentation,
21
23
  IconSquare,
22
24
  SHAPE_KIND_ICONS,
@@ -217,6 +219,10 @@ const PALETTE_CSS = `
217
219
  background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 14%, transparent);
218
220
  color: var(--maude-hud-accent, #d63b1f);
219
221
  }
222
+ /* DDR-223 — Preview/Edit/Present segmented mode toggle at the head of the
223
+ palette (owner steer 2026-08-15: icon segments — lucide eye / pencil-ruler /
224
+ presentation). Standard 32 × 32 buttons; active state reuses the
225
+ aria-pressed tint + underbar. */
220
226
  /* Stage I3 tail — "+ Element" insert popover. A vertical labeled list (not the
221
227
  shape popover's icon grid): Div/Text/Image have no recognizable glyph on
222
228
  their own, and this is a rare, must-be-legible action, not a recalled tool. */
@@ -257,9 +263,12 @@ function ensurePaletteStyles(): void {
257
263
  document.head.appendChild(s);
258
264
  }
259
265
 
260
- // feature-4 (browse/move split) — Browse leads the nav group (it's the boot
261
- // default), then Move (the select tool, V), Hand, Comment.
262
- const NAV_TOOLS = ['browse', 'move', 'hand', 'comment'] as const;
266
+ // DDR-223 — the Preview/Edit segments replace BOTH resting-tool buttons in
267
+ // the nav group: `browse` is armed by the Preview segment, `move` by the Edit
268
+ // segment (clicking the active segment re-arms it; V and Esc still arm move
269
+ // directly). Annotation tools render in BOTH modes (issue #93: preview keeps
270
+ // annotations).
271
+ const NAV_TOOLS = ['hand', 'comment'] as const;
263
272
  // Phase 24 — the two rect/ellipse buttons collapse into one Shape tool (with a
264
273
  // kind popover); sticky/arrow/text/eraser keep their order. Wave H — section
265
274
  // joins the row (it was ⇧S-only before; users couldn't discover it).
@@ -324,7 +333,18 @@ export function resolveInsertAnchor(
324
333
 
325
334
  export function ToolPalette() {
326
335
  ensurePaletteStyles();
327
- const { tool, setTool, tools, sticky, toggleSticky, shapeKind, setShapeKind } = useToolMode();
336
+ const {
337
+ tool,
338
+ setTool,
339
+ tools,
340
+ mode,
341
+ setMode,
342
+ resetTool,
343
+ sticky,
344
+ toggleSticky,
345
+ shapeKind,
346
+ setShapeKind,
347
+ } = useToolMode();
328
348
  const chrome = useChromeVisibility();
329
349
  const [mounted, setMounted] = useState(false);
330
350
  const [shapeOpen, setShapeOpen] = useState(false);
@@ -484,8 +504,65 @@ export function ToolPalette() {
484
504
  );
485
505
  };
486
506
 
507
+ // DDR-223 — the mode toggle, three segments (owner steer 2026-08-15, round
508
+ // 2): Preview (eye), Edit (pencil-ruler), Present (presentation) — icon-only,
509
+ // the words live in aria-label + title. Clicking the ACTIVE Preview/Edit
510
+ // segment re-arms that mode's resting tool (the "get me out of the pen tool"
511
+ // gesture — especially in preview, where browse has no palette button of its
512
+ // own). Present is the existing SHELL-level state (enter-only — the palette
513
+ // is hidden while presenting; exit is Esc or the floating pill); it moved
514
+ // here from the palette's right end so all three "ways of looking at the
515
+ // canvas" sit in one control.
516
+ const renderModeToggle = () => (
517
+ // biome-ignore lint/a11y/useSemanticElements: segmented control of buttons, not a form — fieldset semantics (and its default box styling) don't apply
518
+ <div className="dc-tp-group dc-tp-mode" role="group" aria-label="Canvas mode">
519
+ <button
520
+ type="button"
521
+ data-testid="palette-mode-preview"
522
+ aria-label="Preview mode — the mock is live; annotation tools stay on"
523
+ aria-pressed={mode === 'preview'}
524
+ title="Preview — the mock is live (buttons click); annotation tools stay on"
525
+ onClick={() => (mode === 'preview' ? resetTool() : setMode('preview'))}
526
+ >
527
+ <IconEye />
528
+ </button>
529
+ <button
530
+ type="button"
531
+ data-testid="palette-mode-edit"
532
+ aria-label="Edit mode — click selects & edits, like Figma (V)"
533
+ aria-pressed={mode === 'edit'}
534
+ title="Edit — click selects & edits, like Figma (V)"
535
+ onClick={() => (mode === 'edit' ? resetTool() : setMode('edit'))}
536
+ >
537
+ <IconPencilRuler />
538
+ </button>
539
+ <button
540
+ type="button"
541
+ data-testid="palette-mode-present"
542
+ aria-label="Presentation mode — hide all chrome (Esc to exit)"
543
+ aria-pressed={chrome?.present ?? false}
544
+ title="Presentation mode — hide all chrome (Esc to exit)"
545
+ onClick={() => {
546
+ // Present Mode is a SHELL-level state (it hides the menubar /
547
+ // sidebar / panels too), so the canvas iframe requests it from the
548
+ // parent shell, which flips on `.is-present` + broadcasts
549
+ // dgn:'view-chrome' back down.
550
+ try {
551
+ window.parent.postMessage({ dgn: 'present-enter' }, '*');
552
+ } catch {
553
+ /* detached / cross-origin */
554
+ }
555
+ }}
556
+ >
557
+ <IconPresentation />
558
+ </button>
559
+ </div>
560
+ );
561
+
487
562
  return (
488
563
  <div ref={containerRef} className="dc-tool-palette" role="toolbar" aria-label="Canvas tools">
564
+ {renderModeToggle()}
565
+ <div className="dc-tp-sep" />
489
566
  <div className="dc-tp-group">
490
567
  {navList.map((t) => (t ? renderToolButton(t.id, t.label, t.shortcut) : null))}
491
568
  </div>
@@ -505,7 +582,9 @@ export function ToolPalette() {
505
582
  ) : null}
506
583
  <div className="dc-tp-sep" />
507
584
  <div className="dc-tp-group">
508
- {!readOnly && (
585
+ {/* DDR-223 — "+ Element" is a JSX WRITE, an edit-mode affordance;
586
+ stickers/export/present stay in both modes. */}
587
+ {!readOnly && mode === 'edit' && (
509
588
  <span className="dc-tp-insert">
510
589
  <button
511
590
  type="button"
@@ -575,25 +654,8 @@ export function ToolPalette() {
575
654
  ⬇
576
655
  </span>
577
656
  </button>
578
- <button
579
- type="button"
580
- aria-label="Presentation mode — hide all chrome"
581
- title="Presentation mode — hide all chrome (Esc to exit)"
582
- onClick={() => {
583
- // Present Mode is a SHELL-level state (it hides the menubar /
584
- // sidebar / panels too), so the canvas iframe requests it from the
585
- // parent shell, which flips on `.is-present` + broadcasts
586
- // dgn:'view-chrome' back down. Enter-only — the palette is hidden
587
- // while presenting; exit is Esc or the floating pill.
588
- try {
589
- window.parent.postMessage({ dgn: 'present-enter' }, '*');
590
- } catch {
591
- /* detached / cross-origin */
592
- }
593
- }}
594
- >
595
- <IconPresentation />
596
- </button>
657
+ {/* DDR-223 addendum 2 — the Presentation button moved into the mode
658
+ toggle at the palette's head (third segment). */}
597
659
  </div>
598
660
  </div>
599
661
  );