@1agh/maude 0.29.0 → 0.30.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 (99) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +0 -1
  3. package/apps/studio/annotations-align.ts +149 -0
  4. package/apps/studio/annotations-bindings.ts +197 -0
  5. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  6. package/apps/studio/annotations-groups.ts +270 -0
  7. package/apps/studio/annotations-layer.tsx +1708 -1660
  8. package/apps/studio/annotations-model.ts +2077 -0
  9. package/apps/studio/annotations-snap.ts +125 -0
  10. package/apps/studio/api.ts +208 -116
  11. package/apps/studio/bin/_png-playwright.mjs +1 -1
  12. package/apps/studio/bin/annotate.mjs +732 -0
  13. package/apps/studio/bin/annotate.sh +17 -0
  14. package/apps/studio/bin/read-annotations.mjs +152 -17
  15. package/apps/studio/build.ts +1 -1
  16. package/apps/studio/canvas-arrowheads.ts +78 -9
  17. package/apps/studio/canvas-cursors.ts +2 -0
  18. package/apps/studio/canvas-edit.ts +257 -7
  19. package/apps/studio/canvas-icons.tsx +105 -0
  20. package/apps/studio/canvas-shell.tsx +305 -8
  21. package/apps/studio/client/app.jsx +2812 -238
  22. package/apps/studio/client/index.html +1 -1
  23. package/apps/studio/client/styles/3-shell-maude.css +713 -11
  24. package/apps/studio/client/styles/4-components.css +39 -0
  25. package/apps/studio/client/styles/5-maude-overrides.css +16 -2
  26. package/apps/studio/client/tour/overlay.jsx +105 -17
  27. package/apps/studio/client/tour/usage-tour.js +23 -10
  28. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  29. package/apps/studio/commands/move-artboards-command.ts +1 -1
  30. package/apps/studio/comments-overlay.tsx +7 -5
  31. package/apps/studio/context-menu.tsx +4 -3
  32. package/apps/studio/contextual-toolbar.tsx +14 -0
  33. package/apps/studio/cursors-overlay.tsx +20 -20
  34. package/apps/studio/dist/client.bundle.js +3322 -602
  35. package/apps/studio/dist/comment-mount.js +95 -9
  36. package/apps/studio/dist/styles.css +2490 -167
  37. package/apps/studio/dom-selection.ts +115 -0
  38. package/apps/studio/export-dialog.tsx +3 -3
  39. package/apps/studio/history.ts +20 -3
  40. package/apps/studio/http.ts +124 -5
  41. package/apps/studio/input-router.tsx +7 -0
  42. package/apps/studio/inspect.ts +37 -8
  43. package/apps/studio/participants-chrome.tsx +1 -7
  44. package/apps/studio/server.ts +5 -2
  45. package/apps/studio/sync/agent.ts +231 -57
  46. package/apps/studio/sync/codec.ts +45 -0
  47. package/apps/studio/sync/cold-start.ts +158 -0
  48. package/apps/studio/sync/connection-state.ts +58 -2
  49. package/apps/studio/sync/index.ts +563 -221
  50. package/apps/studio/sync/journal.ts +190 -0
  51. package/apps/studio/sync/migrate-seed.ts +202 -44
  52. package/apps/studio/sync/projection.ts +22 -3
  53. package/apps/studio/sync/status.ts +15 -4
  54. package/apps/studio/test/activity.test.ts +1 -7
  55. package/apps/studio/test/annotate-write.test.ts +184 -0
  56. package/apps/studio/test/annotations-align.test.ts +88 -0
  57. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  58. package/apps/studio/test/annotations-groups.test.ts +231 -0
  59. package/apps/studio/test/annotations-snap.test.ts +79 -0
  60. package/apps/studio/test/canvas-edit.test.ts +181 -1
  61. package/apps/studio/test/canvas-origin-gate.test.ts +8 -0
  62. package/apps/studio/test/collab-bridge.test.ts +0 -2
  63. package/apps/studio/test/collab-room.test.ts +2 -7
  64. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  65. package/apps/studio/test/csrf-write-guard.test.ts +52 -0
  66. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  67. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  68. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  69. package/apps/studio/test/sync-agent.test.ts +272 -2
  70. package/apps/studio/test/sync-codec.test.ts +65 -0
  71. package/apps/studio/test/sync-cold-start.test.ts +244 -0
  72. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  73. package/apps/studio/test/sync-hardening.test.ts +0 -8
  74. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  75. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  76. package/apps/studio/test/sync-journal.test.ts +176 -0
  77. package/apps/studio/test/sync-runtime.test.ts +360 -5
  78. package/apps/studio/test/sync-status.test.ts +66 -0
  79. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  80. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  81. package/apps/studio/test/use-collab.test.ts +0 -0
  82. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  83. package/apps/studio/tool-palette.tsx +12 -2
  84. package/apps/studio/undo-hud.tsx +0 -1
  85. package/apps/studio/use-agent-presence.tsx +4 -2
  86. package/apps/studio/use-annotation-resize.tsx +484 -54
  87. package/apps/studio/use-annotation-selection.tsx +16 -4
  88. package/apps/studio/use-canvas-activity.tsx +2 -2
  89. package/apps/studio/use-collab.tsx +20 -15
  90. package/apps/studio/use-selection-set.tsx +9 -0
  91. package/apps/studio/use-tool-mode.tsx +5 -1
  92. package/apps/studio/whats-new.json +135 -10
  93. package/apps/studio/whats-new.schema.json +18 -2
  94. package/cli/commands/design-link.test.mjs +84 -0
  95. package/cli/commands/design.mjs +2 -1
  96. package/cli/commands/design.test.mjs +5 -1
  97. package/cli/lib/design-link.mjs +51 -1
  98. package/package.json +8 -8
  99. package/plugins/design/templates/_shell.html +28 -4
@@ -1353,6 +1353,45 @@
1353
1353
  color: var(--u-fg-2);
1354
1354
  margin-bottom: 14px;
1355
1355
  }
1356
+ /* "Do this one thing" hint — shown when a step needs a canvas open or an element
1357
+ ⌘-clicked (the cross-origin canvas means the tour can't do it for you). */
1358
+ .mdcc-tour__hint {
1359
+ display: flex;
1360
+ align-items: center;
1361
+ gap: 8px;
1362
+ margin: -4px 0 14px;
1363
+ padding: 8px 10px;
1364
+ border-radius: var(--radius-md);
1365
+ background: color-mix(in oklab, var(--u-accent) 12%, transparent);
1366
+ border: 1px solid color-mix(in oklab, var(--u-accent) 35%, transparent);
1367
+ color: var(--u-fg-1);
1368
+ font-size: 12px;
1369
+ line-height: 1.4;
1370
+ }
1371
+ .mdcc-tour__hint-dot {
1372
+ flex: none;
1373
+ width: 8px;
1374
+ height: 8px;
1375
+ border-radius: 50%;
1376
+ background: var(--u-accent);
1377
+ animation: mdcc-tour-pulse 1.4s ease-in-out infinite;
1378
+ }
1379
+ @keyframes mdcc-tour-pulse {
1380
+ 0%,
1381
+ 100% {
1382
+ opacity: 0.4;
1383
+ transform: scale(0.85);
1384
+ }
1385
+ 50% {
1386
+ opacity: 1;
1387
+ transform: scale(1.15);
1388
+ }
1389
+ }
1390
+ @media (prefers-reduced-motion: reduce) {
1391
+ .mdcc-tour__hint-dot {
1392
+ animation: none;
1393
+ }
1394
+ }
1356
1395
  .mdcc-tour__actions {
1357
1396
  display: flex;
1358
1397
  align-items: center;
@@ -25,6 +25,9 @@
25
25
  .maude .help-modal-hd {
26
26
  border-bottom: 1px solid var(--border-subtle);
27
27
  background: var(--bg-1);
28
+ /* title + SKU sat glued together (flex space-between, no gap) */
29
+ justify-content: flex-start;
30
+ gap: var(--space-4);
28
31
  }
29
32
  .maude .help-modal-hd .title { font-family: var(--font-display); font-weight: 600; letter-spacing: var(--tracking-tight); text-transform: none; color: var(--fg-0); }
30
33
  .maude .help-modal-hd .sku { font-family: var(--font-mono); color: var(--fg-3); }
@@ -65,6 +68,8 @@
65
68
  .maude .mdcc-tour-nudge {
66
69
  background: var(--bg-2); border: 1px solid var(--border-default); border-left: 3px solid var(--accent);
67
70
  border-radius: var(--radius-md); box-shadow: var(--shadow-lg); font-family: var(--font-body); color: var(--fg-1);
71
+ /* raised above the in-canvas zoom pill (bottom-left), same as .st-toast */
72
+ bottom: 84px;
68
73
  }
69
74
  .maude .mdcc-tour-nudge__cta { background: var(--accent); color: var(--accent-fg); border: 0; border-radius: var(--radius-sm); font-family: var(--font-body); font-weight: 500; }
70
75
  .maude .mdcc-tour-nudge__cta:hover { background: var(--accent-hover); }
@@ -108,8 +113,17 @@
108
113
  .maude .sv-h-num, .maude .sv-count { font-family: var(--font-mono); color: var(--fg-3); }
109
114
  .maude .sv-tok-cell { border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--bg-1); overflow: hidden; }
110
115
  .maude .sv-tok-swatch { border-radius: 0; }
111
- .maude .sv-tok-name { font-family: var(--font-mono); color: var(--fg-0); }
112
- .maude .sv-tok-value { font-family: var(--font-mono); color: var(--fg-2); }
116
+ /* token meta — the legacy side-by-side row wrapped long names across 3 lines
117
+ * ("--presence-" / "offline"); stack name over value, each on one nowrap line. */
118
+ .maude .sv-tok-meta {
119
+ flex-direction: column;
120
+ align-items: flex-start;
121
+ justify-content: flex-start;
122
+ gap: 1px;
123
+ padding: var(--space-2) var(--space-3);
124
+ }
125
+ .maude .sv-tok-name { font-family: var(--font-mono); color: var(--fg-0); white-space: nowrap; max-width: 100%; }
126
+ .maude .sv-tok-value { font-family: var(--font-mono); color: var(--fg-2); max-width: 100%; }
113
127
  .maude .sv-type-row { border-bottom: 1px solid var(--border-subtle); }
114
128
  .maude .sv-type-tok { font-family: var(--font-mono); color: var(--fg-2); }
115
129
  .maude .sv-preview-card { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--bg-1); overflow: hidden; box-shadow: var(--shadow-sm); }
@@ -1,10 +1,23 @@
1
1
  // overlay.jsx — hand-rolled guided-tour engine (zero runtime dep, DDR-B).
2
2
  //
3
- // One <TourOverlay steps={[…]} open onClose onComplete/> drives both the
4
- // per-feature spotlight tours (a What's New entry's `tour[]`) and the evergreen
5
- // usage tour. A step = { target (CSS selector / [data-tour] anchor), title,
6
- // body, placement? }. A step whose target can't be resolved still shows, just
7
- // centered with no spotlight the tour never dead-ends on a missing element.
3
+ // One <TourOverlay steps={[…]} open onClose onComplete bus hasSelection hasCanvas/>
4
+ // drives both the per-feature spotlight tours (a What's New entry's `tour[]`) and
5
+ // the evergreen usage tour.
6
+ //
7
+ // A step is { target, title, body, placement?, ...setup }. The engine does more
8
+ // than spotlight: before each step it puts the shell into the state the target
9
+ // needs, then waits for the element to actually render before measuring — so the
10
+ // tour *walks the user through the live feature* instead of floating a modal over
11
+ // a missing element. Setup directives (all optional):
12
+ // canvas: true → ensure a canvas is open (bus.setup opens the first)
13
+ // inspector: true → open the right-hand Inspector
14
+ // tab: 'inspect'|'layers'|'css' → switch the Inspector tab
15
+ // requireSelection: true → the target only exists once an element is ⌘-clicked
16
+ // in the canvas; the card shows a "⌘-click to continue"
17
+ // hint and the spotlight snaps onto the real row the
18
+ // moment a selection lands.
19
+ // A step whose target still can't be resolved shows centered (no spotlight) — the
20
+ // tour never dead-ends on a missing element.
8
21
  //
9
22
  // A11y: role="dialog" + aria-modal, focus moves to the primary button on each
10
23
  // step and is trapped within the card, focus is restored on close, Esc skips,
@@ -26,7 +39,7 @@ function getRect(target) {
26
39
  }
27
40
 
28
41
  const CARD_W = 320;
29
- const CARD_H_EST = 168;
42
+ const CARD_H_EST = 184;
30
43
 
31
44
  function computeCardStyle(rect) {
32
45
  if (typeof window === 'undefined' || !rect) {
@@ -34,6 +47,13 @@ function computeCardStyle(rect) {
34
47
  }
35
48
  const vw = window.innerWidth;
36
49
  const vh = window.innerHeight;
50
+ const placeLeft = rect.left > CARD_W + 36 && vw - rect.right < CARD_W + 36;
51
+ if (placeLeft) {
52
+ // Panel-on-the-right targets (the Inspector): park the card to its left so it
53
+ // never covers the very thing it's pointing at.
54
+ const top = Math.min(Math.max(12, rect.top), vh - CARD_H_EST - 12);
55
+ return { top, left: Math.max(12, rect.left - CARD_W - 16), width: CARD_W };
56
+ }
37
57
  const roomBelow = vh - rect.bottom;
38
58
  const top =
39
59
  roomBelow > CARD_H_EST + 24 ? rect.bottom + 12 : Math.max(12, rect.top - CARD_H_EST - 12);
@@ -41,29 +61,84 @@ function computeCardStyle(rect) {
41
61
  return { top, left, width: CARD_W };
42
62
  }
43
63
 
44
- export function TourOverlay({ steps, open, onClose, onComplete }) {
64
+ export function TourOverlay({ steps, open, onClose, onComplete, bus, hasSelection, hasCanvas }) {
45
65
  const [i, setI] = useState(0);
46
66
  const [rect, setRect] = useState(null);
47
67
  const cardRef = useRef(null);
48
68
  const prevFocus = useRef(null);
69
+ const pollRef = useRef(null);
70
+ // The bus is rebuilt by the parent on every render; ref it so its identity
71
+ // churn never re-triggers the per-step setup effect (which would thrash).
72
+ const busRef = useRef(bus);
73
+ busRef.current = bus;
74
+
75
+ const step = steps[i] || null;
49
76
 
50
77
  const measure = useCallback(() => {
51
- const step = steps[i];
52
- setRect(step ? getRect(step.target) : null);
53
- }, [steps, i]);
78
+ if (!step) {
79
+ setRect(null);
80
+ return null;
81
+ }
82
+ const r = getRect(step.target);
83
+ setRect(r);
84
+ return r;
85
+ }, [step]);
54
86
 
55
87
  // Reset to the first step whenever a tour (re)opens.
56
88
  useEffect(() => {
57
89
  if (open) setI(0);
58
90
  }, [open]);
59
91
 
92
+ // Per-step: run the step's setup (open canvas / inspector / switch tab), then
93
+ // poll for the target to render before settling on a measurement. React state
94
+ // updates + the canvas-shell round-trip are async, so a single synchronous
95
+ // querySelector almost always misses on the first frame.
96
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-run only on step/selection change; bus + measure reached via ref/stable closures on purpose.
60
97
  useLayoutEffect(() => {
61
- if (open) measure();
62
- }, [open, measure]);
98
+ if (!open || !step) return undefined;
99
+ let cancelled = false;
100
+ setRect(null);
101
+
102
+ // Establish the UI state this step wants to highlight.
103
+ try {
104
+ busRef.current?.setup?.(step);
105
+ } catch {}
106
+
107
+ let tries = 0;
108
+ const MAX = 24; // ~3s at 130ms
109
+ const tick = () => {
110
+ if (cancelled) return;
111
+ const r = measure();
112
+ const settled = r || tries >= MAX;
113
+ if (settled) {
114
+ if (r) {
115
+ try {
116
+ document.querySelector(step.target)?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
117
+ } catch {}
118
+ // Re-measure after the scroll so the spotlight is glued post-layout.
119
+ requestAnimationFrame(() => {
120
+ if (!cancelled) measure();
121
+ });
122
+ }
123
+ return;
124
+ }
125
+ tries += 1;
126
+ pollRef.current = setTimeout(tick, 130);
127
+ };
128
+ tick();
129
+
130
+ return () => {
131
+ cancelled = true;
132
+ if (pollRef.current) clearTimeout(pollRef.current);
133
+ };
134
+ // Re-run when the step changes OR a selection arrives (a requireSelection
135
+ // target pops into existence the moment the user ⌘-clicks). `bus`/`measure`
136
+ // are reached through refs/stable closures to avoid identity-churn re-runs.
137
+ }, [open, i, hasSelection]);
63
138
 
64
139
  // Keep the spotlight glued to its target through scroll/resize; restore focus.
65
140
  useEffect(() => {
66
- if (!open) return;
141
+ if (!open) return undefined;
67
142
  prevFocus.current = document.activeElement;
68
143
  const onMove = () => measure();
69
144
  window.addEventListener('resize', onMove);
@@ -90,7 +165,7 @@ export function TourOverlay({ steps, open, onClose, onComplete }) {
90
165
 
91
166
  // Keyboard: Esc skips, ←/→ navigate, Tab is trapped within the card buttons.
92
167
  useEffect(() => {
93
- if (!open) return;
168
+ if (!open) return undefined;
94
169
  function onKey(e) {
95
170
  if (e.key === 'Escape') {
96
171
  e.preventDefault();
@@ -123,7 +198,7 @@ export function TourOverlay({ steps, open, onClose, onComplete }) {
123
198
 
124
199
  // Move focus to the primary action on each step (accessible dialog behavior).
125
200
  useEffect(() => {
126
- if (!open) return;
201
+ if (!open) return undefined;
127
202
  const t = setTimeout(() => {
128
203
  const primary = cardRef.current?.querySelector('[data-tour-primary]');
129
204
  try {
@@ -133,8 +208,7 @@ export function TourOverlay({ steps, open, onClose, onComplete }) {
133
208
  return () => clearTimeout(t);
134
209
  }, [open, i]);
135
210
 
136
- if (!open || !steps.length) return null;
137
- const step = steps[i];
211
+ if (!open || !steps.length || !step) return null;
138
212
  const pad = 6;
139
213
  const spot = rect
140
214
  ? {
@@ -145,6 +219,12 @@ export function TourOverlay({ steps, open, onClose, onComplete }) {
145
219
  }
146
220
  : null;
147
221
 
222
+ // A requireSelection step whose target hasn't materialized yet: tell the user
223
+ // the one action only they can take (the canvas iframe is cross-origin, so the
224
+ // tour can't ⌘-click for them — DDR-054).
225
+ const needSelHint = !!step.requireSelection && !hasSelection;
226
+ const needCanvasHint = (!!step.canvas || !!step.requireSelection) && hasCanvas === false;
227
+
148
228
  return (
149
229
  <div className="mdcc-tour" role="presentation">
150
230
  {spot ? (
@@ -170,6 +250,14 @@ export function TourOverlay({ steps, open, onClose, onComplete }) {
170
250
  <div className="mdcc-tour__body" id="mdcc-tour-body">
171
251
  {step.body}
172
252
  </div>
253
+ {(needCanvasHint || needSelHint) && (
254
+ <div className="mdcc-tour__hint" role="status" aria-live="polite">
255
+ <span className="mdcc-tour__hint-dot" aria-hidden="true" />
256
+ {needCanvasHint
257
+ ? 'Open any canvas from the sidebar to follow along.'
258
+ : 'Hold ⌘ and click an element in the canvas — the panel fills in live.'}
259
+ </div>
260
+ )}
173
261
  <div className="mdcc-tour__actions">
174
262
  <button type="button" className="mdcc-tour__skip" onClick={onClose}>
175
263
  Skip
@@ -1,35 +1,48 @@
1
1
  // usage-tour.js — the evergreen "how the Maude UI works" walkthrough.
2
2
  //
3
- // Targets stable elements that exist at first load (no canvas open required):
4
- // the sidebar, the empty viewport, the menus, the What's New badge, and Help.
5
- // Steps whose target is missing are shown centered by the overlay, so this is
6
- // resilient to chrome changes. Reached from Help "Take the tour" and offered
7
- // once on first run.
3
+ // Targets stable [data-tour] anchors (redesign-proof styling classes get
4
+ // renamed, these don't) on elements that exist at first load: the sidebar, the
5
+ // viewport, the menus, the Inspector, the What's New badge, and Help. The
6
+ // Inspector step carries `inspector: true` so the engine opens the panel before
7
+ // spotlighting it. Steps whose target is missing are shown centered by the
8
+ // overlay, so this stays resilient to chrome changes. Reached from Help →
9
+ // "Take the tour" and offered once on first run.
8
10
 
9
11
  export const USAGE_TOUR = [
10
12
  {
11
- target: '.sidebar',
13
+ target: "[data-tour='sidebar']",
12
14
  title: 'Your canvases live here',
13
15
  body: 'Every mock in the project shows up in this tree. Click one to open it in a tab — or use “+ board” to spin up a blank brief-board, no command needed.',
16
+ placement: 'right',
14
17
  },
15
18
  {
16
- target: '.viewport',
19
+ target: "[data-tour='viewport']",
17
20
  title: 'The canvas',
18
21
  body: 'Open a canvas, then ⌘-hover to preview the element under your cursor and ⌘-click to select it. Right-click for Copy CSS / Fit / Reset.',
19
22
  },
20
23
  {
21
- target: '.mb-menus',
24
+ target: "[data-tour='menus']",
22
25
  title: 'Menus & tools',
23
- body: 'View toggles panels (tree, comments, annotations); Selection and Tools act on the active canvas. Press ? for every shortcut.',
26
+ body: 'View toggles panels (tree, comments, Inspector); Selection and Tools act on the active canvas. Press ? for every shortcut.',
27
+ placement: 'bottom',
28
+ },
29
+ {
30
+ target: "[data-tour='inspector']",
31
+ inspector: true,
32
+ title: 'The Inspector',
33
+ body: 'Opened from View → Inspector (or press I). Inspect · Layers · CSS — ⌘-click any element and edit its CSS against your design tokens, right here. No AI round-trip.',
34
+ placement: 'left',
24
35
  },
25
36
  {
26
37
  target: "[data-tour='whatsnew']",
27
38
  title: 'What’s new',
28
- body: 'New features land here. Click the ✦ badge any time to catch up on what shipped.',
39
+ body: 'New features land here. Click the ✦ badge any time to catch up on what shipped — some entries come with a guided tour like this one.',
40
+ placement: 'bottom',
29
41
  },
30
42
  {
31
43
  target: "[data-tour='help']",
32
44
  title: 'Help is one key away',
33
45
  body: 'Press ? for the full keyboard cheat-sheet — and you can restart this tour from the Help menu whenever you like.',
46
+ placement: 'bottom',
34
47
  },
35
48
  ];
@@ -20,7 +20,7 @@
20
20
  */
21
21
 
22
22
  import type { Stroke } from '../annotations-layer.tsx';
23
- import type { CommandRecord, CommandSinks, EditCommand } from '../undo-stack.ts';
23
+ import type { CommandRecord, EditCommand } from '../undo-stack.ts';
24
24
  import { registerCommand } from '../undo-stack.ts';
25
25
 
26
26
  /**
@@ -17,7 +17,7 @@
17
17
  * stale layout.
18
18
  */
19
19
 
20
- import type { CommandRecord, CommandSinks, EditCommand } from '../undo-stack.ts';
20
+ import type { CommandRecord, EditCommand } from '../undo-stack.ts';
21
21
  import { registerCommand } from '../undo-stack.ts';
22
22
 
23
23
  export interface ArtboardLayoutEntry {
@@ -161,7 +161,7 @@ function screenRectFor(
161
161
  } catch {
162
162
  return null;
163
163
  }
164
- if (!el || !el.isConnected) return null;
164
+ if (!el?.isConnected) return null;
165
165
  const r = el.getBoundingClientRect();
166
166
  if (r.width === 0 && r.height === 0) return null;
167
167
  return { x: r.left, y: r.top, w: r.width, h: r.height };
@@ -204,7 +204,7 @@ export function CommentsOverlay(): React.ReactNode {
204
204
  const mirrorSelection = useCallback(
205
205
  (comment: OverlayComment | undefined) => {
206
206
  if (!selSet) return;
207
- if (!comment || !comment.selector) {
207
+ if (!comment?.selector) {
208
208
  selSet.clear();
209
209
  return;
210
210
  }
@@ -298,7 +298,7 @@ export function CommentsOverlay(): React.ReactNode {
298
298
  const detail = (
299
299
  e as CustomEvent<{ selection?: ComposeSelection; clientX?: number; clientY?: number }>
300
300
  ).detail;
301
- if (!detail || !detail.selection) return;
301
+ if (!detail?.selection) return;
302
302
  setComposer({
303
303
  selection: detail.selection,
304
304
  clientX: typeof detail.clientX === 'number' ? detail.clientX : 0,
@@ -383,7 +383,9 @@ export function CommentsOverlay(): React.ReactNode {
383
383
  const indexById = useMemo(() => {
384
384
  const m = new Map<string, number>();
385
385
  const all = comments.slice().sort((a, b) => a.created.localeCompare(b.created));
386
- all.forEach((c, i) => m.set(c.id, i + 1));
386
+ all.forEach((c, i) => {
387
+ m.set(c.id, i + 1);
388
+ });
387
389
  return m;
388
390
  }, [comments]);
389
391
 
@@ -784,7 +786,7 @@ function CommentPin({
784
786
  return () => {
785
787
  if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
786
788
  };
787
- }, [comment.selector, comment.bounds]);
789
+ }, [comment.selector, comment.bounds, comment.index]);
788
790
 
789
791
  const author = comment.author?.trim() || 'unknown';
790
792
  const label = `Comment ${sequence} by ${author}`;
@@ -237,7 +237,9 @@ const MENU_CSS = `
237
237
  .dc-context-menu .dc-menu-flyout.is-flip { left: auto; right: calc(100% + 3px); }
238
238
  `.trim();
239
239
 
240
- function ensureMenuStyles(): void {
240
+ // Exported (FigJam v3) the annotation layer's right-click menu reuses the
241
+ // same .dc-context-menu visual language without duplicating the stylesheet.
242
+ export function ensureMenuStyles(): void {
241
243
  if (typeof document === 'undefined') return;
242
244
  if (document.getElementById('dc-context-menu-css')) return;
243
245
  const s = document.createElement('style');
@@ -342,7 +344,7 @@ function ContextMenuView({
342
344
  el.querySelectorAll<HTMLButtonElement>('button.dc-menu-item:not([disabled])')
343
345
  );
344
346
  if (items.length === 0) return;
345
- const idx = items.findIndex((b) => b === document.activeElement);
347
+ const idx = items.indexOf(document.activeElement);
346
348
  const nextIdx =
347
349
  e.key === 'ArrowDown'
348
350
  ? (idx + 1) % items.length
@@ -368,7 +370,6 @@ function ContextMenuView({
368
370
  {sections.map((section, si) => {
369
371
  const sectionKey = section.map((i) => i.id).join('|') || `s${si}`;
370
372
  return (
371
- // biome-ignore lint/a11y/useSemanticElements: ARIA group within role="menu"; no native equivalent.
372
373
  <div key={sectionKey} role="group">
373
374
  {si > 0 ? <div className="dc-menu-sep" aria-hidden="true" /> : null}
374
375
  {section.map((item) => (
@@ -207,6 +207,20 @@ export function ContextualToolbar() {
207
207
  return (
208
208
  <div ref={ref} className="dc-elem-ctx-tb" role="toolbar" aria-label="Element actions">
209
209
  <span className="dc-elem-ctx-count">{count === 1 ? '1 element' : `${count} elements`}</span>
210
+ <button
211
+ type="button"
212
+ title="Inspect — open the right panel (Inspect / Layers / CSS)"
213
+ onClick={() => {
214
+ // Phase 12 — open the shell's Inspector on the current selection.
215
+ try {
216
+ window.parent.postMessage({ dgn: 'open-inspector' }, '*');
217
+ } catch {
218
+ /* detached / cross-origin */
219
+ }
220
+ }}
221
+ >
222
+ Inspect
223
+ </button>
210
224
  <button
211
225
  type="button"
212
226
  title="Copy CSS selector"
@@ -43,24 +43,28 @@ const CURSOR_CSS = `
43
43
  }
44
44
  .dc-cursor-arrow {
45
45
  display: block;
46
- filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.25));
46
+ filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
47
47
  }
48
+ /* DS colors-presence .cur-label recipe — hue pill, mono 10px, dark
49
+ * accent-fg text (never white-on-hue, which washed out on light hues).
50
+ * NOTE: keep this comment backtick-free — it lives inside the CURSOR_CSS
51
+ * template literal and a stray backtick closes it. */
48
52
  .dc-cursor-label {
49
53
  position: absolute;
50
- top: 18px;
51
- left: 14px;
52
- padding: 2px 6px;
53
- font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
54
+ /* specimen geometry: the pill hangs below-right of the 15px triangle */
55
+ top: 14px;
56
+ left: 10px;
57
+ padding: 1px 8px;
58
+ font-family: var(--font-mono, ui-monospace, monospace);
54
59
  font-weight: 500;
55
- font-size: 11px;
56
- line-height: 1.2;
57
- color: var(--maude-hud-accent-fg, #fff);
58
- border-radius: var(--radius-sm, 2px);
60
+ font-size: 10px;
61
+ line-height: 1.4;
62
+ color: var(--maude-hud-accent-fg, oklch(0.180 0.030 268));
63
+ border-radius: 999px;
59
64
  white-space: nowrap;
60
65
  max-width: 180px;
61
66
  overflow: hidden;
62
67
  text-overflow: ellipsis;
63
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
64
68
  }
65
69
  .dc-peer-selection {
66
70
  position: absolute;
@@ -117,20 +121,16 @@ const Cursor = memo(function Cursor({ peer, viewport }: CursorProps): JSX.Elemen
117
121
  const screenY = peer.cursor.y * viewport.zoom + viewport.y;
118
122
  return (
119
123
  <div className="dc-cursor" style={{ transform: `translate(${screenX}px, ${screenY}px)` }}>
124
+ {/* DS colors-presence Pointer — one plain triangle glyph, tinted by its
125
+ owner (the specimen's exact 24-grid path, no tail/notch). */}
120
126
  <svg
121
127
  className="dc-cursor-arrow"
122
- width="20"
123
- height="22"
124
- viewBox="0 0 20 22"
128
+ width="15"
129
+ height="15"
130
+ viewBox="0 0 24 24"
125
131
  aria-hidden="true"
126
132
  >
127
- <path
128
- d="M 2 2 L 2 18 L 6 14 L 9 21 L 12 20 L 9 13 L 14 13 Z"
129
- fill={peer.color}
130
- stroke="#fff"
131
- strokeWidth="1.2"
132
- strokeLinejoin="round"
133
- />
133
+ <path d="M4 3 L20 11.5 L12.5 13 L10 21 Z" fill={peer.color} />
134
134
  </svg>
135
135
  <div className="dc-cursor-label" style={{ background: peer.color }}>
136
136
  {peer.name}