@1agh/maude 0.29.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.
Files changed (162) hide show
  1. package/README.md +6 -6
  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/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -4,6 +4,7 @@
4
4
  // Universal — no project tokens needed; styling lives in client/styles/.
5
5
 
6
6
  import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
7
+ import { createPortal } from 'react-dom';
7
8
  import { createRoot } from 'react-dom/client';
8
9
 
9
10
  // Trusted tool→cursor resolver (shares the single TOOL_CURSORS source with the
@@ -12,11 +13,21 @@ import { createRoot } from 'react-dom/client';
12
13
  // bundle — no React, no input-router. See the tool-cursor handler below.
13
14
  import { resolveToolCursor } from '../canvas-cursors.ts';
14
15
  import { canvasUrl } from './canvas-url.js';
16
+ import ChatPanel from './panels/ChatPanel.jsx';
17
+ import DiffView from './panels/DiffView.jsx';
18
+ import GitPanel from './panels/GitPanel.jsx';
19
+ import IdentityBar from './panels/IdentityBar.jsx';
20
+ import OnboardingWizard from './panels/OnboardingWizard.jsx';
21
+ import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
22
+ import { appIsFirstRun, isNativeApp, onUpdateReady, restartToUpdate } from './github.js';
23
+ import { COLLAB_TOUR } from './tour/collab-tour.js';
15
24
  import { TourOverlay } from './tour/overlay.jsx';
16
25
  import { USAGE_TOUR } from './tour/usage-tour.js';
17
26
  import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
18
27
 
19
28
  const USAGE_TOUR_STORE = 'mdcc-usage-tour-seen';
29
+ // Phase 29 (E4) — the collab "rychlý kurz" is offered once after onboarding.
30
+ const COLLAB_TOUR_STORE = 'mdcc-collab-tour-seen';
20
31
 
21
32
  const SYSTEM_TAB = '__system__';
22
33
  const THEME_STORE = 'mdcc-theme';
@@ -295,6 +306,13 @@ const STICONS = {
295
306
  sparkle: (
296
307
  <path d="M8 1.8l1.4 4.8L14 8l-4.6 1.4L8 14.2l-1.4-4.8L2 8l4.6-1.4z" fill="currentColor" stroke="none" />
297
308
  ),
309
+ megaphone: (
310
+ <>
311
+ <path d="M2 6.7 11 4v8L2 9.3z" />
312
+ <path d="M11 5.2a2.4 2.4 0 0 1 0 5.6" />
313
+ <path d="M4.3 9.5v2.3a1.2 1.2 0 0 0 2.4 0v-1.7" />
314
+ </>
315
+ ),
298
316
  'panel-left': (
299
317
  <>
300
318
  <rect x="2.5" y="3" width="11" height="10" rx="1.5" />
@@ -319,6 +337,96 @@ const STICONS = {
319
337
  <polyline points="2.2 9 8 12.3 13.8 9" />
320
338
  </>
321
339
  ),
340
+ // Layer-type glyphs (Phase 12.3 W3.1) — one mark per LayerNode `type`.
341
+ box: <rect x="3" y="3" width="10" height="10" rx="1.2" />,
342
+ type: (
343
+ <>
344
+ <polyline points="4 4 12 4" />
345
+ <line x1="8" y1="4" x2="8" y2="12" />
346
+ </>
347
+ ),
348
+ button: (
349
+ <>
350
+ <rect x="2.5" y="5" width="11" height="6" rx="3" />
351
+ <line x1="6" y1="8" x2="10" y2="8" />
352
+ </>
353
+ ),
354
+ input: (
355
+ <>
356
+ <rect x="2.5" y="5" width="11" height="6" rx="1.2" />
357
+ <line x1="5" y1="8" x2="5" y2="8" />
358
+ </>
359
+ ),
360
+ link: (
361
+ <>
362
+ <path d="M6.5 9.5a2.5 2.5 0 0 1 0-3.5l1.5-1.5a2.5 2.5 0 0 1 3.5 3.5l-1 1" />
363
+ <path d="M9.5 6.5a2.5 2.5 0 0 1 0 3.5l-1.5 1.5a2.5 2.5 0 0 1-3.5-3.5l1-1" />
364
+ </>
365
+ ),
366
+ list: (
367
+ <>
368
+ <line x1="6" y1="4.5" x2="13" y2="4.5" />
369
+ <line x1="6" y1="8" x2="13" y2="8" />
370
+ <line x1="6" y1="11.5" x2="13" y2="11.5" />
371
+ <circle cx="3.2" cy="4.5" r="0.8" fill="currentColor" />
372
+ <circle cx="3.2" cy="8" r="0.8" fill="currentColor" />
373
+ <circle cx="3.2" cy="11.5" r="0.8" fill="currentColor" />
374
+ </>
375
+ ),
376
+ eye: (
377
+ <>
378
+ <path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z" />
379
+ <circle cx="8" cy="8" r="2" />
380
+ </>
381
+ ),
382
+ eyedropper: (
383
+ <>
384
+ <path d="M11 2.6a1.7 1.7 0 0 1 2.4 2.4l-1.2 1.2-2.4-2.4z" />
385
+ <path d="M9.5 4.6 4 10.1V12h1.9l5.5-5.5" />
386
+ </>
387
+ ),
388
+ // Figma-style property prefix glyphs (#2) — small marks INSIDE numeric fields.
389
+ 'p-corner': <path d="M3.5 12.5V7a3.5 3.5 0 0 1 3.5-3.5h5.5" />,
390
+ 'p-opacity': (
391
+ <>
392
+ <rect x="3" y="3" width="10" height="10" rx="1.5" />
393
+ <path d="M3 8h10M8 3v10" strokeWidth="0.9" opacity="0.55" />
394
+ </>
395
+ ),
396
+ 'p-lineheight': (
397
+ <>
398
+ <line x1="6.5" y1="4" x2="13" y2="4" />
399
+ <line x1="6.5" y1="8" x2="13" y2="8" />
400
+ <line x1="6.5" y1="12" x2="13" y2="12" />
401
+ <path d="M3.2 4.6 3.2 11.4M2 6 3.2 4.5 4.4 6M2 10 3.2 11.5 4.4 10" />
402
+ </>
403
+ ),
404
+ 'p-letterspacing': (
405
+ <>
406
+ <path d="M3 4v8M13 4v8" />
407
+ <path d="M6 11.5 8 5l2 6.5M6.7 9.3h2.6" strokeWidth="1.1" />
408
+ </>
409
+ ),
410
+ 'p-gap': (
411
+ <>
412
+ <rect x="2" y="4.5" width="3.6" height="7" rx="0.6" />
413
+ <rect x="10.4" y="4.5" width="3.6" height="7" rx="0.6" />
414
+ <path d="M6.8 8h2.4M7.4 6.9 6.4 8l1 1.1M8.6 6.9 9.6 8l-1 1.1" strokeWidth="1" />
415
+ </>
416
+ ),
417
+ 'p-border': <rect x="3" y="3" width="10" height="10" rx="1" />,
418
+ 'p-size': (
419
+ <>
420
+ <path d="M3 13 6.6 3l3.6 10" />
421
+ <path d="M4.3 9.6h4.6" />
422
+ </>
423
+ ),
424
+ 'eye-off': (
425
+ <>
426
+ <path d="M6.3 4A6.7 6.7 0 0 1 8 3.5C12 3.5 14.5 8 14.5 8a12 12 0 0 1-2 2.4M4.4 5.3A12 12 0 0 0 1.5 8S4 12.5 8 12.5a6.5 6.5 0 0 0 2.1-.35" />
427
+ <line x1="2.5" y1="2.5" x2="13.5" y2="13.5" />
428
+ </>
429
+ ),
322
430
  sliders: (
323
431
  <>
324
432
  <line x1="3" y1="5" x2="13" y2="5" />
@@ -390,6 +498,22 @@ const STICONS = {
390
498
  <line x1="13" y1="3" x2="7.6" y2="8.4" />
391
499
  </>
392
500
  ),
501
+ share: (
502
+ <>
503
+ <circle cx="4" cy="8" r="1.9" />
504
+ <circle cx="11.6" cy="3.6" r="1.9" />
505
+ <circle cx="11.6" cy="12.4" r="1.9" />
506
+ <line x1="5.7" y1="7" x2="9.9" y2="4.6" />
507
+ <line x1="5.7" y1="9" x2="9.9" y2="11.4" />
508
+ </>
509
+ ),
510
+ pen: (
511
+ <>
512
+ <path d="M3 13l.8-3L10.6 3.2a1.1 1.1 0 0 1 1.6 0l.6.6a1.1 1.1 0 0 1 0 1.6L6 12.2z" />
513
+ <line x1="9.6" y1="4.2" x2="11.8" y2="6.4" />
514
+ </>
515
+ ),
516
+ square: <rect x="3.5" y="3.5" width="9" height="9" rx="1" />,
393
517
  };
394
518
 
395
519
  // ⌘K command palette — the mockup's signature surface, wired to real shell
@@ -398,12 +522,19 @@ const STICONS = {
398
522
  function CommandPalette({ open, onClose, actions }) {
399
523
  const [q, setQ] = useState('');
400
524
  const [active, setActive] = useState(0);
525
+ const listRef = useRef(null);
401
526
  useEffect(() => {
402
527
  if (open) {
403
528
  setQ('');
404
529
  setActive(0);
405
530
  }
406
531
  }, [open]);
532
+ // Keep the keyboard-active row visible while arrowing through a scrolled list.
533
+ useEffect(() => {
534
+ listRef.current
535
+ ?.querySelector('.st-pal-item.is-active')
536
+ ?.scrollIntoView({ block: 'nearest' });
537
+ }, [active]);
407
538
  const filtered = useMemo(() => {
408
539
  const needle = q.trim().toLowerCase();
409
540
  if (!needle) return actions;
@@ -457,7 +588,7 @@ function CommandPalette({ open, onClose, actions }) {
457
588
  />
458
589
  <Kbd>⌘K</Kbd>
459
590
  </div>
460
- <div className="st-pal-list">
591
+ <div className="st-pal-list" ref={listRef}>
461
592
  {filtered.length === 0 ? (
462
593
  <div className="st-pal-empty">No matching command.</div>
463
594
  ) : (
@@ -530,9 +661,19 @@ function initialsOf(name) {
530
661
  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
531
662
  return ((parts[0][0] || '') + (parts[parts.length - 1][0] || '')).toUpperCase() || '?';
532
663
  }
533
- function StAvatar({ initials, hue, title }) {
664
+ function StAvatar({ initials, hue, title, pulse }) {
665
+ // Hue rides a custom property so CSS can mix it into the surface (DS avatar
666
+ // recipe: tinted bg + hue border + fg-0 text — solid fill + white text broke
667
+ // the accent-fg contrast rule and washed out in light theme). `pulse` plays
668
+ // the DS motion-presence role (scale+opacity ring) — the AI agent's "live"
669
+ // tell while it's editing.
534
670
  return (
535
- <span className="st-avatar" style={{ background: hue }} title={title} aria-label={title}>
671
+ <span
672
+ className={'st-avatar' + (pulse ? ' is-pulsing' : '')}
673
+ style={{ '--av-hue': hue }}
674
+ data-tip={title}
675
+ aria-label={title}
676
+ >
536
677
  {initials}
537
678
  </span>
538
679
  );
@@ -542,6 +683,78 @@ function Kbd({ children }) {
542
683
  return <span className="kbd">{children}</span>;
543
684
  }
544
685
 
686
+ // ───────── Resizable panel grip (DS components-resize-panels contract) ─────────
687
+ //
688
+ // 8px hit area on a 1px seam; grip dots + accent surface on hover/focus/drag;
689
+ // pointer drag (with capture, so moves keep arriving over the iframe), arrow-key
690
+ // nudge (8px, ⇧=24px), Home/End to min/max, double-click resets to default.
691
+ // Width persists per panel in localStorage.
692
+
693
+ function usePanelSize(storeKey, { min, max, def }) {
694
+ const clamp = useCallback((v) => Math.min(max, Math.max(min, v)), [min, max]);
695
+ const [w, setWRaw] = useState(() => {
696
+ try {
697
+ const v = parseInt(localStorage.getItem(storeKey) || '', 10);
698
+ return Number.isFinite(v) ? clamp(v) : def;
699
+ } catch {
700
+ return def;
701
+ }
702
+ });
703
+ useEffect(() => {
704
+ try {
705
+ localStorage.setItem(storeKey, String(w));
706
+ } catch {}
707
+ }, [storeKey, w]);
708
+ const setW = useCallback(
709
+ (next) => setWRaw((prev) => clamp(typeof next === 'function' ? next(prev) : next)),
710
+ [clamp]
711
+ );
712
+ return { w, setW, min, max, def };
713
+ }
714
+
715
+ function PanelGrip({ label, size, onPointerDown, active, dir = 'ltr' }) {
716
+ const { w, setW, min, max, def } = size;
717
+ // `dir` is grip-relative: 'ltr' (left panel) → ArrowRight widens; 'rtl'
718
+ // (right dock) → ArrowRight narrows, since the seam moves toward the panel.
719
+ const grow = dir === 'rtl' ? -1 : 1;
720
+ return (
721
+ <div
722
+ className={'st-grip' + (active ? ' is-active' : '')}
723
+ role="separator"
724
+ tabIndex={0}
725
+ aria-orientation="vertical"
726
+ aria-label={label}
727
+ aria-valuemin={min}
728
+ aria-valuemax={max}
729
+ aria-valuenow={Math.round(w)}
730
+ onPointerDown={onPointerDown}
731
+ onDoubleClick={() => setW(def)}
732
+ onKeyDown={(e) => {
733
+ const step = e.shiftKey ? 24 : 8;
734
+ if (e.key === 'ArrowRight') {
735
+ e.preventDefault();
736
+ setW((v) => v + step * grow);
737
+ } else if (e.key === 'ArrowLeft') {
738
+ e.preventDefault();
739
+ setW((v) => v - step * grow);
740
+ } else if (e.key === 'Home') {
741
+ e.preventDefault();
742
+ setW(min);
743
+ } else if (e.key === 'End') {
744
+ e.preventDefault();
745
+ setW(max);
746
+ }
747
+ }}
748
+ >
749
+ <svg className="st-grip-dots" viewBox="0 0 6 18" aria-hidden="true">
750
+ <circle cx="3" cy="3" r="1.1" fill="currentColor" />
751
+ <circle cx="3" cy="9" r="1.1" fill="currentColor" />
752
+ <circle cx="3" cy="15" r="1.1" fill="currentColor" />
753
+ </svg>
754
+ </div>
755
+ );
756
+ }
757
+
545
758
  // T5 (Plan C) — shell-level Export & Handoff dialog (maude `.st-dialog`), per
546
759
  // `.design/ui/Studio.tsx` HandoffBoard. Wired to the privileged main-origin
547
760
  // `POST /_api/export` (7 real formats × scopes). The shadcn card is HANDOFF —
@@ -817,7 +1030,7 @@ function DirRow({ name, depth, defaultOpen, children }) {
817
1030
  onClick={() => setOpen((v) => !v)}
818
1031
  >
819
1032
  <span className="st-row-glyph">
820
- <StIcon name={open ? 'chevron-down' : 'chevron-right'} size={13} />
1033
+ <StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
821
1034
  </span>
822
1035
  <span className="st-row-name">{name}</span>
823
1036
  </button>
@@ -847,7 +1060,7 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
847
1060
  aria-label={open ? 'Collapse design system' : 'Expand design system'}
848
1061
  title={open ? 'Collapse' : 'Expand'}
849
1062
  >
850
- <StIcon name={open ? 'chevron-down' : 'chevron-right'} size={13} />
1063
+ <StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
851
1064
  </button>
852
1065
  <button
853
1066
  type="button"
@@ -867,7 +1080,7 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
867
1080
  );
868
1081
  }
869
1082
 
870
- function FileRow({ file, activePath, onOpen, onDelete, openCount: oc, depth, kind, sidecar }) {
1083
+ function FileRow({ file, activePath, onOpen, onDelete, openCount: oc, depth, kind, sidecar, dirty }) {
871
1084
  const isSel = file.path === activePath;
872
1085
  const isCanvas = CANVAS_EXT_RE.test(file.name);
873
1086
  // Non-canvas rows (PROJECT *.md, RUNTIME _active.json, ...) are display-only —
@@ -898,6 +1111,11 @@ function FileRow({ file, activePath, onOpen, onDelete, openCount: oc, depth, kin
898
1111
  <StIcon name="file" size={13} />
899
1112
  </span>
900
1113
  <span className="st-row-name">{label}</span>
1114
+ {dirty && (
1115
+ <span className="st-git-badge" data-kind={dirty} title={`Unsaved (${dirty})`} aria-label={`Unsaved, ${dirty}`}>
1116
+ {dirty}
1117
+ </span>
1118
+ )}
901
1119
  {oc > 0 && <span className="st-row-badge">{oc}</span>}
902
1120
  </button>
903
1121
  );
@@ -934,7 +1152,9 @@ function CanvasRow({
934
1152
  openCount: oc,
935
1153
  showHidden,
936
1154
  forceOpen,
1155
+ dirtyByPath,
937
1156
  }) {
1157
+ const dirty = dirtyByPath?.get(primary.path);
938
1158
  const hasSidecars = sidecars.length > 0;
939
1159
  const [openState, setOpenState] = useState(false);
940
1160
  // Sidecars are only revealed when the user opts in via `showHidden` — the
@@ -953,6 +1173,7 @@ function CanvasRow({
953
1173
  openCount={oc}
954
1174
  depth={depth}
955
1175
  kind={kind}
1176
+ dirty={dirty}
956
1177
  />
957
1178
  );
958
1179
  }
@@ -983,9 +1204,14 @@ function CanvasRow({
983
1204
  setOpenState((v) => !v);
984
1205
  }}
985
1206
  >
986
- <StIcon name={open ? 'chevron-down' : 'chevron-right'} size={13} />
1207
+ <StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
987
1208
  </span>
988
1209
  <span className="st-row-name">{displayName(primary.name)}</span>
1210
+ {dirty && (
1211
+ <span className="st-git-badge" data-kind={dirty} title={`Unsaved (${dirty})`} aria-label={`Unsaved, ${dirty}`}>
1212
+ {dirty}
1213
+ </span>
1214
+ )}
989
1215
  {oc > 0 && <span className="st-row-badge">{oc}</span>}
990
1216
  </button>
991
1217
  {open &&
@@ -1018,6 +1244,7 @@ function Tree({
1018
1244
  activeDsName,
1019
1245
  onOpenSystem,
1020
1246
  onDelete,
1247
+ dirtyByPath,
1021
1248
  }) {
1022
1249
  const dirs = Object.keys(node)
1023
1250
  .filter((k) => k !== '_files')
@@ -1059,6 +1286,7 @@ function Tree({
1059
1286
  kind={kind}
1060
1287
  showHidden={showHidden}
1061
1288
  forceOpen={forceOpen}
1289
+ dirtyByPath={dirtyByPath}
1062
1290
  />
1063
1291
  );
1064
1292
  })}
@@ -1090,6 +1318,7 @@ function Tree({
1090
1318
  activeDsName={activeDsName}
1091
1319
  onOpenSystem={onOpenSystem}
1092
1320
  onDelete={onDelete}
1321
+ dirtyByPath={dirtyByPath}
1093
1322
  />
1094
1323
  );
1095
1324
  if (dsMatch && onOpenSystem) {
@@ -1155,6 +1384,11 @@ function Sidebar({
1155
1384
  onDeleteBoard,
1156
1385
  collapsed,
1157
1386
  onCollapse,
1387
+ width,
1388
+ resizing,
1389
+ dirtyByPath,
1390
+ project,
1391
+ gitBranch,
1158
1392
  }) {
1159
1393
  const filteredGroups = useMemo(() => {
1160
1394
  if (!search) return groups;
@@ -1200,14 +1434,19 @@ function Sidebar({
1200
1434
  }, [filteredGroups]);
1201
1435
 
1202
1436
  return (
1203
- <nav className={'st-sidebar' + (collapsed ? ' is-collapsed' : '')} aria-label="Files">
1437
+ <nav
1438
+ className={'st-sidebar' + (collapsed ? ' is-collapsed' : '') + (resizing ? ' is-resizing' : '')}
1439
+ style={collapsed || !width ? undefined : { width, flexBasis: width }}
1440
+ aria-label="Files"
1441
+ data-tour="sidebar"
1442
+ >
1204
1443
  <div className="st-sb-hd">
1205
1444
  <span className="st-sb-title">Files</span>
1206
1445
  <div className="st-sb-hd-actions">
1207
1446
  <button
1208
1447
  type="button"
1209
1448
  className="st-iconbtn"
1210
- title="New blank brief board"
1449
+ data-tip="New blank brief board"
1211
1450
  aria-label="New blank brief board"
1212
1451
  aria-expanded={creating}
1213
1452
  onClick={() => {
@@ -1217,7 +1456,10 @@ function Sidebar({
1217
1456
  >
1218
1457
  <StIcon name="plus" size={15} />
1219
1458
  </button>
1220
- <span className="st-live" title={wsConnected ? 'live · file index synced' : 'reconnecting…'}>
1459
+ <span
1460
+ className="st-live"
1461
+ data-tip={wsConnected ? 'live · file index synced' : 'reconnecting…'}
1462
+ >
1221
1463
  <span className={'st-live-dot' + (wsConnected ? ' is-connected' : '')} aria-hidden="true" />
1222
1464
  {htmlShown} / {htmlCount}
1223
1465
  </span>
@@ -1226,7 +1468,7 @@ function Sidebar({
1226
1468
  type="button"
1227
1469
  className="st-iconbtn"
1228
1470
  aria-label="Collapse sidebar"
1229
- title="Collapse sidebar (T)"
1471
+ data-tip="Collapse sidebar · T"
1230
1472
  onClick={onCollapse}
1231
1473
  >
1232
1474
  <StIcon name="panel-left" size={15} />
@@ -1263,7 +1505,7 @@ function Sidebar({
1263
1505
  type="button"
1264
1506
  className="st-newboard-go"
1265
1507
  disabled={newBusy || !newName.trim()}
1266
- title="Create (Enter)"
1508
+ data-tip="Create · Enter"
1267
1509
  aria-label="Create brief board"
1268
1510
  onClick={submitNewBoard}
1269
1511
  >
@@ -1285,13 +1527,21 @@ function Sidebar({
1285
1527
  placeholder="Search canvases…"
1286
1528
  value={search}
1287
1529
  onChange={(e) => setSearch(e.target.value)}
1530
+ onKeyDown={(e) => {
1531
+ // Esc — clear the filter first; a second Esc leaves the field.
1532
+ if (e.key === 'Escape') {
1533
+ e.preventDefault();
1534
+ if (search) setSearch('');
1535
+ else e.currentTarget.blur();
1536
+ }
1537
+ }}
1288
1538
  aria-label="Filter files"
1289
1539
  />
1290
1540
  {search ? (
1291
1541
  <button
1292
1542
  className="st-search-clear"
1293
1543
  onClick={() => setSearch('')}
1294
- title="Clear (Esc)"
1544
+ data-tip="Clear · Esc"
1295
1545
  aria-label="Clear search"
1296
1546
  >
1297
1547
  ×
@@ -1336,7 +1586,7 @@ function Sidebar({
1336
1586
  aria-expanded={sectionOpen}
1337
1587
  title={sectionOpen ? 'Collapse section' : 'Expand section'}
1338
1588
  >
1339
- <StIcon name={sectionOpen ? 'chevron-down' : 'chevron-right'} size={13} />
1589
+ <StIcon name="chevron-right" className={'st-chev' + (sectionOpen ? ' is-open' : '')} size={13} />
1340
1590
  <span className="st-sec-name">{meta.title}</span>
1341
1591
  {pill && <span className="st-pill">{pill}</span>}
1342
1592
  </button>
@@ -1355,6 +1605,7 @@ function Sidebar({
1355
1605
  activeDsName={activeDsName}
1356
1606
  onOpenSystem={isDs ? onOpenSystem : undefined}
1357
1607
  onDelete={isDs ? undefined : onDeleteBoard}
1608
+ dirtyByPath={dirtyByPath}
1358
1609
  />
1359
1610
  ) : (
1360
1611
  <div className="st-tree-empty">{search ? 'No matches.' : 'Empty.'}</div>
@@ -1363,6 +1614,14 @@ function Sidebar({
1363
1614
  );
1364
1615
  })}
1365
1616
  </div>
1617
+ {/* Phase 29 (E4) — the project + draft switcher: a compact one-line dock that
1618
+ opens UPWARD, sitting directly above the GitHub identity avatar so the two
1619
+ form one bottom dock. Renders nothing until the project is a git repo. */}
1620
+ <RepoBranchSwitcher project={project} liveBranch={gitBranch} />
1621
+ {/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
1622
+ sign in, connected account + New/Pull/Share, sign out. Self-contained
1623
+ (owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
1624
+ <IdentityBar />
1366
1625
  </nav>
1367
1626
  );
1368
1627
  }
@@ -1417,7 +1676,7 @@ function HelpModal({ open, onClose, onStartTour }) {
1417
1676
  <span className="title" id="help-modal-title">
1418
1677
  Help · shortcuts &amp; commands
1419
1678
  </span>
1420
- <span className="sku">MDCC-DEV-SRV / v{MDCC_VERSION}</span>
1679
+ <span className="sku">MAUDE-DEV-SRV / v{MDCC_VERSION}</span>
1421
1680
  {onStartTour && (
1422
1681
  <button
1423
1682
  type="button"
@@ -1506,22 +1765,28 @@ function HelpModal({ open, onClose, onStartTour }) {
1506
1765
  </ul>
1507
1766
  </details>
1508
1767
  <details>
1509
- <summary>Tabs &amp; canvas</summary>
1768
+ <summary>Canvas &amp; panels</summary>
1510
1769
  <ul>
1511
1770
  <li>
1512
- click in tree <span>open tab</span>
1771
+ click in tree <span>open canvas (replaces the active one)</span>
1513
1772
  </li>
1514
1773
  <li>
1515
- <kbd>×</kbd> on tab <span>close tab</span>
1774
+ File Close canvas <span>clear the stage</span>
1516
1775
  </li>
1517
1776
  <li>
1518
- <kbd>⌘R</kbd> <span>reload iframe</span>
1777
+ <kbd>⌘R</kbd> <span>reload canvas</span>
1519
1778
  </li>
1520
1779
  <li>
1521
1780
  <kbd>/</kbd> <span>focus search</span>
1522
1781
  </li>
1523
1782
  <li>
1524
- <kbd>⌘⇧M</kbd> <span>toggle comments panel</span>
1783
+ <kbd>⌘⇧M</kbd> <span>comments panel</span>
1784
+ </li>
1785
+ <li>
1786
+ <kbd>⌘⇧I</kbd> <span>inspector</span>
1787
+ </li>
1788
+ <li>
1789
+ <kbd>?</kbd> <span>keyboard-shortcuts cheat sheet</span>
1525
1790
  </li>
1526
1791
  </ul>
1527
1792
  </details>
@@ -1661,7 +1926,7 @@ function HelpModal({ open, onClose, onStartTour }) {
1661
1926
  <details>
1662
1927
  <summary>Pin-to-element flow</summary>
1663
1928
  <ol>
1664
- <li>Open canvas tab</li>
1929
+ <li>Open a canvas</li>
1665
1930
  <li>
1666
1931
  <kbd>⌘</kbd>+click element
1667
1932
  </li>
@@ -1701,13 +1966,152 @@ function HelpModal({ open, onClose, onStartTour }) {
1701
1966
  );
1702
1967
  }
1703
1968
 
1969
+ // ───────── Keyboard-shortcuts overlay (DS components-shortcuts-overlay) ─────
1970
+ //
1971
+ // The ? cheat-sheet: dim scrim, shared panel material, four dense mono-headed
1972
+ // columns, Esc chip in the footer. REAL bindings only — every row here is
1973
+ // wired in the shell handler, the canvas input-router, or canvas-lib's
1974
+ // viewport controller. Scope chips mark the rows that need canvas focus.
1975
+
1976
+ const SHORTCUT_GROUPS = [
1977
+ {
1978
+ id: 'canvas',
1979
+ label: 'Canvas',
1980
+ items: [
1981
+ { label: 'Command palette', kbd: '⌘ K' },
1982
+ { label: 'New brief board', kbd: 'N' },
1983
+ { label: 'Export…', kbd: '⇧ ⌘ E' },
1984
+ { label: 'Handoff to production', kbd: '⇧ ⌘ H' },
1985
+ { label: 'Reload canvas', kbd: '⌘ R' },
1986
+ { label: 'Search files', kbd: '/', alt: '⌘ F' },
1987
+ ],
1988
+ },
1989
+ {
1990
+ id: 'tools',
1991
+ label: 'Tools · canvas focus',
1992
+ items: [
1993
+ { label: 'Move · Hand · Comment', kbd: 'V', alt: 'H / C' },
1994
+ { label: 'Pen · Highlighter · Eraser', kbd: 'B', alt: 'I / E' },
1995
+ { label: 'Shape · Arrow', kbd: 'R', alt: 'A' },
1996
+ { label: 'Sticky · Text · Section', kbd: 'N', alt: 'T / ⇧S' },
1997
+ { label: 'Undo / redo', kbd: '⌘ Z', alt: '⇧ ⌘ Z' },
1998
+ ],
1999
+ },
2000
+ {
2001
+ id: 'selection',
2002
+ label: 'Selection & zoom',
2003
+ items: [
2004
+ { label: 'Select element', kbd: '⌘ click' },
2005
+ { label: 'Add to selection', kbd: '⌘ ⇧ click' },
2006
+ { label: 'Preview deepest', kbd: '⌘ hover' },
2007
+ { label: 'Deselect · close menu', kbd: 'Esc' },
2008
+ { label: 'Zoom in / out', kbd: '⌘ +', alt: '⌘ −' },
2009
+ { label: 'Fit · actual size', kbd: '⌘ 0', alt: '⌘ 1' },
2010
+ ],
2011
+ },
2012
+ {
2013
+ id: 'view',
2014
+ label: 'View',
2015
+ items: [
2016
+ { label: 'Project tree', kbd: 'T' },
2017
+ { label: 'Design system view', kbd: 'S' },
2018
+ { label: 'Inspector', kbd: '⌘ ⇧ I' },
2019
+ { label: 'Comments sidebar', kbd: '⌘ ⇧ M' },
2020
+ { label: 'Annotations', kbd: '⇧ P' },
2021
+ { label: 'Hidden files', kbd: 'H' },
2022
+ { label: 'This cheat sheet · help', kbd: '?', alt: 'F1' },
2023
+ ],
2024
+ },
2025
+ ];
2026
+
2027
+ function ShortcutCombo({ kbd, alt }) {
2028
+ const combo = (s, key) => (
2029
+ <span className="so-combo" key={key}>
2030
+ {s.split(' ').map((k, i) => (
2031
+ <Kbd key={`${k}-${i}`}>{k}</Kbd>
2032
+ ))}
2033
+ </span>
2034
+ );
2035
+ return (
2036
+ <span className="so-combos">
2037
+ {combo(kbd, 'main')}
2038
+ {alt
2039
+ ? alt.split(' / ').map((a) => (
2040
+ <Fragment key={a}>
2041
+ <span className="so-or">/</span>
2042
+ {combo(a, a)}
2043
+ </Fragment>
2044
+ ))
2045
+ : null}
2046
+ </span>
2047
+ );
2048
+ }
2049
+
2050
+ function ShortcutsOverlay({ open, onClose }) {
2051
+ useEffect(() => {
2052
+ if (!open) return;
2053
+ function onKey(e) {
2054
+ if (e.key === 'Escape') {
2055
+ e.preventDefault();
2056
+ onClose();
2057
+ }
2058
+ }
2059
+ window.addEventListener('keydown', onKey);
2060
+ return () => window.removeEventListener('keydown', onKey);
2061
+ }, [open, onClose]);
2062
+ if (!open) return null;
2063
+ const bindings = SHORTCUT_GROUPS.reduce((n, g) => n + g.items.length, 0);
2064
+ return (
2065
+ <div
2066
+ className="st-scrim"
2067
+ role="presentation"
2068
+ onMouseDown={(e) => {
2069
+ if (e.target === e.currentTarget) onClose();
2070
+ }}
2071
+ >
2072
+ <div className="so-overlay" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts">
2073
+ <div className="so-overlay-hd">
2074
+ <span className="so-title">Keyboard shortcuts</span>
2075
+ <span className="so-trigger">
2076
+ press <Kbd>?</Kbd> to open
2077
+ </span>
2078
+ </div>
2079
+ <div className="so-columns">
2080
+ {SHORTCUT_GROUPS.map((g) => (
2081
+ <section key={g.id} className={'so-section so-section--' + g.id}>
2082
+ <h3 className="so-section-hd">{g.label}</h3>
2083
+ <dl className="so-list">
2084
+ {g.items.map((it) => (
2085
+ <div key={it.label} className="so-pair">
2086
+ <dt>{it.label}</dt>
2087
+ <dd>
2088
+ <ShortcutCombo kbd={it.kbd} alt={it.alt} />
2089
+ </dd>
2090
+ </div>
2091
+ ))}
2092
+ </dl>
2093
+ </section>
2094
+ ))}
2095
+ </div>
2096
+ <div className="so-overlay-ft">
2097
+ <span>
2098
+ close with <Kbd>Esc</Kbd>
2099
+ </span>
2100
+ <span className="so-count">
2101
+ {bindings} bindings · {SHORTCUT_GROUPS.length} groups
2102
+ </span>
2103
+ </div>
2104
+ </div>
2105
+ </div>
2106
+ );
2107
+ }
2108
+
1704
2109
  // ───────── Menubar (CV-01/CV-08 top chrome) ─────────
1705
2110
  //
1706
2111
  // Replaces the legacy `.header` action-button toolbar. Mirrors the shared
1707
2112
  // Menubar component from .design/ui/Canvas Viewport.html — brand · menus ·
1708
- // status. View dropdown is wired to the only toggleable panel today (the
1709
- // Comments sidebar); the rest is inert with a phase-tag explaining when it
1710
- // lands.
2113
+ // status. View dropdown is wired to the panels + zoom that exist today;
2114
+ // Presentation Mode stays phase-tagged until it ships.
1711
2115
 
1712
2116
  const MENU_NAMES = ['File', 'Edit', 'View', 'Selection', 'Tools', 'Help'];
1713
2117
 
@@ -1729,7 +2133,7 @@ function useDropdownClose(onClose) {
1729
2133
  }, [onClose]);
1730
2134
  }
1731
2135
 
1732
- function ViewDropdown({ panels, onToggle, onClose }) {
2136
+ function ViewDropdown({ panels, onToggle, onClose, onZoom, hasCanvas }) {
1733
2137
  useDropdownClose(onClose);
1734
2138
  return (
1735
2139
  <div className="st-dropdown" role="menu" aria-label="View" style={{ left: 152 }}>
@@ -1752,117 +2156,55 @@ function ViewDropdown({ panels, onToggle, onClose }) {
1752
2156
  <span className="st-dd-check">{p.checked ? <StIcon name="check" size={13} /> : null}</span>
1753
2157
  <span>{p.label}</span>
1754
2158
  </span>
1755
- {p.phase ? <span className="st-dd-phase">{p.phase}</span> : <Kbd>{p.shortcut || ''}</Kbd>}
2159
+ {p.phase ? (
2160
+ <span className="st-dd-phase">{p.phase}</span>
2161
+ ) : p.shortcut ? (
2162
+ <Kbd>{p.shortcut}</Kbd>
2163
+ ) : null}
1756
2164
  </button>
1757
2165
  ))}
1758
2166
  <div className="st-dd-sep" />
1759
2167
  <div className="st-dd-hd">Zoom</div>
1760
2168
  {[
1761
- { label: 'Zoom In', shortcut: '⌘ +' },
1762
- { label: 'Zoom Out', shortcut: '⌘ −' },
1763
- { label: 'Fit to Screen', shortcut: '⌘ 0' },
1764
- { label: 'Actual Size · 100 %', shortcut: '0' },
2169
+ { op: 'in', label: 'Zoom In', shortcut: '⌘ +' },
2170
+ { op: 'out', label: 'Zoom Out', shortcut: '⌘ −' },
2171
+ { op: 'fit', label: 'Fit to Screen', shortcut: '⌘ 0' },
2172
+ { op: 'actual', label: 'Actual Size · 100 %', shortcut: '⌘ 1' },
1765
2173
  ].map((z) => (
1766
- <button key={z.label} type="button" role="menuitem" className="st-dd-item" aria-disabled="true">
1767
- <span className="st-dd-lead">
1768
- <span className="st-dd-check" />
1769
- <span>{z.label}</span>
1770
- </span>
1771
- <span className="st-dd-phase">Phase 4</span>
1772
- </button>
1773
- ))}
1774
- </div>
1775
- );
1776
- }
1777
-
1778
- // ─────────────────────────────────────────────────────────────────────────────
1779
- // Phase 5.1 — Selection + Tools dropdowns (mirror ViewDropdown shape).
1780
-
1781
- function SelectionDropdown({ onAction, onClose }) {
1782
- useDropdownClose(onClose);
1783
- const items = [
1784
- { id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
1785
- { id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⌘ ⇧ A' },
1786
- ];
1787
- return (
1788
- <div className="st-dropdown" role="menu" aria-label="Selection" style={{ left: 214 }}>
1789
- {items.map((it) => (
1790
- <button
1791
- key={it.id}
1792
- type="button"
1793
- role="menuitem"
1794
- className="st-dd-item"
1795
- onClick={() => {
1796
- onAction(it.id);
1797
- onClose();
1798
- }}
1799
- >
1800
- <span className="st-dd-lead">
1801
- <span className="st-dd-check" />
1802
- <span>{it.label}</span>
1803
- </span>
1804
- <Kbd>{it.shortcut}</Kbd>
1805
- </button>
1806
- ))}
1807
- </div>
1808
- );
1809
- }
1810
-
1811
- function ToolsDropdown({ onAction, onClose }) {
1812
- useDropdownClose(onClose);
1813
- // Mirrors DEFAULT_TOOLS in apps/studio/use-tool-mode.tsx —
1814
- // kept in sync by hand because the menubar lives in the dev-server shell
1815
- // (no shared bundle with the canvas iframes).
1816
- const tools = [
1817
- { id: 'move', label: 'Move', shortcut: 'V' },
1818
- { id: 'hand', label: 'Hand', shortcut: 'H' },
1819
- { id: 'comment', label: 'Comment', shortcut: 'C' },
1820
- { id: 'pen', label: 'Pen', shortcut: 'B' },
1821
- { id: 'rect', label: 'Rect', shortcut: 'R' },
1822
- { id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
1823
- { id: 'sticky', label: 'Sticky', shortcut: 'N' },
1824
- { id: 'arrow', label: 'Arrow', shortcut: 'A' },
1825
- { id: 'text', label: 'Text', shortcut: 'T' },
1826
- { id: 'eraser', label: 'Eraser', shortcut: 'E' },
1827
- ];
1828
- return (
1829
- <div className="st-dropdown" role="menu" aria-label="Tools" style={{ left: 290 }}>
1830
- <div className="st-dd-hd">Tool palette</div>
1831
- {tools.map((t) => (
1832
2174
  <button
1833
- key={t.id}
2175
+ key={z.label}
1834
2176
  type="button"
1835
2177
  role="menuitem"
1836
2178
  className="st-dd-item"
2179
+ aria-disabled={hasCanvas ? undefined : 'true'}
1837
2180
  onClick={() => {
1838
- onAction(t.id);
2181
+ if (!hasCanvas) return;
2182
+ onZoom?.(z.op);
1839
2183
  onClose();
1840
2184
  }}
1841
2185
  >
1842
2186
  <span className="st-dd-lead">
1843
2187
  <span className="st-dd-check" />
1844
- <span>{t.label}</span>
2188
+ <span>{z.label}</span>
1845
2189
  </span>
1846
- <Kbd>{t.shortcut}</Kbd>
2190
+ <Kbd>{z.shortcut}</Kbd>
1847
2191
  </button>
1848
2192
  ))}
1849
2193
  </div>
1850
2194
  );
1851
2195
  }
1852
2196
 
1853
- // Plan C follow-up File + Edit menus, previously inert. Both dispatch to real
1854
- // shell flows (File) or the in-canvas undo stack / selection bridges (Edit).
1855
- function FileDropdown({ onAction, onClose }) {
2197
+ // Help dropdowncheat sheet · deep help · tour · what's new.
2198
+ // Shared menubar dropdown — File / Edit / Selection / Tools / Help all render
2199
+ // the identical {id,label,shortcut,sep?,disabled?} list over the same button
2200
+ // skeleton, differing only in aria-label, left offset, and an optional header.
2201
+ // (ViewDropdown stays separate — its checkbox/phase/zoom-op rows genuinely
2202
+ // diverge.) Per the /flow:done simplifier pass — collapsed 5 near-dupes.
2203
+ function DropdownMenu({ label, left, header, items, onAction, onClose }) {
1856
2204
  useDropdownClose(onClose);
1857
- const items = [
1858
- { id: 'new', label: 'New canvas…', shortcut: '⌘N' },
1859
- { id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
1860
- { id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
1861
- { sep: true },
1862
- { id: 'reload', label: 'Reload canvas', shortcut: '⌘R' },
1863
- ];
1864
2205
  return (
1865
- <div className="st-dropdown" role="menu" aria-label="File" style={{ left: 40 }}>
2206
+ <div className="st-dropdown" role="menu" aria-label={label} style={{ left }}>
2207
+ {header ? <div className="st-dd-hd">{header}</div> : null}
1866
2208
  {items.map((it, i) =>
1867
2209
  it.sep ? (
1868
2210
  <div key={'s' + i} className="st-dd-sep" />
@@ -1872,7 +2214,9 @@ function FileDropdown({ onAction, onClose }) {
1872
2214
  type="button"
1873
2215
  role="menuitem"
1874
2216
  className="st-dd-item"
2217
+ aria-disabled={it.disabled ? 'true' : undefined}
1875
2218
  onClick={() => {
2219
+ if (it.disabled) return;
1876
2220
  onAction(it.id);
1877
2221
  onClose();
1878
2222
  }}
@@ -1881,7 +2225,7 @@ function FileDropdown({ onAction, onClose }) {
1881
2225
  <span className="st-dd-check" />
1882
2226
  <span>{it.label}</span>
1883
2227
  </span>
1884
- <Kbd>{it.shortcut}</Kbd>
2228
+ {it.shortcut ? <Kbd>{it.shortcut}</Kbd> : null}
1885
2229
  </button>
1886
2230
  )
1887
2231
  )}
@@ -1889,40 +2233,107 @@ function FileDropdown({ onAction, onClose }) {
1889
2233
  );
1890
2234
  }
1891
2235
 
2236
+ function HelpDropdown({ onAction, onClose }) {
2237
+ return (
2238
+ <DropdownMenu
2239
+ label="Help"
2240
+ left={320}
2241
+ onAction={onAction}
2242
+ onClose={onClose}
2243
+ items={[
2244
+ { id: 'shortcuts', label: 'Keyboard shortcuts', shortcut: '?' },
2245
+ { id: 'help', label: 'Help · commands & flows', shortcut: 'F1' },
2246
+ { sep: true },
2247
+ { id: 'tour', label: 'Take the tour' },
2248
+ // The collab "how sharing works" course teaches the plain-words Save →
2249
+ // Publish → Pull cycle — a non-technical, native-app concern. A web-studio
2250
+ // dev already knows git, so it's hidden there (DDR-119).
2251
+ ...(isNativeApp() ? [{ id: 'collab-tour', label: 'How sharing works' }] : []),
2252
+ { id: 'whatsnew', label: "What's new" },
2253
+ ]}
2254
+ />
2255
+ );
2256
+ }
2257
+
2258
+ function SelectionDropdown({ onAction, onClose }) {
2259
+ return (
2260
+ <DropdownMenu
2261
+ label="Selection"
2262
+ left={214}
2263
+ onAction={onAction}
2264
+ onClose={onClose}
2265
+ items={[
2266
+ { id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
2267
+ { id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⌘ ⇧ A' },
2268
+ ]}
2269
+ />
2270
+ );
2271
+ }
2272
+
2273
+ function ToolsDropdown({ onAction, onClose }) {
2274
+ // Mirrors DEFAULT_TOOLS in apps/studio/use-tool-mode.tsx — kept in sync by
2275
+ // hand because the menubar lives in the dev-server shell (no shared bundle
2276
+ // with the canvas iframes).
2277
+ return (
2278
+ <DropdownMenu
2279
+ label="Tools"
2280
+ left={290}
2281
+ header="Tool palette"
2282
+ onAction={onAction}
2283
+ onClose={onClose}
2284
+ items={[
2285
+ { id: 'move', label: 'Move', shortcut: 'V' },
2286
+ { id: 'hand', label: 'Hand', shortcut: 'H' },
2287
+ { id: 'comment', label: 'Comment', shortcut: 'C' },
2288
+ { id: 'pen', label: 'Pen', shortcut: 'B' },
2289
+ { id: 'rect', label: 'Rect', shortcut: 'R' },
2290
+ { id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
2291
+ { id: 'sticky', label: 'Sticky', shortcut: 'N' },
2292
+ { id: 'arrow', label: 'Arrow', shortcut: 'A' },
2293
+ { id: 'text', label: 'Text', shortcut: 'T' },
2294
+ { id: 'eraser', label: 'Eraser', shortcut: 'E' },
2295
+ ]}
2296
+ />
2297
+ );
2298
+ }
2299
+
2300
+ // Plan C follow-up — File + Edit menus, previously inert. Both dispatch to real
2301
+ // shell flows (File) or the in-canvas undo stack / selection bridges (Edit).
2302
+ function FileDropdown({ onAction, onClose, hasCanvas }) {
2303
+ return (
2304
+ <DropdownMenu
2305
+ label="File"
2306
+ left={40}
2307
+ onAction={onAction}
2308
+ onClose={onClose}
2309
+ items={[
2310
+ // Bare N — the browser reserves ⌘N (New Window) and never delivers it.
2311
+ { id: 'new', label: 'New canvas…', shortcut: 'N' },
2312
+ { id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
2313
+ { id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
2314
+ { sep: true },
2315
+ { id: 'reload', label: 'Reload canvas', shortcut: '⌘R', disabled: !hasCanvas },
2316
+ { id: 'close', label: 'Close canvas', disabled: !hasCanvas },
2317
+ ]}
2318
+ />
2319
+ );
2320
+ }
2321
+
1892
2322
  function EditDropdown({ onAction, onClose }) {
1893
- useDropdownClose(onClose);
1894
- const items = [
1895
- { id: 'undo', label: 'Undo', shortcut: '⌘Z' },
1896
- { id: 'redo', label: 'Redo', shortcut: '⇧⌘Z' },
1897
- { sep: true },
1898
- { id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
1899
- { id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⇧⌘A' },
1900
- ];
1901
2323
  return (
1902
- <div className="st-dropdown" role="menu" aria-label="Edit" style={{ left: 90 }}>
1903
- {items.map((it, i) =>
1904
- it.sep ? (
1905
- <div key={'s' + i} className="st-dd-sep" />
1906
- ) : (
1907
- <button
1908
- key={it.id}
1909
- type="button"
1910
- role="menuitem"
1911
- className="st-dd-item"
1912
- onClick={() => {
1913
- onAction(it.id);
1914
- onClose();
1915
- }}
1916
- >
1917
- <span className="st-dd-lead">
1918
- <span className="st-dd-check" />
1919
- <span>{it.label}</span>
1920
- </span>
1921
- <Kbd>{it.shortcut}</Kbd>
1922
- </button>
1923
- )
1924
- )}
1925
- </div>
2324
+ <DropdownMenu
2325
+ label="Edit"
2326
+ left={90}
2327
+ onAction={onAction}
2328
+ onClose={onClose}
2329
+ items={[
2330
+ { id: 'undo', label: 'Undo', shortcut: '⌘Z' },
2331
+ { id: 'redo', label: 'Redo', shortcut: '⇧⌘Z' },
2332
+ { sep: true },
2333
+ { id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
2334
+ { id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⇧⌘A' },
2335
+ ]}
2336
+ />
1926
2337
  );
1927
2338
  }
1928
2339
 
@@ -1934,24 +2345,43 @@ function Menubar({
1934
2345
  setOpenMenu,
1935
2346
  commentsPanelOpen,
1936
2347
  onToggleComments,
2348
+ changesOpen,
2349
+ changesCount,
2350
+ onToggleChanges,
1937
2351
  onOpenSystem,
1938
2352
  sidebarOpen,
1939
2353
  onToggleSidebar,
1940
2354
  showHidden,
1941
2355
  onToggleShowHidden,
1942
2356
  onOpenHelp,
2357
+ onOpenShortcuts,
2358
+ onStartTour,
2359
+ onStartCollabTour,
1943
2360
  annotationsVisible,
1944
2361
  onToggleAnnotations,
2362
+ minimapVisible,
2363
+ onToggleMinimap,
2364
+ zoomCtlVisible,
2365
+ onToggleZoomCtl,
2366
+ presentMode,
2367
+ onTogglePresent,
1945
2368
  postToActiveCanvas,
1946
2369
  onOpenWhatsNew,
1947
2370
  whatsNewCount,
1948
2371
  artboardCount = 0,
1949
2372
  presence = null,
1950
2373
  inspectorOpen,
2374
+ inspectorTab,
1951
2375
  onToggleInspector,
2376
+ onOpenLayers,
2377
+ assistantOpen,
2378
+ onToggleAssistant,
2379
+ assistantBusy,
2380
+ assistantUnseen,
1952
2381
  onNewCanvas,
1953
2382
  onOpenExport,
1954
2383
  onReload,
2384
+ onCloseCanvas,
1955
2385
  }) {
1956
2386
  const isSystem = activePath === SYSTEM_TAB;
1957
2387
  const stamp = isSystem ? 'SYSTEM' : activePath ? 'CANVAS' : 'IDLE';
@@ -1967,6 +2397,13 @@ function Menubar({
1967
2397
 
1968
2398
  const panels = [
1969
2399
  { id: 'tree', label: 'Project Tree', shortcut: 'T', checked: sidebarOpen, disabled: false },
2400
+ {
2401
+ id: 'changes',
2402
+ label: changesCount > 0 ? `Changes · ${changesCount} unsaved` : 'Changes',
2403
+ shortcut: '⌘ ⇧ G',
2404
+ checked: changesOpen,
2405
+ disabled: false,
2406
+ },
1970
2407
  {
1971
2408
  id: 'comments',
1972
2409
  label: 'Comments Sidebar',
@@ -1981,8 +2418,32 @@ function Menubar({
1981
2418
  checked: showHidden,
1982
2419
  disabled: false,
1983
2420
  },
1984
- { id: 'layers', label: 'Layers Panel', phase: 'Phase 12', disabled: true },
1985
- { id: 'inspector', label: 'Inspector', shortcut: 'I', checked: inspectorOpen, disabled: false },
2421
+ {
2422
+ id: 'layers',
2423
+ label: 'Layers',
2424
+ shortcut: '',
2425
+ checked: inspectorOpen && inspectorTab === 'layers',
2426
+ disabled: false,
2427
+ },
2428
+ {
2429
+ id: 'inspector',
2430
+ label: 'Inspector',
2431
+ shortcut: '⌘ ⇧ I',
2432
+ checked: inspectorOpen,
2433
+ disabled: false,
2434
+ },
2435
+ // Phase 31 (DDR-123) — native-only ACP chat sidepanel.
2436
+ ...(isNativeApp()
2437
+ ? [
2438
+ {
2439
+ id: 'assistant',
2440
+ label: 'Assistant',
2441
+ shortcut: '⌘ ⇧ A',
2442
+ checked: assistantOpen,
2443
+ disabled: false,
2444
+ },
2445
+ ]
2446
+ : []),
1986
2447
  {
1987
2448
  id: 'annotate',
1988
2449
  label: 'Annotations',
@@ -1990,28 +2451,90 @@ function Menubar({
1990
2451
  checked: annotationsVisible,
1991
2452
  disabled: false,
1992
2453
  },
1993
- { id: 'present', label: 'Presentation Mode', phase: 'Phase 6', disabled: true },
2454
+ {
2455
+ id: 'minimap',
2456
+ label: 'Minimap',
2457
+ shortcut: '',
2458
+ checked: minimapVisible,
2459
+ disabled: !activePath || isSystem,
2460
+ },
2461
+ {
2462
+ id: 'zoomctl',
2463
+ label: 'Zoom controls',
2464
+ shortcut: '',
2465
+ checked: zoomCtlVisible,
2466
+ disabled: !activePath || isSystem,
2467
+ },
2468
+ {
2469
+ id: 'present',
2470
+ label: 'Presentation Mode',
2471
+ shortcut: '',
2472
+ checked: presentMode,
2473
+ disabled: !activePath || isSystem,
2474
+ },
1994
2475
  ];
1995
2476
 
1996
- const DROPDOWN_MENUS = ['file', 'edit', 'view', 'selection', 'tools'];
2477
+ const DROPDOWN_MENUS = ['file', 'edit', 'view', 'selection', 'tools', 'help'];
1997
2478
  function onMenuClick(key) {
1998
2479
  if (DROPDOWN_MENUS.includes(key)) {
1999
2480
  setOpenMenu(openMenu === key ? null : key);
2000
- } else if (key === 'help') {
2001
- setOpenMenu(null);
2002
- onOpenHelp();
2003
2481
  }
2004
2482
  }
2005
2483
 
2484
+ // Keyboard menubar (native-menu parity): while a dropdown is open, ↑/↓ rove
2485
+ // its items, ←/→ switch to the adjacent menu, Home/End jump, Esc returns
2486
+ // focus to the trigger (useDropdownClose handles the close itself).
2487
+ useEffect(() => {
2488
+ if (!openMenu || !DROPDOWN_MENUS.includes(openMenu)) return;
2489
+ // Move focus into the menu so ↑/↓ work immediately after a click.
2490
+ const t = setTimeout(() => {
2491
+ document
2492
+ .querySelector('.st-dropdown [role="menuitem"]:not([aria-disabled="true"])')
2493
+ ?.focus();
2494
+ }, 0);
2495
+ function onKey(e) {
2496
+ const items = [
2497
+ ...document.querySelectorAll('.st-dropdown [role="menuitem"]:not([aria-disabled="true"])'),
2498
+ ];
2499
+ if (!items.length) return;
2500
+ const idx = items.indexOf(document.activeElement);
2501
+ if (e.key === 'ArrowDown') {
2502
+ e.preventDefault();
2503
+ items[(idx + 1) % items.length].focus();
2504
+ } else if (e.key === 'ArrowUp') {
2505
+ e.preventDefault();
2506
+ items[(idx - 1 + items.length) % items.length].focus();
2507
+ } else if (e.key === 'Home') {
2508
+ e.preventDefault();
2509
+ items[0].focus();
2510
+ } else if (e.key === 'End') {
2511
+ e.preventDefault();
2512
+ items[items.length - 1].focus();
2513
+ } else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
2514
+ e.preventDefault();
2515
+ const dir = e.key === 'ArrowRight' ? 1 : -1;
2516
+ const cur = DROPDOWN_MENUS.indexOf(openMenu);
2517
+ setOpenMenu(DROPDOWN_MENUS[(cur + dir + DROPDOWN_MENUS.length) % DROPDOWN_MENUS.length]);
2518
+ } else if (e.key === 'Escape') {
2519
+ document.querySelector('.st-menu[aria-expanded="true"]')?.focus();
2520
+ }
2521
+ }
2522
+ window.addEventListener('keydown', onKey);
2523
+ return () => {
2524
+ clearTimeout(t);
2525
+ window.removeEventListener('keydown', onKey);
2526
+ };
2527
+ }, [openMenu, setOpenMenu]);
2528
+
2006
2529
  return (
2007
2530
  <header className="st-menubar" role="menubar" aria-label="Application menubar">
2008
- <span className="st-brand">
2531
+ <span className="st-brand" data-tour="brand">
2009
2532
  <span className="st-brand-mark">
2010
2533
  <svg viewBox="0 0 32 32" width="100%" height="100%" fill="none" aria-hidden="true"><path d="M16 5l2.8 8.2L27 16l-8.2 2.8L16 27l-2.8-8.2L5 16l8.2-2.8z" fill="currentColor" /></svg>
2011
2534
  </span>
2012
2535
  <span className="st-brand-name">maude</span>
2013
2536
  </span>
2014
- <nav className="st-menus" aria-label="Application menus">
2537
+ <nav className="st-menus" aria-label="Application menus" data-tour="menus">
2015
2538
  {MENU_NAMES.map((name) => {
2016
2539
  const key = name.toLowerCase();
2017
2540
  const hasDropdown = DROPDOWN_MENUS.includes(key);
@@ -2040,11 +2563,13 @@ function Menubar({
2040
2563
  </nav>
2041
2564
  {openMenu === 'file' && (
2042
2565
  <FileDropdown
2566
+ hasCanvas={!!activePath}
2043
2567
  onAction={(id) => {
2044
2568
  if (id === 'new') onNewCanvas?.();
2045
2569
  else if (id === 'export') onOpenExport?.('export');
2046
2570
  else if (id === 'handoff') onOpenExport?.('handoff');
2047
2571
  else if (id === 'reload') onReload?.();
2572
+ else if (id === 'close') onCloseCanvas?.();
2048
2573
  }}
2049
2574
  onClose={() => setOpenMenu(null)}
2050
2575
  />
@@ -2066,11 +2591,19 @@ function Menubar({
2066
2591
  panels={panels}
2067
2592
  onToggle={(id) => {
2068
2593
  if (id === 'tree') onToggleSidebar();
2594
+ else if (id === 'changes') onToggleChanges();
2069
2595
  else if (id === 'comments') onToggleComments();
2070
2596
  else if (id === 'hidden') onToggleShowHidden();
2071
2597
  else if (id === 'annotate') onToggleAnnotations();
2072
2598
  else if (id === 'inspector') onToggleInspector();
2599
+ else if (id === 'assistant') onToggleAssistant?.();
2600
+ else if (id === 'layers') onOpenLayers?.();
2601
+ else if (id === 'minimap') onToggleMinimap?.();
2602
+ else if (id === 'zoomctl') onToggleZoomCtl?.();
2603
+ else if (id === 'present') onTogglePresent?.();
2073
2604
  }}
2605
+ onZoom={(op) => postToActiveCanvas({ dgn: 'zoom', op })}
2606
+ hasCanvas={!!activePath && !isSystem}
2074
2607
  onClose={() => setOpenMenu(null)}
2075
2608
  />
2076
2609
  )}
@@ -2090,24 +2623,51 @@ function Menubar({
2090
2623
  onClose={() => setOpenMenu(null)}
2091
2624
  />
2092
2625
  )}
2093
- <div className="st-mb-right">
2626
+ {openMenu === 'help' && (
2627
+ <HelpDropdown
2628
+ onAction={(id) => {
2629
+ if (id === 'shortcuts') onOpenShortcuts?.();
2630
+ else if (id === 'help') onOpenHelp?.();
2631
+ else if (id === 'tour') onStartTour?.();
2632
+ else if (id === 'collab-tour') onStartCollabTour?.();
2633
+ else if (id === 'whatsnew') onOpenWhatsNew?.();
2634
+ }}
2635
+ onClose={() => setOpenMenu(null)}
2636
+ />
2637
+ )}
2638
+ <div className="st-mb-right" data-tour="status">
2094
2639
  {presence ? <div className="st-presence">{presence}</div> : null}
2095
- <button
2096
- type="button"
2640
+ {isNativeApp() && (
2641
+ <button
2642
+ type="button"
2643
+ className="st-assistant"
2644
+ data-active={assistantOpen ? 'true' : 'false'}
2645
+ data-busy={assistantBusy ? 'true' : 'false'}
2646
+ data-unseen={assistantUnseen ? 'true' : 'false'}
2647
+ aria-label={`Assistant${assistantBusy ? ' — working' : assistantUnseen ? ' — new reply' : ''}`}
2648
+ data-tip="Assistant ⌘⇧A"
2649
+ onClick={onToggleAssistant}
2650
+ >
2651
+ <StIcon name="sparkle" size={15} />
2652
+ </button>
2653
+ )}
2654
+ <button
2655
+ type="button"
2097
2656
  className="st-whatsnew"
2657
+ data-tour="whatsnew"
2098
2658
  data-unseen={whatsNewCount > 0 ? 'true' : 'false'}
2099
2659
  aria-label={`What's new${whatsNewCount > 0 ? ` — ${whatsNewCount} unseen` : ''}`}
2100
- title="What's new"
2660
+ data-tip="What's new"
2101
2661
  onClick={onOpenWhatsNew}
2102
2662
  >
2103
- <StIcon name="sparkle" size={15} />
2663
+ <StIcon name="megaphone" size={15} />
2104
2664
  </button>
2105
2665
  <span className="st-stamp">{stamp}</span>
2106
2666
  <span className="st-mb-file" title={activePath || ''}>
2107
2667
  {fileLabel}
2108
2668
  </span>
2109
2669
  <span className="st-mb-sep" />
2110
- <span className="st-mb-count" title="Artboards in the open canvas">
2670
+ <span className="st-mb-count" data-tip="Artboards in the open canvas">
2111
2671
  <span className="st-dot" style={{ background: 'var(--accent)' }} />
2112
2672
  {artboardCount} ARTBOARDS
2113
2673
  </span>
@@ -2127,9 +2687,11 @@ function Viewport({
2127
2687
  onSelectDs,
2128
2688
  project,
2129
2689
  cfg,
2690
+ loadingPath,
2691
+ onIframeLoad,
2130
2692
  }) {
2131
2693
  return (
2132
- <div className="viewport st-stage">
2694
+ <div className="viewport st-stage" data-tour="viewport">
2133
2695
  {tabs.length === 0 && (
2134
2696
  <div className="st-empty">
2135
2697
  <div className="st-empty-brand">
@@ -2148,8 +2710,8 @@ function Viewport({
2148
2710
  <strong>Design system</strong> view above it.
2149
2711
  <br />
2150
2712
  <br />
2151
- Tabs work like in an editor — close with the × on each tab. <Kbd>⌘R</Kbd> reloads the
2152
- active iframe.
2713
+ Opening a file replaces the active canvas. <Kbd>⌘R</Kbd> reloads it; File ▸ Close
2714
+ canvas clears the stage.
2153
2715
  <br />
2154
2716
  <br />
2155
2717
  <strong>Element selection:</strong> hold <Kbd>⌘</Kbd> inside the canvas and hover for a
@@ -2182,6 +2744,7 @@ function Viewport({
2182
2744
  src={canvasUrl(t.path, cfg)}
2183
2745
  className={t.path === activePath ? 'active' : ''}
2184
2746
  data-path={t.path}
2747
+ onLoad={() => onIframeLoad?.(t.path)}
2185
2748
  // T2 (9.1-A) — only sandbox + delegate clipboard when the canvas is
2186
2749
  // served cross-origin (canvasOrigin present = the split is on). In
2187
2750
  // the default same-origin mode these attrs are omitted so behavior
@@ -2193,6 +2756,19 @@ function Viewport({
2193
2756
  />
2194
2757
  );
2195
2758
  })}
2759
+ {loadingPath && loadingPath === activePath && (
2760
+ // DS skeletons recipe — calm .skel pulse while the canvas-shell compiles
2761
+ // the TSX. Cleared by the iframe's dgn:'loaded' message (or the onLoad
2762
+ // fallback timer for legacy .html canvases that never post it).
2763
+ <div className="st-canvas-loading" aria-hidden="true">
2764
+ <div className="st-skel-card">
2765
+ <div className="st-skel-cap st-mono">compiling canvas…</div>
2766
+ <span className="skel st-skel-thumb" />
2767
+ <span className="skel st-skel-line" style={{ width: '72%' }} />
2768
+ <span className="skel st-skel-line" style={{ width: '46%' }} />
2769
+ </div>
2770
+ </div>
2771
+ )}
2196
2772
  </div>
2197
2773
  );
2198
2774
  }
@@ -2372,7 +2948,7 @@ function SystemView({ data, onOpen, cfg, onSelectDs }) {
2372
2948
  return (
2373
2949
  <div className="sv">
2374
2950
  <header className="sv-header">
2375
- <span className="sv-sku">MDCC-DSN/01</span>
2951
+ <span className="sv-sku">MAUDE-DSN/01</span>
2376
2952
  <span className="sv-title">design system view</span>
2377
2953
  {hasPicker ? (
2378
2954
  <label className="sv-ds-picker">
@@ -2459,6 +3035,10 @@ function StatusBar({
2459
3035
  onToggleTheme,
2460
3036
  onClearSelected,
2461
3037
  syncStatus,
3038
+ changesCount = 0,
3039
+ unpushed = 0,
3040
+ changesOpen = false,
3041
+ onOpenChanges,
2462
3042
  }) {
2463
3043
  const isSystem = activePath === SYSTEM_TAB;
2464
3044
  const text =
@@ -2516,7 +3096,9 @@ function StatusBar({
2516
3096
  </span>
2517
3097
  </span>
2518
3098
 
2519
- {selected && selected.selector && !isSystem && (
3099
+ {/* Selection is meaningless without an open canvas — a stale _active.json
3100
+ selection used to leave a ghost SELECTED chip on the empty shell. */}
3101
+ {activePath && selected && selected.selector && !isSystem && (
2520
3102
  <span className="st-sb-slot st-sb-sel" role="group" aria-label="Selected element">
2521
3103
  <span className="lbl">selected</span>
2522
3104
  <span className="val" title={title}>
@@ -2526,7 +3108,8 @@ function StatusBar({
2526
3108
  type="button"
2527
3109
  className="st-sb-sel-clear"
2528
3110
  onClick={onClearSelected}
2529
- title="Clear (Esc inside iframe)"
3111
+ data-tip="Clear · Esc inside iframe"
3112
+ data-tip-pos="top"
2530
3113
  aria-label="Clear selection"
2531
3114
  >
2532
3115
  ×
@@ -2539,6 +3122,33 @@ function StatusBar({
2539
3122
  <span className="val">{openCount} open</span>
2540
3123
  </span>
2541
3124
 
3125
+ {/* Phase 28 — changes count, click to open the Changes panel (⌘⇧G). */}
3126
+ {onOpenChanges && (
3127
+ <button
3128
+ type="button"
3129
+ className={
3130
+ 'st-sb-slot st-sb-changes' +
3131
+ (changesOpen ? ' is-open' : '') +
3132
+ (changesCount > 0 ? ' has-changes' : unpushed > 0 ? ' has-unpushed' : '')
3133
+ }
3134
+ onClick={onOpenChanges}
3135
+ data-tip="Open Changes · ⌘⇧G"
3136
+ data-tip-pos="top"
3137
+ aria-label="Open Changes panel"
3138
+ aria-pressed={changesOpen}
3139
+ >
3140
+ <span className="st-sb-changes-dot" aria-hidden="true" />
3141
+ <span className="lbl">changes</span>
3142
+ <span className="val">
3143
+ {changesCount > 0
3144
+ ? `${changesCount} unsaved`
3145
+ : unpushed > 0
3146
+ ? `${unpushed} to publish`
3147
+ : 'all saved'}
3148
+ </span>
3149
+ </button>
3150
+ )}
3151
+
2542
3152
  <span className="st-sb-spacer" />
2543
3153
 
2544
3154
  <span className="st-sb-slot" role="group" aria-label="Connection">
@@ -2566,7 +3176,8 @@ function StatusBar({
2566
3176
  type="button"
2567
3177
  className="st-sb-theme"
2568
3178
  onClick={onToggleTheme}
2569
- title={`Switch to ${nextTheme} theme`}
3179
+ data-tip={`Switch to ${nextTheme} theme`}
3180
+ data-tip-pos="top"
2570
3181
  aria-label={`Switch to ${nextTheme} theme`}
2571
3182
  >
2572
3183
  <StIcon name={theme === 'dark' ? 'sun' : 'moon'} size={13} />
@@ -2588,6 +3199,8 @@ function CommentsPanel({
2588
3199
  onResolve,
2589
3200
  onReopen,
2590
3201
  onDelete,
3202
+ width,
3203
+ resizing,
2591
3204
  }) {
2592
3205
  const counts = totalCounts(commentsByFile);
2593
3206
  // Build groups: [{ file, comments: filtered }]
@@ -2613,7 +3226,11 @@ function CommentsPanel({
2613
3226
  }
2614
3227
 
2615
3228
  return (
2616
- <aside className="st-rpanel" aria-label="Comments">
3229
+ <aside
3230
+ className={'st-rpanel' + (resizing ? ' is-resizing' : '')}
3231
+ style={width ? { width, flexBasis: width } : undefined}
3232
+ aria-label="Comments"
3233
+ >
2617
3234
  <div className="st-rp-tabs st-rp-tabs--filters">
2618
3235
  <div className="st-cm-filters" role="tablist">
2619
3236
  <button
@@ -2740,6 +3357,35 @@ function CommentsPanel({
2740
3357
  // while offline (with queued-edit count), red when offline > 24h, green flash
2741
3358
  // for 3s right after a reconnect. Driven entirely by the 'sync:status' payload
2742
3359
  // the dev-server's linked-mode sync runtime broadcasts.
3360
+ // Phase 32 (Task 1) — auto-update notice. Shown after the shell has downloaded +
3361
+ // staged a newer build in the background. Non-blocking: "Restart now" applies it,
3362
+ // "Later" dismisses (the next focus/4h check re-stages and re-surfaces it).
3363
+ function UpdateBanner({ update, onDismiss }) {
3364
+ const [restarting, setRestarting] = useState(false);
3365
+ if (!update) return null;
3366
+ const ver = update.version ? ` (v${update.version})` : '';
3367
+ return (
3368
+ <div role="status" aria-live="polite" className="st-banner st-banner--info">
3369
+ <span className="st-banner-dot" aria-hidden="true" />
3370
+ <span>Maude updated{ver} · restart to apply</span>
3371
+ <button
3372
+ type="button"
3373
+ className="btn btn--primary btn--sm"
3374
+ disabled={restarting}
3375
+ onClick={() => {
3376
+ setRestarting(true);
3377
+ restartToUpdate().catch(() => setRestarting(false));
3378
+ }}
3379
+ >
3380
+ {restarting ? 'Restarting…' : 'Restart now'}
3381
+ </button>
3382
+ <button type="button" className="btn btn--ghost btn--sm" onClick={onDismiss}>
3383
+ Later
3384
+ </button>
3385
+ </div>
3386
+ );
3387
+ }
3388
+
2743
3389
  function SyncBanner({ status }) {
2744
3390
  // Plan C follow-up — the banner overlapped the menubar and wasn't dismissable.
2745
3391
  // Dismissal is keyed on the connection state, so a transition (reconnect flash,
@@ -2748,37 +3394,66 @@ function SyncBanner({ status }) {
2748
3394
  if (!status || status.linked === false) return null;
2749
3395
  // DDR-060 / 9.1-D — the "linked but 0 syncable" state is surfaced in the
2750
3396
  // status bar (sb-sync slot), NOT as a floating banner. This component owns
2751
- // only the transient offline / reconnect-flash banner (Task 8).
3397
+ // the transient offline / reconnect-flash banner (Task 8) plus the DDR-102
3398
+ // rejected-docs chip and divergence-resolution toast.
2752
3399
  if (status.notSyncable) return null;
2753
3400
  const { state, queuedOps, flash, conflicts } = status;
2754
3401
  const showFlash = flash === 'synced';
2755
3402
  const offline = state === 'offline' || state === 'offline-long';
2756
- if (!offline && !showFlash) return null;
2757
- const dismissKey = `${state}:${showFlash ? 'flash' : 'offline'}`;
2758
- if (dismissedKey === dismissKey) return null;
2759
-
3403
+ // DDR-102 per-doc rollup + the latest divergence notice (additive fields;
3404
+ // an old payload without them renders exactly the pre-DDR-102 banner).
3405
+ const rejected = status.docs?.rejected ?? 0;
3406
+ const lastDiverged = Array.isArray(conflicts)
3407
+ ? [...conflicts].reverse().find((c) => c.kind === 'cold-start-diverged')
3408
+ : null;
3409
+ if (!offline && !showFlash && !lastDiverged && rejected === 0) return null;
3410
+
3411
+ // One banner at a time — priority: reconnect flash > offline > divergence
3412
+ // toast > rejected chip. Dismissal is keyed per state so a new event
3413
+ // (another conflict, a changed rejected count) re-surfaces it.
2760
3414
  let variant;
2761
3415
  let text;
3416
+ let dismissKey;
2762
3417
  if (showFlash) {
2763
3418
  variant = 'success';
2764
3419
  text = 'Synced with hub';
2765
- } else if (state === 'offline-long') {
2766
- variant = 'error';
2767
- text = `Long offline — ${queuedOps} edit(s) queued. Consider \`git commit && git push\` as backup.`;
3420
+ dismissKey = `${state}:flash`;
3421
+ } else if (offline) {
3422
+ const conflictNote =
3423
+ conflicts && conflicts.length > 0 ? ` (${conflicts.length} conflict notice(s))` : '';
3424
+ if (state === 'offline-long') {
3425
+ variant = 'error';
3426
+ text = `Long offline — ${queuedOps} edit(s) queued. Consider \`git commit && git push\` as backup.${conflictNote}`;
3427
+ } else {
3428
+ variant = 'warn';
3429
+ text = `Working offline · ${queuedOps} edit(s) queued · will sync when the hub reconnects.${conflictNote}`;
3430
+ }
3431
+ dismissKey = `${state}:offline`;
3432
+ } else if (lastDiverged) {
3433
+ // DDR-102 fail-closed: a snapshotFailed conflict means the hub-wins overwrite
3434
+ // was REFUSED (local kept) because _history couldn't be written — surface it
3435
+ // as an error, not a routine "kept newest" notice.
3436
+ if (lastDiverged.snapshotFailed) {
3437
+ variant = 'error';
3438
+ text = `Diverged on ${lastDiverged.slug}: kept local — the history snapshot FAILED, so the overwrite was refused. Check disk space / .design/_history write access.`;
3439
+ } else {
3440
+ variant = 'warn';
3441
+ text = `Diverged on ${lastDiverged.slug}: kept the ${
3442
+ lastDiverged.winner === 'local' ? 'local (newer)' : 'hub'
3443
+ } version — the other is snapshotted in history → /design:rollback ${lastDiverged.slug}`;
3444
+ }
3445
+ dismissKey = `diverged:${lastDiverged.slug}:${lastDiverged.at}`;
2768
3446
  } else {
2769
3447
  variant = 'warn';
2770
- text = `Working offline · ${queuedOps} edit(s) queued · will sync when the hub reconnects.`;
3448
+ text = `${rejected} canvas(es) not syncing the hub rejected auth. Details: maude design status`;
3449
+ dismissKey = `rejected:${rejected}`;
2771
3450
  }
2772
- const conflictNote =
2773
- conflicts && conflicts.length > 0 ? ` (${conflicts.length} conflict notice(s))` : '';
3451
+ if (dismissedKey === dismissKey) return null;
2774
3452
 
2775
3453
  return (
2776
3454
  <div role="status" aria-live="polite" className={`st-banner st-banner--${variant}`}>
2777
3455
  <span className="st-banner-dot" aria-hidden="true" />
2778
- <span>
2779
- {text}
2780
- {conflictNote}
2781
- </span>
3456
+ <span>{text}</span>
2782
3457
  <button
2783
3458
  type="button"
2784
3459
  className="st-banner-close"
@@ -2792,6 +3467,1587 @@ function SyncBanner({ status }) {
2792
3467
  );
2793
3468
  }
2794
3469
 
3470
+ // ---------- CSS knobs (Phase 12.2, DDR-104) — interactive panel ----------
3471
+ //
3472
+ // Hybrid vocabulary (friendly collapsible section headers + CSS-named rows),
3473
+ // per-field DS-token quick-pick, nested box-model widget, per-corner radius,
3474
+ // per-row provenance (token-bound / raw-override / inherited), per-field save
3475
+ // state, and two escape hatches: custom CSS property (via /_api/edit-css) +
3476
+ // custom HTML attribute (via /_api/edit-attr). Each knob pre-fills from the
3477
+ // AUTHORED inline value (`el.authored`); the resolved `computed` value is a
3478
+ // faint placeholder only (NOT editable — the v1 UX bug). Ported from the
3479
+ // critic-approved + user-iterated `.design/ui/Studio.tsx` spec.
3480
+
3481
+ const CSS_DISPLAYS = ['block', 'inline-block', 'flex', 'inline-flex', 'grid', 'inline', 'none'];
3482
+ const CSS_FLEX_DIR = ['row', 'row-reverse', 'column', 'column-reverse'];
3483
+ const CSS_ALIGN = ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'];
3484
+ const CSS_JUSTIFY = [
3485
+ 'flex-start',
3486
+ 'center',
3487
+ 'flex-end',
3488
+ 'space-between',
3489
+ 'space-around',
3490
+ 'space-evenly',
3491
+ ];
3492
+ const CSS_WEIGHTS = ['300', '400', '500', '600', '700', '800'];
3493
+ const CSS_FONTS = [
3494
+ 'inherit',
3495
+ 'system-ui',
3496
+ 'sans-serif',
3497
+ 'serif',
3498
+ 'monospace',
3499
+ 'Inter',
3500
+ 'Inter Tight',
3501
+ 'JetBrains Mono',
3502
+ ];
3503
+ const CSS_BORDER_STYLES = ['none', 'solid', 'dashed', 'dotted', 'double'];
3504
+ const CSS_UNITS = ['px', 'rem', 'em', '%', 'vw', 'vh', 'auto'];
3505
+ // Properties whose bare-number value is unitless — never append a unit suffix.
3506
+ const CSS_UNITLESS = new Set(['line-height', 'opacity', 'font-weight', 'z-index', 'flex-grow', 'flex-shrink', 'order']);
3507
+ // #2 — Figma-style property prefix inside numeric fields: a small glyph (icon) or
3508
+ // a mono letter (t). Only where it reads cleanly; selects/colours keep their own.
3509
+ const PROP_LEAD = {
3510
+ 'font-size': { icon: 'p-size' },
3511
+ 'line-height': { icon: 'p-lineheight' },
3512
+ 'letter-spacing': { icon: 'p-letterspacing' },
3513
+ gap: { icon: 'p-gap' },
3514
+ width: { t: 'W' },
3515
+ height: { t: 'H' },
3516
+ 'max-width': { t: 'W' },
3517
+ 'border-radius': { icon: 'p-corner' },
3518
+ 'border-width': { icon: 'p-border' },
3519
+ opacity: { icon: 'p-opacity' },
3520
+ };
3521
+ const CSS_ALIGN_OPTS = ['left', 'center', 'right', 'justify'];
3522
+
3523
+ let _cssColorCtx = null;
3524
+ // Normalize any CSS color string to #rrggbb for the native color input via a
3525
+ // throwaway canvas fillStyle round-trip (parses rgb/hsl/named). Unparseable
3526
+ // values (some oklch) fall back to '' → picker defaults to #000000; the swatch
3527
+ // still shows the true color string regardless.
3528
+ function cssColorToHex(c) {
3529
+ if (!c) return '';
3530
+ if (/^#[0-9a-f]{6}$/i.test(c)) return c.toLowerCase();
3531
+ try {
3532
+ if (!_cssColorCtx) _cssColorCtx = document.createElement('canvas').getContext('2d');
3533
+ if (!_cssColorCtx) return '';
3534
+ _cssColorCtx.fillStyle = '#000000';
3535
+ _cssColorCtx.fillStyle = c;
3536
+ const v = _cssColorCtx.fillStyle;
3537
+ if (/^#[0-9a-f]{6}$/i.test(v)) return v.toLowerCase();
3538
+ const m = v.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
3539
+ if (m) {
3540
+ return `#${[m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('')}`;
3541
+ }
3542
+ } catch {
3543
+ /* canvas unavailable */
3544
+ }
3545
+ return '';
3546
+ }
3547
+
3548
+ // ---- Colour math for the HSV picker (#6 — Figma-style colour control) ----
3549
+ const clamp01 = (n) => Math.min(1, Math.max(0, n));
3550
+ function hexToRgb(hex) {
3551
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
3552
+ if (!m) return { r: 0, g: 0, b: 0 };
3553
+ const n = Number.parseInt(m[1], 16);
3554
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
3555
+ }
3556
+ function rgbToHex({ r, g, b }) {
3557
+ return `#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`;
3558
+ }
3559
+ function rgbToHsv({ r, g, b }) {
3560
+ r /= 255;
3561
+ g /= 255;
3562
+ b /= 255;
3563
+ const max = Math.max(r, g, b);
3564
+ const min = Math.min(r, g, b);
3565
+ const d = max - min;
3566
+ let h = 0;
3567
+ if (d) {
3568
+ if (max === r) h = ((g - b) / d) % 6;
3569
+ else if (max === g) h = (b - r) / d + 2;
3570
+ else h = (r - g) / d + 4;
3571
+ h *= 60;
3572
+ if (h < 0) h += 360;
3573
+ }
3574
+ return { h, s: max ? d / max : 0, v: max };
3575
+ }
3576
+ function hsvToRgb({ h, s, v }) {
3577
+ const c = v * s;
3578
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
3579
+ const m = v - c;
3580
+ let r = 0;
3581
+ let g = 0;
3582
+ let b = 0;
3583
+ if (h < 60) [r, g, b] = [c, x, 0];
3584
+ else if (h < 120) [r, g, b] = [x, c, 0];
3585
+ else if (h < 180) [r, g, b] = [0, c, x];
3586
+ else if (h < 240) [r, g, b] = [0, x, c];
3587
+ else if (h < 300) [r, g, b] = [x, 0, c];
3588
+ else [r, g, b] = [c, 0, x];
3589
+ return { r: (r + m) * 255, g: (g + m) * 255, b: (b + m) * 255 };
3590
+ }
3591
+
3592
+ // Round bare px to whole numbers for the placeholder hint; pass other values through.
3593
+ function cssHint(v) {
3594
+ if (!v) return '';
3595
+ const m = /^(-?\d*\.?\d+)px$/.exec(v);
3596
+ return m ? `${Math.round(Number.parseFloat(m[1]))}px` : v;
3597
+ }
3598
+
3599
+ // Split "16px" → { n:"16", unit:"px" }; "auto"→{n:"",unit:"auto"}; var()/raw → {n:raw,unit:""}.
3600
+ function cssSplitUnit(v) {
3601
+ if (!v) return { n: '', unit: 'px' };
3602
+ const t = v.trim();
3603
+ const m = /^(-?\d*\.?\d+)\s*(px|rem|em|%|vw|vh)?$/.exec(t);
3604
+ if (m) return { n: m[1], unit: m[2] || 'px' };
3605
+ if (t === 'auto') return { n: '', unit: 'auto' };
3606
+ return { n: t, unit: '' };
3607
+ }
3608
+
3609
+ // Phase 12.2/12.3 — the WS `selected` echo is the server's projection
3610
+ // (SelectedElement) and LACKS the client-only DOM fields the CSS knobs pre-fill
3611
+ // from (`authored` / `computed` inline style + `customStyles` / `attrs` — all
3612
+ // captured in the iframe, never round-tripped through the server). When the echo
3613
+ // is for the SAME element we already hold locally, preserve those fields instead
3614
+ // of clobbering them to empty (else the server round-trip wipes the custom-CSS /
3615
+ // custom-attr rows + computed readout right after selection).
3616
+ function mergeSelClientFields(incoming, prev) {
3617
+ if (!incoming || Array.isArray(incoming) || Array.isArray(prev) || !prev) return incoming;
3618
+ if (!incoming.id || incoming.id !== prev.id) return incoming;
3619
+ return {
3620
+ ...incoming,
3621
+ authored: incoming.authored ?? prev.authored,
3622
+ computed: incoming.computed ?? prev.computed,
3623
+ customStyles: incoming.customStyles ?? prev.customStyles,
3624
+ attrs: incoming.attrs ?? prev.attrs,
3625
+ };
3626
+ }
3627
+
3628
+ // Resolve the active canvas's DS tokens CSS path (mirrors canvas-url.js / DDR-093):
3629
+ // the canvas's declared DS wins, else designSystems[0], else the legacy default.
3630
+ function cssTokensRelFor(file, cfg) {
3631
+ const ds0 = cfg?.designSystems?.[0];
3632
+ const name = file ? cfg?.canvasDesignSystems?.[file] : null;
3633
+ const ds = (name && cfg?.designSystems?.find((d) => d.name === name)) || ds0;
3634
+ return ds?.tokensCssRel || cfg?.tokensCssRel || ds0?.tokensCssRel || '';
3635
+ }
3636
+
3637
+ // The active canvas's DS NAME (mirrors cssTokensRelFor's resolution order).
3638
+ function activeDsNameFor(file, cfg) {
3639
+ const byCanvas = file ? cfg?.canvasDesignSystems?.[file] : null;
3640
+ return byCanvas || cfg?.defaultDesignSystem || cfg?.designSystems?.[0]?.name || null;
3641
+ }
3642
+
3643
+ // Parse a DS tokens CSS body → token names grouped by family + a name→value map
3644
+ // (resolving one level of var() aliasing so the popover renders real swatches +
3645
+ // values). Phase 12.3 (W2.1/W3 multi-DS).
3646
+ function parseTokensCss(css) {
3647
+ const raw = {};
3648
+ for (const m of css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;}]+)/gi)) {
3649
+ if (!(m[1] in raw)) raw[m[1]] = m[2].trim();
3650
+ }
3651
+ const vals = {};
3652
+ for (const name of Object.keys(raw)) {
3653
+ const v = raw[name];
3654
+ const ref = /^var\(\s*(--[a-z0-9-]+)\s*\)$/i.exec(v);
3655
+ vals[name] = ref && raw[ref[1]] ? raw[ref[1]] : v;
3656
+ }
3657
+ const names = Object.keys(raw);
3658
+ const g = (re) => names.filter((n) => re.test(n));
3659
+ // Colours detected by VALUE, not name — so EVERY colour token a DS defines is
3660
+ // offered (the name-prefix list dropped many). A token is a colour if its
3661
+ // resolved value reads as one. (#3 — "see all tokens the DS has".)
3662
+ const isColor = (v) =>
3663
+ /^(#[0-9a-f]{3,8}|rgba?\(|hsla?\(|oklch\(|oklab\(|lab\(|lch\(|hwb\(|color\()/i.test(v) ||
3664
+ /^(transparent|currentcolor|white|black|red|green|blue|gray|grey|orange|yellow|purple|pink|cyan|magenta|teal|navy|maroon|olive|lime|aqua|silver|gold)$/i.test(
3665
+ v
3666
+ );
3667
+ return {
3668
+ color: names.filter((n) => isColor(vals[n])),
3669
+ space: g(/^--space-/),
3670
+ radius: g(/^--radius-/),
3671
+ type: g(/^--type-/),
3672
+ shadow: g(/^--shadow-/),
3673
+ lh: g(/^--lh-/),
3674
+ vals,
3675
+ };
3676
+ }
3677
+
3678
+ // Fetch + parse the tokens CSS of EVERY design system in the config (main
3679
+ // origin), so the token popover can offer tokens grouped per DS (W3 multi-DS
3680
+ // feedback). The active canvas's DS is ordered first. Returns
3681
+ // `[{ name, color, space, radius, type, shadow, lh, vals }]`.
3682
+ function useAllDsTokens(cfg, designRel, activeName) {
3683
+ const list = cfg?.designSystems || [];
3684
+ // A stable key so the effect only re-fetches when the DS set / paths change.
3685
+ const sig = list.map((d) => `${d.name}:${d.tokensCssRel}`).join('|');
3686
+ const [byDs, setByDs] = useState([]);
3687
+ useEffect(() => {
3688
+ if (!list.length) return undefined;
3689
+ let cancelled = false;
3690
+ Promise.all(
3691
+ list.map(async (ds) => {
3692
+ if (!ds.tokensCssRel) return null;
3693
+ try {
3694
+ const r = await fetch(`/${designRel}/${ds.tokensCssRel}`);
3695
+ const css = r.ok ? await r.text() : '';
3696
+ return { name: ds.name, ...parseTokensCss(css) };
3697
+ } catch {
3698
+ return null;
3699
+ }
3700
+ })
3701
+ ).then((res) => {
3702
+ if (cancelled) return;
3703
+ const got = res.filter(Boolean);
3704
+ // Active DS first, rest in config order.
3705
+ got.sort((a, b) => (a.name === activeName ? -1 : b.name === activeName ? 1 : 0));
3706
+ setByDs(got);
3707
+ });
3708
+ return () => {
3709
+ cancelled = true;
3710
+ };
3711
+ }, [sig, designRel, activeName]);
3712
+ return byDs;
3713
+ }
3714
+
3715
+ // Phase 12.3 (#4) — the "Custom" tab of the colour popover: a normal colour
3716
+ // input (native OS picker via a large swatch) + a hex/value text field. Applies
3717
+ // LIVE as you adjust (onApply), so the canvas previews while the picker is open.
3718
+ // #6 — the unified colour picker (Custom tab). A real HSV control: a
3719
+ // saturation/value square + a hue slider + a hex field + an eyedropper — the
3720
+ // Figma model. Replaces BOTH the old native <input type="color"> on the swatch
3721
+ // AND the simple hex field, so colours have ONE popover (Custom · Variables).
3722
+ // `seed` is the resolved current colour (hex). Drag updates the picker UI live;
3723
+ // commits on pointer-up (one source write per drag); the hex field commits on
3724
+ // blur/Enter.
3725
+ function ColorPicker({ seed, onApply }) {
3726
+ const [hsv, setHsv] = useState(() => rgbToHsv(hexToRgb(seed || '#000000')));
3727
+ const hsvRef = useRef(hsv);
3728
+ hsvRef.current = hsv;
3729
+ const svRef = useRef(null);
3730
+ const hueRef = useRef(null);
3731
+ // Reseed when the selection's colour changes (but not while the user drags).
3732
+ const seedRef = useRef(seed);
3733
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reseed on seed change only.
3734
+ useEffect(() => {
3735
+ if (seed && seed !== seedRef.current) {
3736
+ seedRef.current = seed;
3737
+ setHsv(rgbToHsv(hexToRgb(seed)));
3738
+ }
3739
+ }, [seed]);
3740
+ const hex = rgbToHex(hsvToRgb(hsv));
3741
+
3742
+ const dragSV = (e) => {
3743
+ e.preventDefault();
3744
+ const r = svRef.current?.getBoundingClientRect();
3745
+ if (!r) return;
3746
+ const h = hsvRef.current.h;
3747
+ const move = (ev) => {
3748
+ setHsv({
3749
+ h,
3750
+ s: clamp01((ev.clientX - r.left) / r.width),
3751
+ v: clamp01(1 - (ev.clientY - r.top) / r.height),
3752
+ });
3753
+ };
3754
+ move(e);
3755
+ const up = () => {
3756
+ document.removeEventListener('pointermove', move);
3757
+ document.removeEventListener('pointerup', up);
3758
+ onApply(rgbToHex(hsvToRgb(hsvRef.current)));
3759
+ };
3760
+ document.addEventListener('pointermove', move);
3761
+ document.addEventListener('pointerup', up);
3762
+ };
3763
+ const dragHue = (e) => {
3764
+ e.preventDefault();
3765
+ const r = hueRef.current?.getBoundingClientRect();
3766
+ if (!r) return;
3767
+ const { s, v } = hsvRef.current;
3768
+ const move = (ev) => {
3769
+ setHsv({ h: clamp01((ev.clientX - r.left) / r.width) * 360, s, v });
3770
+ };
3771
+ move(e);
3772
+ const up = () => {
3773
+ document.removeEventListener('pointermove', move);
3774
+ document.removeEventListener('pointerup', up);
3775
+ onApply(rgbToHex(hsvToRgb(hsvRef.current)));
3776
+ };
3777
+ document.addEventListener('pointermove', move);
3778
+ document.addEventListener('pointerup', up);
3779
+ };
3780
+ const eyedrop = async () => {
3781
+ try {
3782
+ // EyeDropper is Chromium-only; guarded.
3783
+ const ED = window.EyeDropper;
3784
+ if (!ED) return;
3785
+ const res = await new ED().open();
3786
+ if (res?.sRGBHex) {
3787
+ setHsv(rgbToHsv(hexToRgb(res.sRGBHex)));
3788
+ onApply(res.sRGBHex);
3789
+ }
3790
+ } catch {
3791
+ /* user cancelled */
3792
+ }
3793
+ };
3794
+
3795
+ return (
3796
+ <div className="st-cp-cpick">
3797
+ <button
3798
+ type="button"
3799
+ ref={svRef}
3800
+ className="st-cp-cpick-sv"
3801
+ aria-label="saturation and value"
3802
+ style={{ background: `hsl(${hsv.h} 100% 50%)` }}
3803
+ onPointerDown={dragSV}
3804
+ >
3805
+ <span className="st-cp-cpick-svwhite" />
3806
+ <span className="st-cp-cpick-svblack" />
3807
+ <span
3808
+ className="st-cp-cpick-knob"
3809
+ style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%`, background: hex }}
3810
+ />
3811
+ </button>
3812
+ <div className="st-cp-cpick-controls">
3813
+ {window.EyeDropper ? (
3814
+ <button
3815
+ type="button"
3816
+ className="st-cp-cpick-eye"
3817
+ aria-label="pick from screen"
3818
+ title="eyedropper"
3819
+ onClick={eyedrop}
3820
+ >
3821
+ <StIcon name="eyedropper" size={14} />
3822
+ </button>
3823
+ ) : null}
3824
+ <button
3825
+ type="button"
3826
+ ref={hueRef}
3827
+ className="st-cp-cpick-hue"
3828
+ aria-label="hue"
3829
+ onPointerDown={dragHue}
3830
+ >
3831
+ <span className="st-cp-cpick-huethumb" style={{ left: `${(hsv.h / 360) * 100}%` }} />
3832
+ </button>
3833
+ </div>
3834
+ <input
3835
+ className="st-cp-fin"
3836
+ type="text"
3837
+ value={hex}
3838
+ aria-label="hex value"
3839
+ onChange={(e) => {
3840
+ const v = e.target.value;
3841
+ if (/^#?[0-9a-f]{6}$/i.test(v)) setHsv(rgbToHsv(hexToRgb(v)));
3842
+ }}
3843
+ onKeyDown={(e) => {
3844
+ if (e.key === 'Enter') onApply(e.currentTarget.value);
3845
+ }}
3846
+ onBlur={(e) => onApply(e.currentTarget.value)}
3847
+ />
3848
+ </div>
3849
+ );
3850
+ }
3851
+
3852
+ // Phase 12.3 (W2.1) — token picker as a Figma-style popover instead of a native
3853
+ // <select>. `kind='color'` renders a swatch grid (resolved DS color values);
3854
+ // `kind='value'` a variable list (pretty name + resolved value, à la Figma's
3855
+ // variable picker). Picking commits `var(--token)`. Portals to <body> +
3856
+ // fixed-positions from the trigger rect so the panel's overflow never clips it.
3857
+ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex, activeDs }) {
3858
+ const [open, setOpen] = useState(false);
3859
+ const [pos, setPos] = useState(null);
3860
+ // Phase 12.3 (#4) — colour popover gets two tabs: a normal colour input
3861
+ // (Custom) + the DS variables swatch list (Variables). Token-able non-colour
3862
+ // popovers stay single-mode.
3863
+ const [mode, setMode] = useState('custom');
3864
+ const [query, setQuery] = useState('');
3865
+ const btnRef = useRef(null);
3866
+ const popRef = useRef(null);
3867
+ const bound = typeof current === 'string' && /var\(\s*--/.test(current);
3868
+ const isOn = (n) => current === `var(${n})`;
3869
+ const pretty = (n) => n.replace(/^--/, '').replace(/-/g, ' ');
3870
+ const gs = groups || [];
3871
+ const total = gs.reduce((s, g) => s + (g.names?.length || 0), 0);
3872
+ const showDsHeaders = gs.length > 1; // group by DS only when there's >1
3873
+ // Search filter over the token name + resolved value, per group.
3874
+ const q = query.trim().toLowerCase();
3875
+ const filteredGs = !q
3876
+ ? gs
3877
+ : gs
3878
+ .map((g) => ({
3879
+ ...g,
3880
+ names: (g.names || []).filter(
3881
+ (n) =>
3882
+ pretty(n).toLowerCase().includes(q) ||
3883
+ n.toLowerCase().includes(q) ||
3884
+ (g.vals?.[n] || '').toLowerCase().includes(q)
3885
+ ),
3886
+ }))
3887
+ .filter((g) => g.names.length);
3888
+
3889
+ useEffect(() => {
3890
+ if (!open) {
3891
+ setQuery('');
3892
+ return undefined;
3893
+ }
3894
+ const place = () => {
3895
+ const r = btnRef.current?.getBoundingClientRect();
3896
+ if (!r) return;
3897
+ const W = 224;
3898
+ const MAXH = 300;
3899
+ let left = Math.min(r.right - W, window.innerWidth - W - 8);
3900
+ if (left < 8) left = 8;
3901
+ const below = window.innerHeight - r.bottom;
3902
+ const top = below > MAXH + 8 ? r.bottom + 4 : Math.max(8, r.top - MAXH - 4);
3903
+ setPos({ left, top, width: W, maxHeight: MAXH });
3904
+ };
3905
+ place();
3906
+ const onDoc = (e) => {
3907
+ if (popRef.current?.contains(e.target) || btnRef.current?.contains(e.target)) return;
3908
+ setOpen(false);
3909
+ };
3910
+ const onKey = (e) => {
3911
+ if (e.key === 'Escape') setOpen(false);
3912
+ };
3913
+ const dismiss = () => setOpen(false);
3914
+ // The popover is fixed-positioned from the trigger rect, so a scroll of the
3915
+ // PANEL detaches it → dismiss. But scrolling INSIDE the popover (its own
3916
+ // overflow list) must NOT close it (the user couldn't scroll the variables).
3917
+ const onScroll = (e) => {
3918
+ if (popRef.current?.contains(e.target)) return;
3919
+ setOpen(false);
3920
+ };
3921
+ document.addEventListener('pointerdown', onDoc, true);
3922
+ document.addEventListener('keydown', onKey);
3923
+ window.addEventListener('resize', dismiss);
3924
+ document.addEventListener('scroll', onScroll, true);
3925
+ return () => {
3926
+ document.removeEventListener('pointerdown', onDoc, true);
3927
+ document.removeEventListener('keydown', onKey);
3928
+ window.removeEventListener('resize', dismiss);
3929
+ document.removeEventListener('scroll', onScroll, true);
3930
+ };
3931
+ }, [open]);
3932
+
3933
+ // Pick a token. #3 — apply CORRECTLY across design systems: a token from the
3934
+ // canvas's OWN active DS commits `var(--token)` (round-trips + resolves right);
3935
+ // a token from ANOTHER DS commits its RESOLVED value (literal), because
3936
+ // `var(--token)` would resolve against the canvas's DS scope and paint the WRONG
3937
+ // colour. So what you click is always what's applied ("natvrdo").
3938
+ // SECURITY (ethical-hacker A3) — a cross-DS token value is committed as a
3939
+ // LITERAL, and its source is a (possibly hub-pushed, untrusted) tokens CSS.
3940
+ // A colour-shaped value can still smuggle a fetch primitive (e.g.
3941
+ // `rgb(1 2 3) url(//x)` passes the colour sniff). Refuse to write a literal
3942
+ // carrying url()/image-set()/expression()/@import — fall back to var(), which
3943
+ // resolves against the CANVAS's own DS (never the attacker's value).
3944
+ const UNSAFE_TOKEN_VALUE = /url\(|image-set\(|cross-fade\(|element\(|expression\(|@import|javascript:/i;
3945
+ const pickFrom = (ds, n, resolved) => {
3946
+ if (activeDs && ds && ds !== activeDs && resolved && !UNSAFE_TOKEN_VALUE.test(resolved)) {
3947
+ onPick(resolved);
3948
+ } else {
3949
+ onPick(`var(${n})`);
3950
+ }
3951
+ setOpen(false);
3952
+ };
3953
+ // Custom colour / hex applies LIVE without closing, so the user can keep
3954
+ // tweaking; the popover dismisses on outside-click / Esc like everything else.
3955
+ const applyRaw = (v) => {
3956
+ const val = (v || '').trim();
3957
+ if (val) onPick(val);
3958
+ };
3959
+ // A search field over the token list (name + value). Auto-focuses so the user
3960
+ // can type straight away.
3961
+ const searchBar = (
3962
+ <div className="st-cp-pop-search">
3963
+ <StIcon name="search" size={12} />
3964
+ <input
3965
+ value={query}
3966
+ onChange={(e) => setQuery(e.target.value)}
3967
+ placeholder="Search variables"
3968
+ aria-label="search variables"
3969
+ // biome-ignore lint/a11y/noAutofocus: focusing the search on open is the intent.
3970
+ autoFocus
3971
+ />
3972
+ </div>
3973
+ );
3974
+ // #2 — colour Variables as a scannable LIST (swatch · name · value), per DS.
3975
+ const swatchList = (grps) =>
3976
+ grps.map((g) => (
3977
+ <div className="st-cp-pop-group" key={g.ds}>
3978
+ {showDsHeaders ? <div className="st-cp-pop-ds">{g.ds}</div> : null}
3979
+ <div className="st-cp-pop-list">
3980
+ {g.names.map((n) => (
3981
+ <button
3982
+ key={`${g.ds}:${n}`}
3983
+ type="button"
3984
+ className={`st-cp-pop-row st-cp-pop-crow${isOn(n) ? ' is-on' : ''}`}
3985
+ onClick={() => pickFrom(g.ds, n, g.vals?.[n])}
3986
+ >
3987
+ <span
3988
+ className="st-cp-pop-cswatch"
3989
+ style={{ background: g.vals?.[n] || 'transparent' }}
3990
+ aria-hidden="true"
3991
+ />
3992
+ <span className="st-cp-pop-name">{pretty(n)}</span>
3993
+ <span className="st-cp-pop-val">{g.vals?.[n] || ''}</span>
3994
+ </button>
3995
+ ))}
3996
+ </div>
3997
+ </div>
3998
+ ));
3999
+ // The value-token list (non-colour), per DS.
4000
+ const valueList = (grps) =>
4001
+ grps.map((g) => (
4002
+ <div className="st-cp-pop-group" key={g.ds}>
4003
+ {showDsHeaders ? <div className="st-cp-pop-ds">{g.ds}</div> : null}
4004
+ <div className="st-cp-pop-list">
4005
+ {g.names.map((n) => (
4006
+ <button
4007
+ key={`${g.ds}:${n}`}
4008
+ type="button"
4009
+ className={`st-cp-pop-row${isOn(n) ? ' is-on' : ''}`}
4010
+ onClick={() => pickFrom(g.ds, n, g.vals?.[n])}
4011
+ >
4012
+ <span className="st-cp-pop-name">{pretty(n)}</span>
4013
+ <span className="st-cp-pop-val">{g.vals?.[n] || ''}</span>
4014
+ </button>
4015
+ ))}
4016
+ </div>
4017
+ </div>
4018
+ ));
4019
+ const noMatch = <div className="st-cp-pop-empty">No match</div>;
4020
+
4021
+ return (
4022
+ <>
4023
+ {swatchBg !== undefined ? (
4024
+ // Colour rows: the swatch IS the trigger — one popover, no separate native
4025
+ // OS picker + ◇ (the "two popovers" the user flagged). Shows the current
4026
+ // colour; bound-to-token gets the accent ring.
4027
+ <button
4028
+ type="button"
4029
+ ref={btnRef}
4030
+ className={`st-cp-swatch st-cp-swatch--mini st-cp-swatch--trigger${bound ? ' is-bound' : ''}`}
4031
+ aria-haspopup="dialog"
4032
+ aria-expanded={open}
4033
+ aria-label={label || 'pick a colour'}
4034
+ title={current || 'pick a colour'}
4035
+ onClick={() => setOpen((v) => !v)}
4036
+ >
4037
+ <span style={{ position: 'absolute', inset: 0, background: swatchBg || 'transparent' }} />
4038
+ </button>
4039
+ ) : (
4040
+ <button
4041
+ type="button"
4042
+ ref={btnRef}
4043
+ className={`st-cp-tokbtn${bound ? ' is-bound' : ''}`}
4044
+ aria-haspopup="dialog"
4045
+ aria-expanded={open}
4046
+ aria-label={label || 'pick a design token'}
4047
+ title="design tokens"
4048
+ onClick={() => setOpen((v) => !v)}
4049
+ >
4050
+ <span className="st-cp-tokbtn-glyph" aria-hidden="true" />
4051
+ </button>
4052
+ )}
4053
+ {open && pos
4054
+ ? createPortal(
4055
+ <div
4056
+ ref={popRef}
4057
+ // Portalled to <body> (outside the App's `.maude` div), so it must
4058
+ // re-establish the maude token scope itself — otherwise var(--bg-*)
4059
+ // resolves to the legacy :root project palette (the cream popover bug).
4060
+ className="maude st-cp-pop"
4061
+ data-theme={
4062
+ (typeof document !== 'undefined' &&
4063
+ document.documentElement.getAttribute('data-theme')) ||
4064
+ 'dark'
4065
+ }
4066
+ role="dialog"
4067
+ aria-label={label || 'design tokens'}
4068
+ style={{
4069
+ left: pos.left,
4070
+ top: pos.top,
4071
+ width: pos.width,
4072
+ maxHeight: pos.maxHeight,
4073
+ }}
4074
+ >
4075
+ {kind === 'color' ? (
4076
+ <>
4077
+ <div className="st-cp-poptabs" role="tablist">
4078
+ <button
4079
+ type="button"
4080
+ role="tab"
4081
+ aria-selected={mode === 'custom'}
4082
+ className={`st-cp-poptab${mode === 'custom' ? ' is-active' : ''}`}
4083
+ onClick={() => setMode('custom')}
4084
+ >
4085
+ Custom
4086
+ </button>
4087
+ <button
4088
+ type="button"
4089
+ role="tab"
4090
+ aria-selected={mode === 'vars'}
4091
+ className={`st-cp-poptab${mode === 'vars' ? ' is-active' : ''}`}
4092
+ onClick={() => setMode('vars')}
4093
+ >
4094
+ Variables
4095
+ </button>
4096
+ </div>
4097
+ {mode === 'custom' ? (
4098
+ <ColorPicker seed={seedHex || cssColorToHex(current) || '#000000'} onApply={applyRaw} />
4099
+ ) : !total ? (
4100
+ <div className="st-cp-pop-empty">No color tokens</div>
4101
+ ) : (
4102
+ <>
4103
+ {searchBar}
4104
+ {filteredGs.length ? swatchList(filteredGs) : noMatch}
4105
+ </>
4106
+ )}
4107
+ </>
4108
+ ) : !total ? (
4109
+ <div className="st-cp-pop-empty">No tokens for this property</div>
4110
+ ) : (
4111
+ <>
4112
+ {searchBar}
4113
+ {filteredGs.length ? valueList(filteredGs) : noMatch}
4114
+ </>
4115
+ )}
4116
+ </div>,
4117
+ document.body
4118
+ )
4119
+ : null}
4120
+ </>
4121
+ );
4122
+ }
4123
+
4124
+ function CssKnobs({ el, cfg, onOptimistic }) {
4125
+ const editable = !!el.id;
4126
+ const computed = el.computed || {};
4127
+ // Phase 12.3 — optimistic local overlay over the selection's authored / custom
4128
+ // / attr maps. With the redundant-reload suppression (the flicker fix), an edit
4129
+ // no longer triggers a reselect that would re-post fresh `authored` values — so
4130
+ // the panel must reflect its own commits immediately or it shows the stale
4131
+ // pre-edit value until the user re-selects. Each commit/reset writes here;
4132
+ // `null` marks a removed key. Cleared when a different element is selected.
4133
+ const [overlay, setOverlay] = useState({ a: {}, c: {}, t: {} });
4134
+ // biome-ignore lint/correctness/useExhaustiveDependencies: clear only on element change.
4135
+ useEffect(() => {
4136
+ setOverlay({ a: {}, c: {}, t: {} });
4137
+ }, [el.id]);
4138
+ const mergeOverlay = (base, ov) => {
4139
+ const out = { ...(base || {}) };
4140
+ for (const [k, v] of Object.entries(ov)) {
4141
+ if (v === null) delete out[k];
4142
+ else out[k] = v;
4143
+ }
4144
+ return out;
4145
+ };
4146
+ const authored = mergeOverlay(el.authored, overlay.a);
4147
+ const customStyles = mergeOverlay(el.customStyles, overlay.c);
4148
+ const attrs = mergeOverlay(el.attrs, overlay.t);
4149
+ const setA = (prop, v) => setOverlay((o) => ({ ...o, a: { ...o.a, [prop]: v } }));
4150
+ const setC = (prop, v) => setOverlay((o) => ({ ...o, c: { ...o.c, [prop]: v } }));
4151
+ const setT = (attr, v) => setOverlay((o) => ({ ...o, t: { ...o.t, [attr]: v } }));
4152
+ // Token CSS is served from the MAIN origin at the repo-relative path, i.e.
4153
+ // WITH the designRoot prefix (`/.design/system/<ds>/colors_and_type.css`) —
4154
+ // `tokensCssRel` from config is DS-root-relative (no `.design/`), so prepend it.
4155
+ const _designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '');
4156
+ const _activeDs = activeDsNameFor(el.file, cfg);
4157
+ // W3 — tokens from EVERY configured DS, active one first, so the popover can
4158
+ // offer them grouped per design system.
4159
+ const allDs = useAllDsTokens(cfg, _designRel, _activeDs);
4160
+ // Build per-DS popover groups for one token family (color/space/radius/…).
4161
+ const tokenGroups = (familyKey) =>
4162
+ allDs
4163
+ .map((d) => ({ ds: d.name, names: d[familyKey] || [], vals: d.vals }))
4164
+ .filter((g) => g.names.length);
4165
+ const [status, setStatus] = useState({});
4166
+ const [open, setOpen] = useState({
4167
+ Layout: true,
4168
+ Typography: true,
4169
+ Spacing: true,
4170
+ Size: true,
4171
+ Appearance: true,
4172
+ Advanced: false,
4173
+ });
4174
+ const [split, setSplit] = useState(false);
4175
+
4176
+ // Phase 12.3 — auto-expand Advanced when the selected element carries custom
4177
+ // CSS props / HTML attrs, so a just-added (or pre-existing) custom value is
4178
+ // visible without hunting for the disclosure. Keyed on el.id so it re-runs per
4179
+ // selection (CssKnobs persists across selections — the el prop changes).
4180
+ const hasCustom =
4181
+ Object.keys(customStyles).length > 0 || Object.keys(attrs).length > 0;
4182
+ useEffect(() => {
4183
+ if (hasCustom) setOpen((o) => (o.Advanced ? o : { ...o, Advanced: true }));
4184
+ }, [el.id, hasCustom]);
4185
+
4186
+ async function post(url, payload, key) {
4187
+ setStatus((s) => ({ ...s, [key]: 'saving' }));
4188
+ try {
4189
+ const res = await fetch(url, {
4190
+ method: 'POST',
4191
+ headers: { 'content-type': 'application/json' },
4192
+ body: JSON.stringify(payload),
4193
+ });
4194
+ const j = await res.json().catch(() => ({}));
4195
+ setStatus((s) => ({
4196
+ ...s,
4197
+ [key]: !res.ok || !j.ok ? `err:${(j && j.error) || `HTTP ${res.status}`}` : 'saved',
4198
+ }));
4199
+ } catch (err) {
4200
+ setStatus((s) => ({ ...s, [key]: `err:${err && err.message ? err.message : String(err)}` }));
4201
+ }
4202
+ }
4203
+ // Optimistic preview: nudge the live element so the change shows before the
4204
+ // edit → HMR reload lands. `value` null = remove (reset path). No-op when the
4205
+ // selection has no stable id (can't be resolved in the canvas).
4206
+ const optimistic = (prop, value) => {
4207
+ if (!onOptimistic || !el.id) return;
4208
+ onOptimistic({
4209
+ id: el.id,
4210
+ artboardId: el.artboardId ?? null,
4211
+ index: el.index ?? 0,
4212
+ prop,
4213
+ value,
4214
+ });
4215
+ };
4216
+ const commit = (property, raw) => {
4217
+ const value = (raw || '').trim();
4218
+ if (!editable || !value) return;
4219
+ if (value === (authored[property] ?? '').trim()) return; // no-op
4220
+ optimistic(property, value);
4221
+ setA(property, value); // reflect in the panel immediately (no reload → no reselect)
4222
+ post('/_api/edit-css', { canvas: el.file, id: el.id, property, value }, property);
4223
+ };
4224
+ // A custom CSS property (Advanced) — same write, but the panel surfaces it from
4225
+ // the customStyles map, so overlay THERE.
4226
+ const commitCustom = (property, raw) => {
4227
+ const value = (raw || '').trim();
4228
+ if (!editable || !property.trim() || !value) return;
4229
+ optimistic(property.trim(), value);
4230
+ setC(property.trim(), value);
4231
+ post('/_api/edit-css', { canvas: el.file, id: el.id, property: property.trim(), value }, property.trim());
4232
+ };
4233
+ const commitAttr = (attr, raw) => {
4234
+ const a = (attr || '').trim();
4235
+ const value = (raw || '').trim();
4236
+ if (!editable || !a || !value) return;
4237
+ setT(a, value);
4238
+ post('/_api/edit-attr', { canvas: el.file, id: el.id, attr: a, value }, `@${a}`);
4239
+ };
4240
+ // Phase 12.3 — reset (remove the inline prop / attr → back to class/inherited).
4241
+ const reset = (property) => {
4242
+ if (!editable) return;
4243
+ optimistic(property, null);
4244
+ setA(property, null);
4245
+ post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
4246
+ };
4247
+ const resetCustom = (property) => {
4248
+ if (!editable) return;
4249
+ optimistic(property, null);
4250
+ setC(property, null);
4251
+ post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
4252
+ };
4253
+ const resetAttr = (attr) => {
4254
+ if (!editable) return;
4255
+ setT(attr, null);
4256
+ post('/_api/edit-attr', { canvas: el.file, id: el.id, attr, reset: true }, `@${attr}`);
4257
+ };
4258
+ // Phase 12.3 (W2.2) — Figma/Webflow scrub: drag a number field horizontally to
4259
+ // change its value. Live preview via optimistic apply on every move (no source
4260
+ // write); commits ONCE on release. A pointer that doesn't pass a 3px threshold
4261
+ // is a normal click (focus to type). `opts.step` modifiers: shift = ×10, alt =
4262
+ // ×0.1. `opts.sides` enables Webflow box-model modifiers: alt = symmetric pair,
4263
+ // alt+shift = all four (else just this side). `opts.min` clamps (default 0).
4264
+ const makeScrub = (prop, opts = {}) => (e) => {
4265
+ if (e.button !== 0) return;
4266
+ const input = e.currentTarget;
4267
+ const startX = e.clientX;
4268
+ const baseN =
4269
+ Number.parseFloat(
4270
+ cssSplitUnit(authored[prop] ?? cssHint(computed[prop]) ?? '0').n || '0'
4271
+ ) || 0;
4272
+ const unit = opts.unitless
4273
+ ? ''
4274
+ : opts.unit || cssSplitUnit(authored[prop] ?? '').unit || 'px';
4275
+ const min = opts.min ?? 0;
4276
+ const fmt = (n) => (opts.unitless ? `${n}` : `${n}${unit}`);
4277
+ const sidesFor = (ev) => {
4278
+ if (!opts.sides) return [prop];
4279
+ if (ev.altKey && ev.shiftKey) return opts.sides.all;
4280
+ if (ev.altKey) return opts.sides.pair;
4281
+ return [prop];
4282
+ };
4283
+ let scrubbing = false;
4284
+ let last = baseN;
4285
+ const move = (ev) => {
4286
+ const dx = ev.clientX - startX;
4287
+ if (!scrubbing && Math.abs(dx) < 3) return;
4288
+ if (!scrubbing) {
4289
+ scrubbing = true;
4290
+ document.body.classList.add('st-scrubbing');
4291
+ }
4292
+ ev.preventDefault();
4293
+ const granular = opts.sides ? 1 : ev.shiftKey ? 10 : ev.altKey ? 0.1 : 1;
4294
+ last = Math.round((baseN + dx * granular) * 100) / 100;
4295
+ if (last < min) last = min;
4296
+ const sides = sidesFor(ev);
4297
+ // Live-update the dragged field AND, for a box-model multi-side scrub, the
4298
+ // sibling box inputs so the whole pair / four-up move shows in the panel —
4299
+ // not just the one being dragged (W2.2 feedback).
4300
+ if (input) input.value = String(last);
4301
+ if (opts.sides && sides.length > 1) {
4302
+ const box = input?.closest('.st-cp-box');
4303
+ for (const p of sides) {
4304
+ if (p === prop) continue;
4305
+ const sib = box?.querySelector(`.st-cp-boxv[aria-label="${p}"]`);
4306
+ if (sib) sib.value = String(last);
4307
+ }
4308
+ }
4309
+ for (const p of sides) optimistic(p, fmt(last));
4310
+ };
4311
+ const up = (ev) => {
4312
+ document.removeEventListener('pointermove', move);
4313
+ document.removeEventListener('pointerup', up);
4314
+ if (!scrubbing) return;
4315
+ document.body.classList.remove('st-scrubbing');
4316
+ for (const p of sidesFor(ev)) commit(p, fmt(last));
4317
+ };
4318
+ document.addEventListener('pointermove', move);
4319
+ document.addEventListener('pointerup', up);
4320
+ };
4321
+ const provOf = (prop) => {
4322
+ const v = authored[prop];
4323
+ if (!v) return 'inherit';
4324
+ return /var\(\s*--/.test(v) ? 'bound' : 'raw';
4325
+ };
4326
+
4327
+ if (!editable) {
4328
+ return (
4329
+ <div className="st-cp">
4330
+ <div className="st-cp-id">
4331
+ <span className="st-cp-idtag">{el.tag || 'element'}</span>
4332
+ </div>
4333
+ <div className="st-css-disabled">
4334
+ This selection has no stable element id (a legacy canvas, or a non-element target). Edit
4335
+ it with <code>/design:edit</code>.
4336
+ </div>
4337
+ </div>
4338
+ );
4339
+ }
4340
+
4341
+ const PROVLABEL = { bound: 'token-bound', raw: 'raw override', inherit: 'inherited' };
4342
+ const prov = (p) => (
4343
+ <span className={`st-cp-prov st-cp-prov--${p}`} role="img" aria-label={PROVLABEL[p]} />
4344
+ );
4345
+
4346
+ // Phase 12.3 (#4) — the LEADING dot carries it all: provenance (shape) + save
4347
+ // status (a success/error/saving glow) + reset (double-click an authored row).
4348
+ // No trailing ✓/⟲ that shift the input rightward (the user's gripe). A tooltip
4349
+ // hints the double-click-to-reset.
4350
+ const provDot = (prop, provKind) => {
4351
+ const k = provKind ?? provOf(prop);
4352
+ const s = status[prop];
4353
+ const errMsg = typeof s === 'string' && s.startsWith('err:') ? s.slice(4) : '';
4354
+ const stCls = errMsg ? ' is-err' : s === 'saved' ? ' is-saved' : s === 'saving' ? ' is-saving' : '';
4355
+ const canReset = !!authored[prop];
4356
+ const tip = errMsg
4357
+ ? `error: ${errMsg}`
4358
+ : canReset
4359
+ ? `${PROVLABEL[k]} · double-click to reset`
4360
+ : PROVLABEL[k];
4361
+ return (
4362
+ <button
4363
+ type="button"
4364
+ className={`st-cp-prov st-cp-prov--${k}${stCls}${canReset ? ' is-resettable' : ''}`}
4365
+ aria-label={tip}
4366
+ title={tip}
4367
+ tabIndex={canReset ? 0 : -1}
4368
+ onDoubleClick={canReset ? () => reset(prop) : undefined}
4369
+ onKeyDown={
4370
+ canReset
4371
+ ? (e) => {
4372
+ if (e.key === 'Backspace' || e.key === 'Delete') {
4373
+ e.preventDefault();
4374
+ reset(prop);
4375
+ }
4376
+ }
4377
+ : undefined
4378
+ }
4379
+ />
4380
+ );
4381
+ };
4382
+
4383
+ const row = (prop, control, provKind) => {
4384
+ // #1 bigger-bet — scannable diff: a fully-unset single-prop row is dimmed so
4385
+ // the handful of overridden rows pop (Webflow/Framer model). Composite rows
4386
+ // (border — they pass an explicit provKind) are never dimmed.
4387
+ const unset = provKind === undefined && !authored[prop];
4388
+ return (
4389
+ <div className={`st-cp-row${unset ? ' is-unset' : ''}`} key={prop}>
4390
+ {provDot(prop, provKind)}
4391
+ <label className="st-cp-label" title={prop}>
4392
+ {prop}
4393
+ </label>
4394
+ <div className="st-cp-ctl">{control}</div>
4395
+ </div>
4396
+ );
4397
+ };
4398
+
4399
+ // Props each section owns — drives the per-section "reset section" affordance.
4400
+ const SECTION_PROPS = {
4401
+ Layout: ['display', 'flex-direction', 'align-items', 'justify-content', 'gap'],
4402
+ Typography: [
4403
+ 'font-family',
4404
+ 'color',
4405
+ 'font-size',
4406
+ 'font-weight',
4407
+ 'line-height',
4408
+ 'letter-spacing',
4409
+ 'text-align',
4410
+ ],
4411
+ Spacing: [
4412
+ 'margin-top',
4413
+ 'margin-right',
4414
+ 'margin-bottom',
4415
+ 'margin-left',
4416
+ 'padding-top',
4417
+ 'padding-right',
4418
+ 'padding-bottom',
4419
+ 'padding-left',
4420
+ ],
4421
+ Size: ['width', 'height', 'max-width'],
4422
+ Appearance: [
4423
+ 'background-color',
4424
+ 'border-radius',
4425
+ 'border-top-left-radius',
4426
+ 'border-top-right-radius',
4427
+ 'border-bottom-left-radius',
4428
+ 'border-bottom-right-radius',
4429
+ 'border-width',
4430
+ 'border-style',
4431
+ 'border-color',
4432
+ 'box-shadow',
4433
+ 'opacity',
4434
+ ],
4435
+ };
4436
+ const resetSection = (name) => {
4437
+ (SECTION_PROPS[name] || []).forEach((p) => {
4438
+ if (authored[p]) reset(p);
4439
+ });
4440
+ };
4441
+
4442
+ const sec = (name, body) => {
4443
+ const dirty = (SECTION_PROPS[name] || []).some((p) => authored[p]);
4444
+ return (
4445
+ <section className="st-cp-sec" key={name}>
4446
+ <div className="st-cp-sechd-row">
4447
+ <button
4448
+ type="button"
4449
+ className="st-cp-sechd"
4450
+ aria-expanded={!!open[name]}
4451
+ onClick={() => setOpen((o) => ({ ...o, [name]: !o[name] }))}
4452
+ >
4453
+ <span className="st-cp-caret" aria-hidden="true">
4454
+ {open[name] ? '▾' : '▸'}
4455
+ </span>
4456
+ {name}
4457
+ </button>
4458
+ {dirty ? (
4459
+ <button
4460
+ type="button"
4461
+ className="st-cp-secreset"
4462
+ aria-label={`reset ${name} section to original`}
4463
+ title={`reset ${name}`}
4464
+ onClick={() => resetSection(name)}
4465
+ >
4466
+
4467
+ </button>
4468
+ ) : null}
4469
+ </div>
4470
+ {open[name] ? body : null}
4471
+ </section>
4472
+ );
4473
+ };
4474
+
4475
+ // native <select> committing a CSS value directly
4476
+ const csel = (prop, list) => (
4477
+ <select
4478
+ className="st-cp-nsel"
4479
+ aria-label={prop}
4480
+ value={list.includes(authored[prop]) ? authored[prop] : ''}
4481
+ onChange={(e) => commit(prop, e.target.value)}
4482
+ >
4483
+ <option value="" disabled>
4484
+ {cssHint(computed[prop]) || '—'}
4485
+ </option>
4486
+ {list.map((v) => (
4487
+ <option key={v} value={v}>
4488
+ {v}
4489
+ </option>
4490
+ ))}
4491
+ </select>
4492
+ );
4493
+
4494
+ // token quick-pick — Figma-style POPOVER (W2.1) listing the DS variables for
4495
+ // this property (name + resolved value), grouped per design system (W3);
4496
+ // picking writes var(--token). `familyKey` selects the token family.
4497
+ const tok = (prop, familyKey) => {
4498
+ const groups = tokenGroups(familyKey);
4499
+ return groups.length ? (
4500
+ <TokenPopover
4501
+ kind="value"
4502
+ groups={groups}
4503
+ current={authored[prop]}
4504
+ activeDs={_activeDs}
4505
+ onPick={(v) => commit(prop, v)}
4506
+ label={`${prop} design token`}
4507
+ />
4508
+ ) : null;
4509
+ };
4510
+
4511
+ // free text input — raw value or var(--token), commits on blur/Enter
4512
+ const text = (prop) => (
4513
+ <input
4514
+ className="st-cp-fin"
4515
+ key={`${prop}:${authored[prop] ?? ''}`}
4516
+ aria-label={prop}
4517
+ defaultValue={authored[prop] ?? ''}
4518
+ placeholder={cssHint(computed[prop]) || '—'}
4519
+ onKeyDown={(e) => {
4520
+ if (e.key === 'Enter') e.currentTarget.blur();
4521
+ }}
4522
+ onBlur={(e) => commit(prop, e.currentTarget.value)}
4523
+ />
4524
+ );
4525
+
4526
+ // number + steppers + unit-select (+ optional token quick-pick after)
4527
+ const num = (prop, tokenList, opts = {}) => {
4528
+ const cur = cssSplitUnit(authored[prop] ?? '');
4529
+ // Unitless CSS properties — a bare number must commit WITHOUT a unit suffix
4530
+ // (line-height: 1.5px ≠ 1.5 — knob-smoke finding, 2026-06-12).
4531
+ const unitless = CSS_UNITLESS.has(prop);
4532
+ const unit = unitless ? '' : cur.unit && cur.unit !== 'auto' ? cur.unit : 'px';
4533
+ const bump = (d) => {
4534
+ const base = Number.parseFloat(cur.n || cssHint(computed[prop]) || '0') || 0;
4535
+ commit(prop, `${Math.round((base + d) * 100) / 100}${unit}`);
4536
+ };
4537
+ const lead = PROP_LEAD[prop];
4538
+ return (
4539
+ <>
4540
+ <div className="st-cp-num">
4541
+ {lead ? (
4542
+ <span className="st-cp-numlead" aria-hidden="true">
4543
+ {lead.t ? lead.t : <StIcon name={lead.icon} size={12} />}
4544
+ </span>
4545
+ ) : null}
4546
+ <input
4547
+ className="st-cp-numin st-cp-scrub"
4548
+ key={`${prop}:${authored[prop] ?? ''}`}
4549
+ aria-label={prop}
4550
+ defaultValue={cur.unit && cur.unit !== '' ? cur.n : (authored[prop] ?? '')}
4551
+ placeholder={cssHint(computed[prop]) || '—'}
4552
+ onPointerDown={makeScrub(prop, { unitless, unit, min: opts.min })}
4553
+ onKeyDown={(e) => {
4554
+ if (e.key === 'Enter') e.currentTarget.blur();
4555
+ }}
4556
+ onBlur={(e) => {
4557
+ const raw = e.currentTarget.value.trim();
4558
+ if (!raw) return;
4559
+ commit(prop, /[a-z%(]/i.test(raw) ? raw : `${raw}${unit}`);
4560
+ }}
4561
+ />
4562
+ <span className="st-cp-step">
4563
+ <button
4564
+ type="button"
4565
+ className="st-cp-stepb"
4566
+ tabIndex={-1}
4567
+ aria-label={`increase ${prop}`}
4568
+ onClick={() => bump(1)}
4569
+ >
4570
+
4571
+ </button>
4572
+ <button
4573
+ type="button"
4574
+ className="st-cp-stepb"
4575
+ tabIndex={-1}
4576
+ aria-label={`decrease ${prop}`}
4577
+ onClick={() => bump(-1)}
4578
+ >
4579
+
4580
+ </button>
4581
+ </span>
4582
+ {unitless ? null : (
4583
+ <select
4584
+ className="st-cp-unitsel"
4585
+ aria-label={`${prop} unit`}
4586
+ value={cur.unit || 'px'}
4587
+ onChange={(e) =>
4588
+ commit(prop, e.target.value === 'auto' ? 'auto' : `${cur.n || '0'}${e.target.value}`)
4589
+ }
4590
+ >
4591
+ {CSS_UNITS.map((u) => (
4592
+ <option key={u} value={u}>
4593
+ {u}
4594
+ </option>
4595
+ ))}
4596
+ </select>
4597
+ )}
4598
+ </div>
4599
+ {tok(prop, tokenList)}
4600
+ </>
4601
+ );
4602
+ };
4603
+
4604
+ // color swatch (native picker → hex) + raw text + token quick-pick
4605
+ const color = (prop) => {
4606
+ // ONE colour control: the swatch is the trigger for a single popover with a
4607
+ // full HSV picker (Custom) + the DS swatches (Variables). No separate native
4608
+ // OS picker (#6 — was two popovers doing the same thing).
4609
+ const resolved = computed[prop] || authored[prop] || '';
4610
+ return (
4611
+ <>
4612
+ <TokenPopover
4613
+ kind="color"
4614
+ groups={tokenGroups('color')}
4615
+ current={authored[prop]}
4616
+ activeDs={_activeDs}
4617
+ swatchBg={resolved}
4618
+ seedHex={cssColorToHex(computed[prop] || authored[prop]) || '#000000'}
4619
+ onPick={(v) => commit(prop, v)}
4620
+ label={`${prop} colour`}
4621
+ />
4622
+ {text(prop)}
4623
+ </>
4624
+ );
4625
+ };
4626
+
4627
+ // a box-model side input (margin/padding longhand). Phase 12.3 — Webflow-style:
4628
+ // always shows the RESOLVED value (0 instead of blank) and a faint `is-zero`
4629
+ // styling for an unset/zero side. Edits the single side (the old "link all
4630
+ // sides" toggle was removed — DDR-104 Phase 12.3 W1.5).
4631
+ const side = (prop, group) => {
4632
+ const a = authored[prop];
4633
+ const shown =
4634
+ a != null && a !== ''
4635
+ ? cssSplitUnit(a).n || a
4636
+ : cssSplitUnit(cssHint(computed[prop]) ?? '').n || '0';
4637
+ const isZero = !a || a === '0' || a === '0px' || a === 'auto';
4638
+ // Webflow scrub modifiers — alt = symmetric pair (block for top/bottom,
4639
+ // inline for left/right), alt+shift = all four.
4640
+ const edge = prop.split('-').pop();
4641
+ const pair =
4642
+ edge === 'top' || edge === 'bottom'
4643
+ ? [`${group}-top`, `${group}-bottom`]
4644
+ : [`${group}-left`, `${group}-right`];
4645
+ const all = [`${group}-top`, `${group}-right`, `${group}-bottom`, `${group}-left`];
4646
+ return (
4647
+ <input
4648
+ className={`st-cp-boxv st-cp-scrub st-cp-boxv--${group[0]}${prop.split('-').pop()[0]}${
4649
+ isZero ? ' is-zero' : ''
4650
+ }`}
4651
+ key={`${prop}:${a ?? ''}`}
4652
+ aria-label={prop}
4653
+ defaultValue={shown}
4654
+ title="drag to scrub · alt = symmetric · alt+shift = all sides"
4655
+ onPointerDown={makeScrub(prop, { sides: { pair, all } })}
4656
+ onKeyDown={(e) => {
4657
+ if (e.key === 'Enter') e.currentTarget.blur();
4658
+ }}
4659
+ onBlur={(e) => {
4660
+ const raw = e.currentTarget.value.trim();
4661
+ if (!raw) return;
4662
+ const val = /[a-z%]/i.test(raw) ? raw : `${raw}px`;
4663
+ commit(prop, val);
4664
+ }}
4665
+ />
4666
+ );
4667
+ };
4668
+
4669
+ const corner = (label, prop) => (
4670
+ <label className="st-cp-cornerf">
4671
+ <span>{label}</span>
4672
+ <input
4673
+ key={`${prop}:${authored[prop] ?? ''}`}
4674
+ aria-label={prop}
4675
+ defaultValue={cssSplitUnit(authored[prop] ?? '').n || ''}
4676
+ placeholder={cssHint(computed[prop]) || '0'}
4677
+ onKeyDown={(e) => {
4678
+ if (e.key === 'Enter') e.currentTarget.blur();
4679
+ }}
4680
+ onBlur={(e) => {
4681
+ const raw = e.currentTarget.value.trim();
4682
+ if (raw) commit(prop, /[a-z%]/i.test(raw) ? raw : `${raw}px`);
4683
+ }}
4684
+ />
4685
+ </label>
4686
+ );
4687
+
4688
+ // Phase 12.3 — authored inline props with no curated row + custom HTML attrs,
4689
+ // surfaced in Advanced so the user can see/edit/remove what they added.
4690
+ const customStyleRows = Object.entries(customStyles);
4691
+ const attrRows = Object.entries(attrs);
4692
+
4693
+ return (
4694
+ <div className="st-cp" key={el.id} data-tour="css-panel">
4695
+ <div className="st-cp-id">
4696
+ <span className="st-cp-idtag">
4697
+ {el.tag || 'element'}
4698
+ {el.classes ? <span className="st-cp-idcls">.{el.classes.split(/\s+/)[0]}</span> : null}
4699
+ </span>
4700
+ <span className="st-cp-idmeta">inline style</span>
4701
+ </div>
4702
+
4703
+ {sec(
4704
+ 'Layout',
4705
+ <>
4706
+ {row('display', csel('display', CSS_DISPLAYS))}
4707
+ {row('flex-direction', csel('flex-direction', CSS_FLEX_DIR))}
4708
+ {row('align-items', csel('align-items', CSS_ALIGN))}
4709
+ {row('justify-content', csel('justify-content', CSS_JUSTIFY))}
4710
+ {row('gap', num('gap', 'space'))}
4711
+ </>
4712
+ )}
4713
+
4714
+ {sec(
4715
+ 'Typography',
4716
+ <>
4717
+ {row('font-family', csel('font-family', CSS_FONTS))}
4718
+ {row('color', color('color'))}
4719
+ {row('font-size', num('font-size', 'type'))}
4720
+ {row('font-weight', csel('font-weight', CSS_WEIGHTS))}
4721
+ {row('line-height', num('line-height', 'lh'))}
4722
+ {row('letter-spacing', num('letter-spacing', null, { min: -Infinity }))}
4723
+ {row(
4724
+ 'text-align',
4725
+ <div className="st-cp-seg" role="group" aria-label="text-align">
4726
+ {CSS_ALIGN_OPTS.map((a) => (
4727
+ <button
4728
+ key={a}
4729
+ type="button"
4730
+ className={`st-cp-segbtn${(authored['text-align'] || computed['text-align']) === a ? ' is-active' : ''}`}
4731
+ aria-label={`align ${a}`}
4732
+ aria-pressed={(authored['text-align'] || computed['text-align']) === a}
4733
+ onClick={() => commit('text-align', a)}
4734
+ >
4735
+ <span className={`st-cp-bars st-cp-bars--${a === 'justify' ? 'just' : a}`} aria-hidden="true">
4736
+ <i />
4737
+ <i />
4738
+ <i />
4739
+ </span>
4740
+ </button>
4741
+ ))}
4742
+ </div>
4743
+ )}
4744
+ </>
4745
+ )}
4746
+
4747
+ {sec(
4748
+ 'Spacing',
4749
+ <>
4750
+ <div className="st-cp-box" aria-label="margin and padding">
4751
+ <span className="st-cp-boxtag st-cp-boxtag--m">
4752
+ {prov(provOf('margin-top'))}margin
4753
+ </span>
4754
+ {side('margin-top', 'margin')}
4755
+ {side('margin-right', 'margin')}
4756
+ {side('margin-bottom', 'margin')}
4757
+ {side('margin-left', 'margin')}
4758
+ <div className="st-cp-boxpad">
4759
+ <span className="st-cp-boxtag st-cp-boxtag--p">
4760
+ {prov(provOf('padding-top'))}padding
4761
+ </span>
4762
+ {side('padding-top', 'padding')}
4763
+ {side('padding-right', 'padding')}
4764
+ {side('padding-bottom', 'padding')}
4765
+ {side('padding-left', 'padding')}
4766
+ <div className="st-cp-boxcore">
4767
+ {Math.round(el.bounds?.w || 0)} × {Math.round(el.bounds?.h || 0)}
4768
+ </div>
4769
+ </div>
4770
+ </div>
4771
+ </>
4772
+ )}
4773
+
4774
+ {sec(
4775
+ 'Size',
4776
+ <>
4777
+ {row('width', num('width'))}
4778
+ {row('height', num('height'))}
4779
+ {row('max-width', num('max-width'))}
4780
+ </>
4781
+ )}
4782
+
4783
+ {sec(
4784
+ 'Appearance',
4785
+ <>
4786
+ {row('background-color', color('background-color'))}
4787
+ <div className="st-cp-row">
4788
+ {prov(provOf('border-radius'))}
4789
+ <label className="st-cp-label" title="border-radius">
4790
+ border-radius
4791
+ </label>
4792
+ <div className="st-cp-ctl">
4793
+ {num('border-radius', 'radius')}
4794
+ <button
4795
+ type="button"
4796
+ className={`st-cp-split${split ? ' is-on' : ''}`}
4797
+ aria-pressed={split}
4798
+ aria-label="set each corner separately"
4799
+ title="set each corner separately"
4800
+ onClick={() => setSplit((v) => !v)}
4801
+ />
4802
+ </div>
4803
+ </div>
4804
+ {split ? (
4805
+ <div className="st-cp-corners" aria-label="per-corner radius">
4806
+ {corner('TL', 'border-top-left-radius')}
4807
+ {corner('TR', 'border-top-right-radius')}
4808
+ {corner('BL', 'border-bottom-left-radius')}
4809
+ {corner('BR', 'border-bottom-right-radius')}
4810
+ </div>
4811
+ ) : null}
4812
+ {row(
4813
+ 'border',
4814
+ <div className="st-cp-border">
4815
+ {num('border-width')}
4816
+ <select
4817
+ className="st-cp-nsel st-cp-nsel--mini"
4818
+ aria-label="border-style"
4819
+ value={CSS_BORDER_STYLES.includes(authored['border-style']) ? authored['border-style'] : ''}
4820
+ onChange={(e) => commit('border-style', e.target.value)}
4821
+ >
4822
+ <option value="" disabled>
4823
+ style
4824
+ </option>
4825
+ {CSS_BORDER_STYLES.map((s) => (
4826
+ <option key={s} value={s}>
4827
+ {s}
4828
+ </option>
4829
+ ))}
4830
+ </select>
4831
+ <TokenPopover
4832
+ kind="color"
4833
+ groups={tokenGroups('color')}
4834
+ current={authored['border-color']}
4835
+ activeDs={_activeDs}
4836
+ swatchBg={computed['border-color'] || authored['border-color'] || ''}
4837
+ seedHex={
4838
+ cssColorToHex(computed['border-color'] || authored['border-color']) || '#000000'
4839
+ }
4840
+ onPick={(v) => commit('border-color', v)}
4841
+ label="border colour"
4842
+ />
4843
+ </div>,
4844
+ provOf('border-width')
4845
+ )}
4846
+ {row('box-shadow', tok('box-shadow', 'shadow') || text('box-shadow'))}
4847
+ {row(
4848
+ 'opacity',
4849
+ <div className="st-cp-num">
4850
+ <span className="st-cp-numlead" aria-hidden="true">
4851
+ <StIcon name="p-opacity" size={12} />
4852
+ </span>
4853
+ <input
4854
+ className="st-cp-numin"
4855
+ key={`opacity:${authored.opacity ?? ''}`}
4856
+ aria-label="opacity"
4857
+ defaultValue={authored.opacity ?? ''}
4858
+ placeholder={cssHint(computed.opacity) || '1'}
4859
+ onKeyDown={(e) => {
4860
+ if (e.key === 'Enter') e.currentTarget.blur();
4861
+ }}
4862
+ onBlur={(e) => commit('opacity', e.currentTarget.value)}
4863
+ />
4864
+ </div>
4865
+ )}
4866
+ </>
4867
+ )}
4868
+
4869
+ {/* #5 — the idle/saved status now lives in each row's leading dot (a glow),
4870
+ so the panel no longer carries a confusing standing 'written to source'
4871
+ line. Only a hard ERROR surfaces here, with the failing property. */}
4872
+ {(() => {
4873
+ const err = Object.entries(status).find(
4874
+ ([, s]) => typeof s === 'string' && s.startsWith('err:')
4875
+ );
4876
+ return err ? (
4877
+ <div className="st-cp-save is-err" role="status">
4878
+ <StIcon name="x" size={12} />
4879
+ {err[0]}: {err[1].slice(4)}
4880
+ </div>
4881
+ ) : null;
4882
+ })()}
4883
+
4884
+ {sec(
4885
+ 'Advanced',
4886
+ <div className="st-cp-advbody">
4887
+ {customStyleRows.length ? (
4888
+ <>
4889
+ <div className="st-cp-advgrp">Custom CSS properties</div>
4890
+ {customStyleRows.map(([p, v]) => (
4891
+ <div className="st-cp-kv" key={`cs:${p}`}>
4892
+ <input
4893
+ className="st-cp-fin st-cp-fin--ro"
4894
+ readOnly
4895
+ value={p}
4896
+ aria-label={`custom property ${p} name`}
4897
+ />
4898
+ <input
4899
+ className="st-cp-fin"
4900
+ key={`cs:${p}:${v}`}
4901
+ defaultValue={v}
4902
+ aria-label={`${p} value`}
4903
+ onKeyDown={(e) => {
4904
+ if (e.key === 'Enter') e.currentTarget.blur();
4905
+ }}
4906
+ onBlur={(e) => commitCustom(p, e.currentTarget.value)}
4907
+ />
4908
+ <button
4909
+ type="button"
4910
+ className="st-cp-kvx"
4911
+ aria-label={`remove ${p}`}
4912
+ title="remove"
4913
+ onClick={() => resetCustom(p)}
4914
+ >
4915
+ <StIcon name="x" size={11} />
4916
+ </button>
4917
+ </div>
4918
+ ))}
4919
+ </>
4920
+ ) : null}
4921
+ <div className="st-cp-advgrp">Add CSS property</div>
4922
+ <RawKnob commit={commitCustom} />
4923
+ <div className="st-cp-note">applied as-is — not token-bound</div>
4924
+ {attrRows.length ? (
4925
+ <>
4926
+ <div className="st-cp-advgrp">Custom HTML attributes</div>
4927
+ {attrRows.map(([a, v]) => (
4928
+ <div className="st-cp-kv" key={`at:${a}`}>
4929
+ <input
4930
+ className="st-cp-fin st-cp-fin--ro"
4931
+ readOnly
4932
+ value={a}
4933
+ aria-label={`attribute ${a} name`}
4934
+ />
4935
+ <input
4936
+ className="st-cp-fin"
4937
+ key={`at:${a}:${v}`}
4938
+ defaultValue={v}
4939
+ aria-label={`${a} value`}
4940
+ onKeyDown={(e) => {
4941
+ if (e.key === 'Enter') e.currentTarget.blur();
4942
+ }}
4943
+ onBlur={(e) => commitAttr(a, e.currentTarget.value)}
4944
+ />
4945
+ <button
4946
+ type="button"
4947
+ className="st-cp-kvx"
4948
+ aria-label={`remove ${a}`}
4949
+ title="remove"
4950
+ onClick={() => resetAttr(a)}
4951
+ >
4952
+ <StIcon name="x" size={11} />
4953
+ </button>
4954
+ </div>
4955
+ ))}
4956
+ </>
4957
+ ) : null}
4958
+ <div className="st-cp-advgrp">Add HTML attribute</div>
4959
+ <AttrKnob commit={commitAttr} />
4960
+ </div>
4961
+ )}
4962
+
4963
+ <div className="st-cp-legend">
4964
+ <span>
4965
+ <i className="st-cp-prov st-cp-prov--bound" aria-hidden="true" />
4966
+ token
4967
+ </span>
4968
+ <span>
4969
+ <i className="st-cp-prov st-cp-prov--raw" aria-hidden="true" />
4970
+ override
4971
+ </span>
4972
+ <span>
4973
+ <i className="st-cp-prov st-cp-prov--inherit" aria-hidden="true" />
4974
+ inherited
4975
+ </span>
4976
+ </div>
4977
+ </div>
4978
+ );
4979
+ }
4980
+
4981
+ // Custom CSS property hatch — writes an arbitrary `property: value` to inline style.
4982
+ function RawKnob({ commit }) {
4983
+ const [prop, setProp] = useState('');
4984
+ const [val, setVal] = useState('');
4985
+ const submit = () => {
4986
+ if (prop.trim() && val.trim()) {
4987
+ commit(prop.trim(), val);
4988
+ setProp('');
4989
+ setVal('');
4990
+ }
4991
+ };
4992
+ return (
4993
+ <div className="st-cp-kv">
4994
+ <input
4995
+ className="st-cp-fin"
4996
+ aria-label="custom property name"
4997
+ placeholder="property"
4998
+ value={prop}
4999
+ onChange={(e) => setProp(e.target.value)}
5000
+ />
5001
+ <input
5002
+ className="st-cp-fin"
5003
+ aria-label="custom property value"
5004
+ placeholder="value"
5005
+ value={val}
5006
+ onChange={(e) => setVal(e.target.value)}
5007
+ onKeyDown={(e) => {
5008
+ if (e.key === 'Enter') submit();
5009
+ }}
5010
+ onBlur={submit}
5011
+ />
5012
+ </div>
5013
+ );
5014
+ }
5015
+
5016
+ // Custom HTML attribute hatch — writes a plain JSX attribute (data-*, aria-*, …).
5017
+ function AttrKnob({ commit }) {
5018
+ const [attr, setAttr] = useState('');
5019
+ const [val, setVal] = useState('');
5020
+ const submit = () => {
5021
+ if (attr.trim() && val.trim()) {
5022
+ commit(attr.trim(), val);
5023
+ setAttr('');
5024
+ setVal('');
5025
+ }
5026
+ };
5027
+ return (
5028
+ <div className="st-cp-kv">
5029
+ <input
5030
+ className="st-cp-fin"
5031
+ aria-label="custom attribute name"
5032
+ placeholder="data-…"
5033
+ value={attr}
5034
+ onChange={(e) => setAttr(e.target.value)}
5035
+ />
5036
+ <input
5037
+ className="st-cp-fin"
5038
+ aria-label="custom attribute value"
5039
+ placeholder="value"
5040
+ value={val}
5041
+ onChange={(e) => setVal(e.target.value)}
5042
+ onKeyDown={(e) => {
5043
+ if (e.key === 'Enter') submit();
5044
+ }}
5045
+ onBlur={submit}
5046
+ />
5047
+ </div>
5048
+ );
5049
+ }
5050
+
2795
5051
  // ---------- Inspector panel (display-only) ----------
2796
5052
  //
2797
5053
  // T6 (Plan C) — right-dock Inspect / Layers / CSS tabs per `.design/ui/Studio.tsx`
@@ -2800,8 +5056,230 @@ function SyncBanner({ status }) {
2800
5056
  // mockup's live-CSS-knob WRITEBACK is Phase 12 (needs a canvas-origin write
2801
5057
  // bridge, DDR-054) — the CSS tab shows markup read-only + keeps that callout, so
2802
5058
  // it never implies functionality it lacks (the exact reason DDR-096 deferred it).
2803
- function InspectorPanel({ selected, onClose }) {
2804
- const [tab, setTab] = useState('inspect');
5059
+ // ---------- Layers tree row (Phase 12 Task 4) ----------
5060
+ // Phase 12.3 (W3.1) — map a LayerNode `type` (classified in canvas-shell) to a
5061
+ // type-distinct icon, matching the Studio.tsx layers design.
5062
+ const LAYER_TYPE_ICON = {
5063
+ button: 'button',
5064
+ heading: 'type',
5065
+ text: 'type',
5066
+ input: 'input',
5067
+ form: 'input',
5068
+ image: 'image',
5069
+ link: 'link',
5070
+ list: 'list',
5071
+ nav: 'layers',
5072
+ box: 'box',
5073
+ };
5074
+
5075
+ function LayerRow({
5076
+ node,
5077
+ depth,
5078
+ selectedId,
5079
+ collapsed,
5080
+ hidden,
5081
+ onToggle,
5082
+ onSelect,
5083
+ onHover,
5084
+ onToggleVisibility,
5085
+ }) {
5086
+ const key = `${node.id}:${node.index}`;
5087
+ const hasKids = node.children && node.children.length > 0;
5088
+ const isCollapsed = collapsed.has(key);
5089
+ const isSel = node.id === selectedId;
5090
+ const isHidden = hidden?.has(key);
5091
+ return (
5092
+ <>
5093
+ <div
5094
+ className={
5095
+ 'st-layer st-layer--row' + (isSel ? ' is-sel' : '') + (isHidden ? ' is-hidden' : '')
5096
+ }
5097
+ style={{ paddingLeft: 6 + depth * 14 }}
5098
+ role="treeitem"
5099
+ aria-selected={isSel}
5100
+ aria-expanded={hasKids ? !isCollapsed : undefined}
5101
+ tabIndex={0}
5102
+ title={`${node.tag} · ${node.type}`}
5103
+ onClick={() => onSelect(node)}
5104
+ onMouseEnter={() => onHover(node)}
5105
+ onMouseLeave={() => onHover(null)}
5106
+ onKeyDown={(e) => {
5107
+ if (e.key === 'Enter' || e.key === ' ') {
5108
+ e.preventDefault();
5109
+ onSelect(node);
5110
+ }
5111
+ }}
5112
+ >
5113
+ {hasKids ? (
5114
+ <button
5115
+ type="button"
5116
+ className="st-layer-caret"
5117
+ aria-label={isCollapsed ? 'Expand' : 'Collapse'}
5118
+ onClick={(e) => {
5119
+ e.stopPropagation();
5120
+ onToggle(key);
5121
+ }}
5122
+ >
5123
+ {isCollapsed ? '▸' : '▾'}
5124
+ </button>
5125
+ ) : (
5126
+ <span className="st-layer-caret" aria-hidden="true" />
5127
+ )}
5128
+ <StIcon name={LAYER_TYPE_ICON[node.type] || 'box'} size={12} className="st-layer-ticon" />
5129
+ <span className="st-layer-label">{node.label}</span>
5130
+ <span className="st-layer-type">{node.type}</span>
5131
+ {onToggleVisibility ? (
5132
+ <button
5133
+ type="button"
5134
+ className="st-layer-eye"
5135
+ aria-label={isHidden ? `Show ${node.label}` : `Hide ${node.label}`}
5136
+ aria-pressed={isHidden}
5137
+ title={isHidden ? 'Show' : 'Hide'}
5138
+ onClick={(e) => {
5139
+ e.stopPropagation();
5140
+ onToggleVisibility(node);
5141
+ }}
5142
+ >
5143
+ <StIcon name={isHidden ? 'eye-off' : 'eye'} size={13} />
5144
+ </button>
5145
+ ) : null}
5146
+ </div>
5147
+ {hasKids && !isCollapsed
5148
+ ? node.children.map((c) => (
5149
+ <LayerRow
5150
+ key={`${c.id}:${c.index}`}
5151
+ node={c}
5152
+ depth={depth + 1}
5153
+ selectedId={selectedId}
5154
+ collapsed={collapsed}
5155
+ hidden={hidden}
5156
+ onToggle={onToggle}
5157
+ onSelect={onSelect}
5158
+ onHover={onHover}
5159
+ onToggleVisibility={onToggleVisibility}
5160
+ />
5161
+ ))
5162
+ : null}
5163
+ </>
5164
+ );
5165
+ }
5166
+
5167
+ // Phase 12.3 — live computed readout for the Inspect tab (replaces the stale
5168
+ // "lands with the live CSS bridge (Phase 12)" callout — that bridge shipped).
5169
+ // Reads the resolved values the selection already carries (dom-selection
5170
+ // styleMapsFor → el.computed). Read-only; the CSS tab is where you edit.
5171
+ function InspectComputed({ el }) {
5172
+ const c = el?.computed || {};
5173
+ const a = el?.authored || {};
5174
+ // Prefer the authored token name (var(--accent) → "--accent") as the label;
5175
+ // fall back to the resolved value. The swatch always shows the RESOLVED color.
5176
+ const valueLabel = (prop) => {
5177
+ const av = a[prop];
5178
+ if (av && /var\(\s*--/.test(av)) return av.replace(/^var\(\s*|\s*\)$/g, '');
5179
+ return c[prop] || av || '';
5180
+ };
5181
+ const colorRow = (lbl, prop) => {
5182
+ const resolved = c[prop] || a[prop];
5183
+ if (!resolved) return null;
5184
+ return (
5185
+ <div className="st-insp-row" key={lbl}>
5186
+ <span className="st-insp-label">{lbl}</span>
5187
+ <div className="st-swatch-row">
5188
+ <span className="st-insp-swatch" style={{ background: resolved }} aria-hidden="true" />
5189
+ <span className="st-mono" style={{ fontSize: 11, color: 'var(--fg-1)' }}>
5190
+ {valueLabel(prop)}
5191
+ </span>
5192
+ </div>
5193
+ </div>
5194
+ );
5195
+ };
5196
+ const hasRadius = c['border-radius'] && c['border-radius'] !== '0px';
5197
+ const radiusN = hasRadius ? cssSplitUnit(c['border-radius']).n || c['border-radius'] : null;
5198
+ const font =
5199
+ c['font-size'] || c['font-weight']
5200
+ ? [c['font-size'], c['font-weight']].filter(Boolean).join(' / ')
5201
+ : null;
5202
+ const anyType = c['background-color'] || c.color || hasRadius || font;
5203
+ if (!anyType) return null;
5204
+ return (
5205
+ <>
5206
+ {hasRadius ? (
5207
+ <div className="st-insp-row">
5208
+ <span className="st-insp-label">Radius</span>
5209
+ <div className="st-insp-fields">
5210
+ <span className="st-fmini" style={{ flex: '0 0 auto', maxWidth: 84 }}>
5211
+ <span className="st-mtag">r</span>
5212
+ <input value={radiusN} readOnly aria-label="border radius" />
5213
+ </span>
5214
+ <span className="st-insp-unit">px</span>
5215
+ </div>
5216
+ </div>
5217
+ ) : null}
5218
+ {colorRow('Fill', 'background-color')}
5219
+ {colorRow('Text', 'color')}
5220
+ {font ? (
5221
+ <div className="st-insp-row">
5222
+ <span className="st-insp-label">Font</span>
5223
+ <div className="st-insp-fields">
5224
+ <span className="st-mono" style={{ fontSize: 11, color: 'var(--fg-0)' }}>{font}</span>
5225
+ </div>
5226
+ </div>
5227
+ ) : null}
5228
+ </>
5229
+ );
5230
+ }
5231
+
5232
+ function InspectorPanel({
5233
+ selected,
5234
+ onClose,
5235
+ layersTree,
5236
+ onSelectLayer,
5237
+ onHoverLayer,
5238
+ cfg,
5239
+ onOptimistic,
5240
+ tab: tabProp,
5241
+ onTabChange,
5242
+ width,
5243
+ resizing,
5244
+ }) {
5245
+ // Tab is controllable from the parent (the guided tour drives it to 'css' /
5246
+ // 'layers' so a spotlight step lands on a real row) but falls back to local
5247
+ // state for normal use. A user click both updates local state and notifies the
5248
+ // parent, so the two stay in lockstep whichever owns it.
5249
+ const [tabState, setTabState] = useState('inspect');
5250
+ const tab = tabProp ?? tabState;
5251
+ const setTab = (t) => {
5252
+ setTabState(t);
5253
+ onTabChange?.(t);
5254
+ };
5255
+ const [collapsed, setCollapsed] = useState(() => new Set());
5256
+ // Phase 12.3 (W3.1) — per-layer visibility toggle. Live-only (display:none via
5257
+ // the optimistic apply bus); not persisted to source. Keyed by `${id}:${index}`.
5258
+ const [hiddenLayers, setHiddenLayers] = useState(() => new Set());
5259
+ const toggleCollapse = (key) =>
5260
+ setCollapsed((prev) => {
5261
+ const next = new Set(prev);
5262
+ if (next.has(key)) next.delete(key);
5263
+ else next.add(key);
5264
+ return next;
5265
+ });
5266
+ const toggleVisibility = (node) => {
5267
+ const key = `${node.id}:${node.index}`;
5268
+ const willHide = !hiddenLayers.has(key);
5269
+ onOptimistic?.({
5270
+ id: node.id,
5271
+ artboardId: layersTree?.artboardId ?? null,
5272
+ index: node.index,
5273
+ prop: 'display',
5274
+ value: willHide ? 'none' : null,
5275
+ });
5276
+ setHiddenLayers((prev) => {
5277
+ const next = new Set(prev);
5278
+ if (willHide) next.add(key);
5279
+ else next.delete(key);
5280
+ return next;
5281
+ });
5282
+ };
2805
5283
  // `selected` may be a single element, an array (multi-select), or null.
2806
5284
  const el = Array.isArray(selected) ? selected[0] : selected;
2807
5285
  const tabBtn = (id, label, icon) => (
@@ -2816,8 +5294,13 @@ function InspectorPanel({ selected, onClose }) {
2816
5294
  );
2817
5295
  const b = el?.bounds || null;
2818
5296
  return (
2819
- <aside className="st-rpanel" aria-label="Inspector">
2820
- <div className="st-rp-tabs">
5297
+ <aside
5298
+ className={'st-rpanel' + (resizing ? ' is-resizing' : '')}
5299
+ style={width ? { width, flexBasis: width } : undefined}
5300
+ aria-label="Inspector"
5301
+ data-tour="inspector"
5302
+ >
5303
+ <div className="st-rp-tabs" data-tour="inspector-tabs">
2821
5304
  {tabBtn('inspect', 'Inspect', 'sliders')}
2822
5305
  {tabBtn('layers', 'Layers', 'layers')}
2823
5306
  {tabBtn('css', 'CSS', 'code')}
@@ -2834,7 +5317,11 @@ function InspectorPanel({ selected, onClose }) {
2834
5317
  <div className="st-rp-body">
2835
5318
  {!el ? (
2836
5319
  <div className="st-rp-empty">
2837
- Hold <Kbd>⌘</Kbd> inside the canvas and click an element to inspect it.
5320
+ {/* <p> wrapper st-rp-empty is a flex column, bare text nodes +
5321
+ kbd would stack as stretched flex items. */}
5322
+ <p>
5323
+ Hold <Kbd>⌘</Kbd> inside the canvas and click an element to inspect it.
5324
+ </p>
2838
5325
  </div>
2839
5326
  ) : tab === 'inspect' ? (
2840
5327
  <>
@@ -2842,26 +5329,26 @@ function InspectorPanel({ selected, onClose }) {
2842
5329
  <div className="st-insp-row">
2843
5330
  <span className="st-insp-label">Pos</span>
2844
5331
  <div className="st-insp-fields">
2845
- <span className="st-field-lead">
2846
- <span className="k">X</span>
2847
- <input className="st-field" value={b ? Math.round(b.x) : '—'} readOnly />
5332
+ <span className="st-fmini">
5333
+ <span className="st-mtag">X</span>
5334
+ <input value={b ? Math.round(b.x) : '—'} readOnly aria-label="x position" />
2848
5335
  </span>
2849
- <span className="st-field-lead">
2850
- <span className="k">Y</span>
2851
- <input className="st-field" value={b ? Math.round(b.y) : '—'} readOnly />
5336
+ <span className="st-fmini">
5337
+ <span className="st-mtag">Y</span>
5338
+ <input value={b ? Math.round(b.y) : '—'} readOnly aria-label="y position" />
2852
5339
  </span>
2853
5340
  </div>
2854
5341
  </div>
2855
5342
  <div className="st-insp-row">
2856
5343
  <span className="st-insp-label">Size</span>
2857
5344
  <div className="st-insp-fields">
2858
- <span className="st-field-lead">
2859
- <span className="k">W</span>
2860
- <input className="st-field" value={b ? Math.round(b.w) : '—'} readOnly />
5345
+ <span className="st-fmini">
5346
+ <span className="st-mtag">W</span>
5347
+ <input value={b ? Math.round(b.w) : '—'} readOnly aria-label="width" />
2861
5348
  </span>
2862
- <span className="st-field-lead">
2863
- <span className="k">H</span>
2864
- <input className="st-field" value={b ? Math.round(b.h) : '—'} readOnly />
5349
+ <span className="st-fmini">
5350
+ <span className="st-mtag">H</span>
5351
+ <input value={b ? Math.round(b.h) : '—'} readOnly aria-label="height" />
2865
5352
  </span>
2866
5353
  </div>
2867
5354
  </div>
@@ -2883,14 +5370,29 @@ function InspectorPanel({ selected, onClose }) {
2883
5370
  </div>
2884
5371
  </div>
2885
5372
  ) : null}
2886
- <div className="callout callout--info" style={{ fontSize: 12 }}>
2887
- Computed fill / radius / type readout lands with the live CSS bridge (Phase 12).
2888
- </div>
5373
+ <InspectComputed el={el} />
2889
5374
  </>
2890
5375
  ) : tab === 'layers' ? (
2891
5376
  <>
2892
- <div className="st-rp-hd">Layers · ancestry</div>
2893
- {Array.isArray(el.dom_path) && el.dom_path.length ? (
5377
+ <div className="st-rp-hd">Layers{layersTree?.nodes?.length ? '' : ' · ancestry'}</div>
5378
+ {layersTree?.nodes?.length ? (
5379
+ <div role="tree" aria-label="Artboard layers">
5380
+ {layersTree.nodes.map((n) => (
5381
+ <LayerRow
5382
+ key={`${n.id}:${n.index}`}
5383
+ node={n}
5384
+ depth={0}
5385
+ selectedId={el.id}
5386
+ collapsed={collapsed}
5387
+ hidden={hiddenLayers}
5388
+ onToggle={toggleCollapse}
5389
+ onSelect={(node) => onSelectLayer?.(node)}
5390
+ onHover={(node) => onHoverLayer?.(node)}
5391
+ onToggleVisibility={toggleVisibility}
5392
+ />
5393
+ ))}
5394
+ </div>
5395
+ ) : Array.isArray(el.dom_path) && el.dom_path.length ? (
2894
5396
  el.dom_path.map((node, i) => (
2895
5397
  <div
2896
5398
  key={i}
@@ -2902,25 +5404,13 @@ function InspectorPanel({ selected, onClose }) {
2902
5404
  </div>
2903
5405
  ))
2904
5406
  ) : (
2905
- <div className="st-rp-empty">No ancestry path for this selection.</div>
5407
+ <div className="st-rp-empty">
5408
+ Select an element (⌘-click in the canvas) to see its layer tree.
5409
+ </div>
2906
5410
  )}
2907
5411
  </>
2908
5412
  ) : (
2909
- <>
2910
- <div className="st-rp-hd">Markup · {el.selector || el.tag}</div>
2911
- <div className="st-css">
2912
- <div>
2913
- <span className="comment">/* read-only — outerHTML snapshot */</span>
2914
- </div>
2915
- <div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
2916
- {(el.html || '').slice(0, 600) || '(no markup captured)'}
2917
- </div>
2918
- </div>
2919
- <div className="callout callout--info" style={{ fontSize: 12 }}>
2920
- Phase 12 — knob edits will write back to the artboard live and stage a diff for
2921
- handoff. Today the inspector is read-only.
2922
- </div>
2923
- </>
5413
+ <CssKnobs el={el} cfg={cfg} onOptimistic={onOptimistic} />
2924
5414
  )}
2925
5415
  </div>
2926
5416
  </aside>
@@ -2935,6 +5425,15 @@ function App() {
2935
5425
  const [tabs, setTabs] = useState([]);
2936
5426
  const [activePath, setActivePath] = useState(null);
2937
5427
  const [selected, setSelected] = useState(null);
5428
+ // Phase 12.3 — latest selection, readable from the (stale-closure) onMessage
5429
+ // handler so an HMR reload (triggered by a CSS/attr edit) can re-select the
5430
+ // same element and restore the in-canvas halo the remount dropped.
5431
+ const selectedRef = useRef(null);
5432
+ useEffect(() => {
5433
+ selectedRef.current = selected;
5434
+ }, [selected]);
5435
+ // Phase 12 Task 4 — Layers tree for the active artboard (posted by canvas-shell).
5436
+ const [layersTree, setLayersTree] = useState(null);
2938
5437
  const [wsConnected, setWsConnected] = useState(false);
2939
5438
  // Phase 8 Task 7 — git lifecycle reload prompt. Server has already flushed
2940
5439
  // every dirty Y.Doc to disk by the time this state populates, so accepting
@@ -2943,8 +5442,65 @@ function App() {
2943
5442
  // Phase 9 Task 8 — hub-down offline mode banner. Driven by the 'sync:status'
2944
5443
  // WS message the linked-mode sync runtime emits. null in solo mode.
2945
5444
  const [syncStatus, setSyncStatus] = useState(null);
5445
+ // Phase 27 (E2) — in-UI git layer. `gitStatus` is the live dirty-state the
5446
+ // server broadcasts on `git-status`; `changesOpen` toggles the Changes panel;
5447
+ // `diffTarget` opens the before/after DiffView ({ file, conflict }).
5448
+ const [gitStatus, setGitStatus] = useState(null);
5449
+ // Phase 28 (E3) — remote ahead/behind ("Get latest" nudge). Kept in its OWN
5450
+ // slice, NOT folded into `gitStatus`, because the `git-status` WS broadcast
5451
+ // (line ~5791) replaces `gitStatus` on every dirty-state change and carries
5452
+ // only LOCAL status — merging remote-ahead into it would be clobbered on the
5453
+ // next keystroke. The probe is a real network `git fetch` (server-side, token
5454
+ // from the keychain bridge), so it runs on a slow cadence — mount, a periodic
5455
+ // tick, and after each git action — never on the per-edit WS path.
5456
+ const [remoteSync, setRemoteSync] = useState(null); // { remoteAhead, behind } | null
5457
+ const [changesOpen, setChangesOpen] = useState(false);
5458
+ const [diffTarget, setDiffTarget] = useState(null);
2946
5459
  const [search, setSearch] = useState('');
2947
5460
  const [systemData, setSystemData] = useState(null);
5461
+ // Canvas-compile skeleton (single-canvas model → one path at a time).
5462
+ const [loadingPath, setLoadingPath] = useState(null);
5463
+ const loadFallbackTimer = useRef(null);
5464
+ // Resizable side panels (DS components-resize-panels) + the active drag side.
5465
+ const sbSize = usePanelSize('maude-sb-w', { min: 200, max: 420, def: 252 });
5466
+ const rpSize = usePanelSize('maude-rp-w', { min: 260, max: 480, def: 304 });
5467
+ const [dragSide, setDragSide] = useState(null); // 'sb' | 'rp' | null
5468
+ const bodyRef = useRef(null);
5469
+
5470
+ // Pointer drag for the panel grips — window listeners while dragging (the
5471
+ // grip also pointer-captures, and `.st-body.is-resizing iframe` drops pointer
5472
+ // events so the canvas iframe can't swallow the move stream mid-drag).
5473
+ useEffect(() => {
5474
+ if (!dragSide) return;
5475
+ const onMove = (e) => {
5476
+ const rect = bodyRef.current?.getBoundingClientRect();
5477
+ if (!rect) return;
5478
+ if (dragSide === 'sb') sbSize.setW(e.clientX - rect.left);
5479
+ else rpSize.setW(rect.right - e.clientX);
5480
+ };
5481
+ const onUp = () => setDragSide(null);
5482
+ window.addEventListener('pointermove', onMove);
5483
+ window.addEventListener('pointerup', onUp);
5484
+ return () => {
5485
+ window.removeEventListener('pointermove', onMove);
5486
+ window.removeEventListener('pointerup', onUp);
5487
+ };
5488
+ }, [dragSide, sbSize.setW, rpSize.setW]);
5489
+
5490
+ // Loading-skeleton lifecycle: dgn:'loaded' clears it instantly (TSX canvases);
5491
+ // the iframe load event arms a short fallback for legacy .html canvases that
5492
+ // never post it; a hard cap guards against a canvas that dies mid-compile.
5493
+ const onIframeLoad = useCallback((path) => {
5494
+ clearTimeout(loadFallbackTimer.current);
5495
+ loadFallbackTimer.current = setTimeout(() => {
5496
+ setLoadingPath((p) => (p === path ? null : p));
5497
+ }, 2500);
5498
+ }, []);
5499
+ useEffect(() => {
5500
+ if (!loadingPath) return;
5501
+ const cap = setTimeout(() => setLoadingPath(null), 15000);
5502
+ return () => clearTimeout(cap);
5503
+ }, [loadingPath]);
2948
5504
  // Loaded once at boot from /_config — informs canvasUrl() so TSX iframes
2949
5505
  // can pass the right ?designRel + ?tokens query to the canvas mount shell.
2950
5506
  const [cfg, setCfg] = useState({ designRel: '.design' });
@@ -2996,6 +5552,20 @@ function App() {
2996
5552
  cancelled = true;
2997
5553
  };
2998
5554
  }, []);
5555
+ // Phase 27 (E2) — seed the git dirty-state on mount; live updates arrive over
5556
+ // the `git-status` WS broadcast (Task 5). Solo/non-git projects → repo:false.
5557
+ useEffect(() => {
5558
+ let cancelled = false;
5559
+ fetch('/_api/git/status')
5560
+ .then((r) => r.json())
5561
+ .then((data) => {
5562
+ if (!cancelled && data) setGitStatus(data);
5563
+ })
5564
+ .catch(() => {});
5565
+ return () => {
5566
+ cancelled = true;
5567
+ };
5568
+ }, []);
2999
5569
  const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
3000
5570
  // Phase 6 — the in-iframe composer owns drafting; the shell no longer holds
3001
5571
  // a `draft` state. Mutations route through postMessage → WS instead.
@@ -3008,17 +5578,180 @@ function App() {
3008
5578
  const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
3009
5579
  const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
3010
5580
  const [helpOpen, setHelpOpen] = useState(false);
5581
+ // ? cheat-sheet (DS components-shortcuts-overlay) — separate from the deep
5582
+ // Help modal (F1), which keeps commands & flows.
5583
+ const [shortcutsOpen, setShortcutsOpen] = useState(false);
3011
5584
  const [paletteOpen, setPaletteOpen] = useState(false);
3012
5585
  // T5/T6 (Plan C) — shell-level export/handoff dialog + inspector panel state.
3013
5586
  // The palette (T4) drives them; the dialog (T5) + panel (T6) consume them.
3014
5587
  const [exportDialog, setExportDialog] = useState(null); // null | { mode: 'export'|'handoff', scope? }
3015
5588
  const [inspectorOpen, setInspectorOpen] = useState(false);
5589
+ // Phase 31 (DDR-123) — the native ACP chat sidepanel (right dock, native-only).
5590
+ const [assistantOpen, setAssistantOpen] = useState(false);
5591
+ const [assistantBusy, setAssistantBusy] = useState(false);
5592
+ const [assistantUnseen, setAssistantUnseen] = useState(false);
5593
+ const assistantOpenRef = useRef(assistantOpen);
5594
+ useEffect(() => {
5595
+ assistantOpenRef.current = assistantOpen;
5596
+ if (assistantOpen) {
5597
+ setAssistantUnseen(false); // opening clears the unseen badge
5598
+ // Ask for notification permission on a real user gesture (panel open).
5599
+ try {
5600
+ if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
5601
+ Notification.requestPermission();
5602
+ }
5603
+ } catch {
5604
+ /* notifications unavailable */
5605
+ }
5606
+ }
5607
+ }, [assistantOpen]);
5608
+ // ChatPanel owns one connection PER CHAT (so chats run in parallel in the
5609
+ // background) and reports up here: `onBusyChange` for the menubar pulse, and
5610
+ // `onFinished` when any chat's turn ends — badge + notify if you weren't looking.
5611
+ const handleAssistantFinished = useCallback(() => {
5612
+ if (!assistantOpenRef.current || document.hidden) {
5613
+ setAssistantUnseen(true);
5614
+ try {
5615
+ if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
5616
+ new Notification('Claude finished', { body: 'Your assistant turn is ready in Maude.' });
5617
+ }
5618
+ } catch {
5619
+ /* best-effort — the in-app badge is the reliable signal */
5620
+ }
5621
+ }
5622
+ }, []);
5623
+ // Inspector tab is lifted so View ▸ Layers can open the panel ON the Layers
5624
+ // tab (the menu item sat disabled as "Phase 12" long after the tab shipped).
5625
+ const [inspectorTab, setInspectorTab] = useState('inspect');
5626
+ // The right dock holds exactly ONE panel (Changes / Inspector / Comments) at
5627
+ // a time — opening any panel REPLACES whatever was there. These two helpers
5628
+ // are the single source of that invariant; every open/toggle path routes
5629
+ // through them. (Before, the three booleans were flipped independently across
5630
+ // ~13 call sites and only some closed their siblings, so a panel opened via a
5631
+ // path that left a sibling `true` rendered *behind* it under the fixed
5632
+ // precedence — looking like the new panel "overlapped" the old one.)
5633
+ const openRightPanel = useCallback((which) => {
5634
+ setChangesOpen(which === 'changes');
5635
+ setInspectorOpen(which === 'inspector');
5636
+ setCommentsPanelOpen(which === 'comments');
5637
+ setAssistantOpen(which === 'assistant');
5638
+ }, []);
5639
+ // Functional updates so this is stale-closure-safe inside the keydown /
5640
+ // postMessage listeners; opening always clears the sibling panels.
5641
+ const toggleRightPanel = useCallback((which) => {
5642
+ if (which === 'inspector') {
5643
+ setInspectorOpen((v) => {
5644
+ if (!v) {
5645
+ setChangesOpen(false);
5646
+ setCommentsPanelOpen(false);
5647
+ setAssistantOpen(false);
5648
+ }
5649
+ return !v;
5650
+ });
5651
+ } else if (which === 'comments') {
5652
+ setCommentsPanelOpen((v) => {
5653
+ if (!v) {
5654
+ setChangesOpen(false);
5655
+ setInspectorOpen(false);
5656
+ setAssistantOpen(false);
5657
+ }
5658
+ return !v;
5659
+ });
5660
+ } else if (which === 'changes') {
5661
+ setChangesOpen((v) => {
5662
+ if (!v) {
5663
+ setInspectorOpen(false);
5664
+ setCommentsPanelOpen(false);
5665
+ setAssistantOpen(false);
5666
+ }
5667
+ return !v;
5668
+ });
5669
+ } else if (which === 'assistant') {
5670
+ setAssistantOpen((v) => {
5671
+ if (!v) {
5672
+ setChangesOpen(false);
5673
+ setInspectorOpen(false);
5674
+ setCommentsPanelOpen(false);
5675
+ }
5676
+ return !v;
5677
+ });
5678
+ }
5679
+ }, []);
3016
5680
  const whatsNew = useWhatsNew(MDCC_VERSION);
5681
+ // Phase 29 (E4) — first-run onboarding wizard. The native shell boots a minimal
5682
+ // "welcome" project on first launch; we ask it whether this is a first run and, if
5683
+ // so, show the wizard OVER the (empty) canvas browser. Completing any door switches
5684
+ // the sidecar to a real project (the webview reloads → first-run is then false).
5685
+ const [firstRun, setFirstRun] = useState(false);
5686
+ // Offer the collab "rychlý kurz" once, AFTER onboarding (native app, not first run,
5687
+ // not yet seen). A returning user who already took it isn't re-nudged.
5688
+ const [collabNudge, setCollabNudge] = useState(false);
5689
+ useEffect(() => {
5690
+ if (!isNativeApp()) return undefined;
5691
+ let alive = true;
5692
+ appIsFirstRun()
5693
+ .then((v) => {
5694
+ if (!alive) return;
5695
+ setFirstRun(!!v);
5696
+ if (!v && !readBoolStore(COLLAB_TOUR_STORE, false)) setCollabNudge(true);
5697
+ })
5698
+ .catch(() => {});
5699
+ return () => {
5700
+ alive = false;
5701
+ };
5702
+ }, []);
5703
+ const markCollabSeen = useCallback(() => {
5704
+ setCollabNudge(false);
5705
+ try {
5706
+ localStorage.setItem(COLLAB_TOUR_STORE, '1');
5707
+ } catch {}
5708
+ }, []);
5709
+ // Phase 32 (Task 1) — auto-update. The native shell downloads + stages a newer
5710
+ // build in the background and emits `update-ready`; we surface a non-blocking
5711
+ // banner. Native-only (the web studio is updated by its own deploy).
5712
+ const [updateReady, setUpdateReady] = useState(null);
5713
+ useEffect(() => {
5714
+ if (!isNativeApp()) return undefined;
5715
+ let un;
5716
+ onUpdateReady((p) => setUpdateReady(p && typeof p === 'object' ? p : {}))
5717
+ .then((fn) => {
5718
+ un = fn;
5719
+ })
5720
+ .catch(() => {});
5721
+ return () => {
5722
+ try {
5723
+ un?.();
5724
+ } catch {}
5725
+ };
5726
+ }, []);
3017
5727
  const [tourSteps, setTourSteps] = useState(null);
3018
5728
  const [usageNudge, setUsageNudge] = useState(() => !readBoolStore(USAGE_TOUR_STORE, false));
3019
5729
  const startTour = useCallback((steps) => {
3020
5730
  setTourSteps(Array.isArray(steps) && steps.length ? steps : null);
3021
5731
  }, []);
5732
+ // Guided-tour bus — the overlay calls setup() before each step to put the shell
5733
+ // into the state the step spotlights: open a canvas, open the Inspector, switch
5734
+ // its tab. The canvas iframe is cross-origin (DDR-054) so the tour can't select
5735
+ // an element for the user; requireSelection steps instead wait for a real
5736
+ // ⌘-click. Plain object (the overlay refs it), so per-render churn is harmless.
5737
+ const tourBus = {
5738
+ setup: (step) => {
5739
+ if (!step) return;
5740
+ if ((step.canvas || step.requireSelection) && tabs.length === 0) {
5741
+ setSidebarOpen(true);
5742
+ setTimeout(() => {
5743
+ try {
5744
+ document.querySelector('.st-sidebar [role="treeitem"]')?.click();
5745
+ } catch {}
5746
+ }, 80);
5747
+ }
5748
+ if (step.inspector || step.tab || step.requireSelection) openRightPanel('inspector');
5749
+ if (step.tab) setInspectorTab(step.tab);
5750
+ // Phase 29 (E4) collab tour — open the Changes panel so the Save / Publish /
5751
+ // Get-latest controls the action steps spotlight actually exist to anchor on.
5752
+ if (step.changes) openRightPanel('changes');
5753
+ },
5754
+ };
3022
5755
  const markUsageSeen = useCallback(() => {
3023
5756
  setUsageNudge(false);
3024
5757
  try {
@@ -3026,6 +5759,13 @@ function App() {
3026
5759
  } catch {}
3027
5760
  }, []);
3028
5761
  const [annotationsVisible, setAnnotationsVisible] = useState(true);
5762
+ // Canvas-chrome visibility (View menu). minimap + zoom-controls are
5763
+ // persistent prefs broadcast to every open canvas iframe; presentMode is a
5764
+ // non-destructive "hide ALL chrome + shell, artboards only" overlay with an
5765
+ // Esc / floating-pill escape hatch back to the chrome.
5766
+ const [minimapVisible, setMinimapVisible] = useState(true);
5767
+ const [zoomCtlVisible, setZoomCtlVisible] = useState(true);
5768
+ const [presentMode, setPresentMode] = useState(false);
3029
5769
  // P2/P3 (Plan C) — top-bar live state. (Zoom lives in the canvas toolbar pill,
3030
5770
  // so the top bar no longer mirrors it.)
3031
5771
  // activeArtboards — real artboard count of the open canvas, read from its
@@ -3068,6 +5808,42 @@ function App() {
3068
5808
  });
3069
5809
  }, [activePath]);
3070
5810
 
5811
+ // Chrome visibility (minimap / zoom-controls / Presentation Mode) applies to
5812
+ // EVERY open canvas iframe, not just the active one — broadcast to all. A
5813
+ // freshly-loaded iframe is seeded from the dgn:'loaded' handler below.
5814
+ const broadcastChrome = useCallback((patch) => {
5815
+ for (const el of iframesRef.current.values()) {
5816
+ try {
5817
+ el.contentWindow.postMessage({ dgn: 'view-chrome', ...patch }, '*');
5818
+ } catch {}
5819
+ }
5820
+ }, []);
5821
+ const toggleMinimap = useCallback(() => {
5822
+ setMinimapVisible((v) => {
5823
+ const next = !v;
5824
+ broadcastChrome({ minimap: next });
5825
+ return next;
5826
+ });
5827
+ }, [broadcastChrome]);
5828
+ const toggleZoomCtl = useCallback(() => {
5829
+ setZoomCtlVisible((v) => {
5830
+ const next = !v;
5831
+ broadcastChrome({ zoom: next });
5832
+ return next;
5833
+ });
5834
+ }, [broadcastChrome]);
5835
+ const togglePresent = useCallback(() => {
5836
+ setPresentMode((v) => {
5837
+ const next = !v;
5838
+ broadcastChrome({ present: next });
5839
+ return next;
5840
+ });
5841
+ }, [broadcastChrome]);
5842
+ const exitPresent = useCallback(() => {
5843
+ setPresentMode(false);
5844
+ broadcastChrome({ present: false });
5845
+ }, [broadcastChrome]);
5846
+
3071
5847
  // P3 (Plan C) — local git user for the menubar presence avatar. One-shot.
3072
5848
  useEffect(() => {
3073
5849
  let cancelled = false;
@@ -3245,9 +6021,9 @@ function App() {
3245
6021
  try {
3246
6022
  const m = JSON.parse(e.data);
3247
6023
  if (m.type === 'snapshot' && m.state) {
3248
- setSelected(m.state.selected);
6024
+ setSelected((prev) => mergeSelClientFields(m.state.selected, prev));
3249
6025
  } else if (m.type === 'selected') {
3250
- setSelected(m.selected);
6026
+ setSelected((prev) => mergeSelClientFields(m.selected, prev));
3251
6027
  } else if (m.type === 'comments' && typeof m.file === 'string') {
3252
6028
  setCommentsByFile((prev) => ({ ...prev, [m.file]: m.comments || [] }));
3253
6029
  } else if (m.type === 'ai-activity' && typeof m.file === 'string') {
@@ -3271,6 +6047,20 @@ function App() {
3271
6047
  } else if (m.type === 'sync:status' && m.payload) {
3272
6048
  // Phase 9 Task 8 — hub connection state for the offline banner.
3273
6049
  setSyncStatus(m.payload);
6050
+ } else if (m.type === 'canvas-list-update') {
6051
+ // Phase 30 — a canvas was created/deleted on THIS dev-server; re-read
6052
+ // the branch-scoped tree so other open tabs reflect it without a
6053
+ // reload. Cross-machine peers get a new canvas via git "Get latest".
6054
+ loadTree();
6055
+ } else if (m.type === 'acp-focus') {
6056
+ // Phase 31 (DDR-123) — `/design:chat` from the terminal asked us to
6057
+ // surface the native ACP chat sidepanel. Native-only (the panel
6058
+ // doesn't exist on the web surface).
6059
+ if (isNativeApp()) openRightPanel('assistant');
6060
+ } else if (m.type === 'git-status' && m.payload) {
6061
+ // Phase 27 (E2) Task 5 — live dirty-state. Updates the Changes-panel
6062
+ // count + tree M/A/D badges reactively, no polling.
6063
+ setGitStatus(m.payload);
3274
6064
  } else if (m.type === 'git-lifecycle' && m.payload) {
3275
6065
  // Phase 8 Task 7 — branch switch / pull mid-session. Server has
3276
6066
  // already flushed every dirty Y.Doc to JSON; just prompt the user.
@@ -3289,7 +6079,9 @@ function App() {
3289
6079
  }
3290
6080
  connect();
3291
6081
  return () => wsRef.current && wsRef.current.close();
3292
- }, []);
6082
+ // loadTree is a stable useCallback([]); listed so the canvas-list-update
6083
+ // handler always calls the live reference.
6084
+ }, [loadTree]);
3293
6085
 
3294
6086
  function wsSend(obj) {
3295
6087
  const ws = wsRef.current;
@@ -3298,6 +6090,144 @@ function App() {
3298
6090
  } catch {}
3299
6091
  }
3300
6092
 
6093
+ // ----- Phase 27 (E2) — git actions -----
6094
+ // All write actions POST same-origin (the dev-server's sameOriginWrite + the
6095
+ // dual-allowlist gate them main-origin only). After a mutation we refresh
6096
+ // status optimistically; the `git-status` WS broadcast also lands shortly.
6097
+ const refreshGitStatus = useCallback(async () => {
6098
+ try {
6099
+ const r = await fetch('/_api/git/status');
6100
+ if (r.ok) setGitStatus(await r.json());
6101
+ } catch {}
6102
+ }, []);
6103
+
6104
+ // Phase 28 (E3) — probe the tracking remote so the Changes panel can surface
6105
+ // the "Get latest" nudge (GitPanel reads `status.remoteAhead` / `status.behind`).
6106
+ // `?remote=1` is what makes the server do the `git fetch` + ahead/behind count;
6107
+ // without it the status is local-only and the nudge never fires. Network call —
6108
+ // call sparingly (mount / interval / post-action), never on the WS hot path.
6109
+ const refreshRemoteSync = useCallback(async () => {
6110
+ try {
6111
+ const r = await fetch('/_api/git/status?remote=1');
6112
+ if (!r.ok) return;
6113
+ const data = await r.json();
6114
+ if (data && data.repo !== false)
6115
+ setRemoteSync({ remoteAhead: !!data.remoteAhead, behind: data.behind || 0 });
6116
+ } catch {}
6117
+ }, []);
6118
+
6119
+ const gitPostJson = useCallback(async (path, body) => {
6120
+ try {
6121
+ const r = await fetch(path, {
6122
+ method: 'POST',
6123
+ headers: { 'Content-Type': 'application/json' },
6124
+ body: JSON.stringify(body || {}),
6125
+ });
6126
+ const data = await r.json().catch(() => ({}));
6127
+ return { ok: r.ok, ...data };
6128
+ } catch (e) {
6129
+ return { ok: false, error: 'Network error — is the project still open?' };
6130
+ }
6131
+ }, []);
6132
+
6133
+ const gitCommit = useCallback(
6134
+ async (message, files) => {
6135
+ const res = await gitPostJson('/_api/git/commit', { message, files });
6136
+ if (res.ok) await refreshGitStatus();
6137
+ return res;
6138
+ },
6139
+ [gitPostJson, refreshGitStatus]
6140
+ );
6141
+
6142
+ const gitDiscard = useCallback(
6143
+ async (files) => {
6144
+ const res = await gitPostJson('/_api/git/discard', { files });
6145
+ if (res.ok) await refreshGitStatus();
6146
+ return res;
6147
+ },
6148
+ [gitPostJson, refreshGitStatus]
6149
+ );
6150
+
6151
+ const gitPublish = useCallback(async () => {
6152
+ const res = await gitPostJson('/_api/git/push', {});
6153
+ // Refresh so the "N versions ready to publish" count clears to 0 after a
6154
+ // successful push (the server advanced the local remote-tracking ref), and
6155
+ // re-probe the remote so a stale "Get latest" nudge clears.
6156
+ if (res.ok) {
6157
+ await refreshGitStatus();
6158
+ refreshRemoteSync();
6159
+ }
6160
+ return res;
6161
+ }, [gitPostJson, refreshGitStatus, refreshRemoteSync]);
6162
+
6163
+ const gitGetLatest = useCallback(async () => {
6164
+ const res = await gitPostJson('/_api/git/pull', {});
6165
+ // On success the remote is merged in — clear the nudge by re-probing.
6166
+ if (res.ok) {
6167
+ await refreshGitStatus();
6168
+ refreshRemoteSync();
6169
+ }
6170
+ // A true content conflict → open the visual resolver on the first file.
6171
+ if (res.conflict && Array.isArray(res.files) && res.files.length) {
6172
+ setDiffTarget({ file: res.files[0], conflict: true });
6173
+ }
6174
+ return res;
6175
+ }, [gitPostJson, refreshGitStatus, refreshRemoteSync]);
6176
+
6177
+ // Phase 28 (E3) — finish a Get-latest conflict from the DiffView resolver.
6178
+ // `choice` is 'mine' | 'theirs' | 'both'; the server completes the two-parent
6179
+ // merge commit (and, for 'both', writes our version as a "(mine)" copy).
6180
+ const gitResolveConflict = useCallback(
6181
+ async (choice) => {
6182
+ const res = await gitPostJson('/_api/git/resolve', { choice });
6183
+ if (res.ok) {
6184
+ await refreshGitStatus();
6185
+ refreshRemoteSync();
6186
+ }
6187
+ return res;
6188
+ },
6189
+ [gitPostJson, refreshGitStatus, refreshRemoteSync]
6190
+ );
6191
+
6192
+ // `path` (optional) scopes History to one canvas — the per-file version list
6193
+ // behind the History click-to-preview + DiffView "Saved version" picker
6194
+ // (phase-27.1). Omit for the repo-wide log.
6195
+ const gitLoadLog = useCallback(async (path) => {
6196
+ try {
6197
+ const qs = '/_api/git/log?limit=40' + (path ? `&path=${encodeURIComponent(path)}` : '');
6198
+ const r = await fetch(qs);
6199
+ if (!r.ok) return [];
6200
+ const data = await r.json();
6201
+ return data.entries || [];
6202
+ } catch {
6203
+ return [];
6204
+ }
6205
+ }, []);
6206
+
6207
+ // Repo-relative path → M/A/D/U badge for the tree (paths match: both the tree
6208
+ // and gitStatus use `.design/ui/Foo.tsx`). Keyed off gitStatus so it updates
6209
+ // live with the WS broadcast.
6210
+ const dirtyByPath = useMemo(() => {
6211
+ const KIND = { modified: 'M', added: 'A', deleted: 'D', untracked: 'U' };
6212
+ const m = new Map();
6213
+ for (const f of gitStatus?.files || []) m.set(f.path, KIND[f.status]);
6214
+ return m;
6215
+ }, [gitStatus]);
6216
+ const unsavedCount = gitStatus?.files?.length || 0;
6217
+
6218
+ // Phase 28 (E3) — keep remote ahead/behind fresh so the "Get latest" nudge
6219
+ // surfaces on its own: probe once a repo is known, again whenever the Changes
6220
+ // panel opens, and on a slow 60 s tick WHILE the panel is open (a teammate's
6221
+ // publish then shows up without the user first attempting their own publish).
6222
+ // Declared after `refreshRemoteSync` to avoid a temporal-dead-zone on the dep.
6223
+ useEffect(() => {
6224
+ if (gitStatus?.repo === false) return; // solo / non-git project — no remote
6225
+ refreshRemoteSync();
6226
+ if (!changesOpen) return; // only keep polling while the panel is visible
6227
+ const id = setInterval(refreshRemoteSync, 60000);
6228
+ return () => clearInterval(id);
6229
+ }, [gitStatus?.repo, changesOpen, refreshRemoteSync]);
6230
+
3301
6231
  // ----- Tab management (single-canvas) -----
3302
6232
  // Single-canvas model: opening a file REPLACES the active one (no tab strip).
3303
6233
  // The `tabs` state stays as a 0-or-1 array so the rest of the plumbing
@@ -3311,6 +6241,9 @@ function App() {
3311
6241
  });
3312
6242
  setActivePath(path);
3313
6243
  setFocusedCommentId(null);
6244
+ // Canvas-compile skeleton — cleared by the iframe's dgn:'loaded' message,
6245
+ // the onLoad fallback timer (legacy .html), or a hard 15s cap.
6246
+ if (path !== SYSTEM_TAB) setLoadingPath(path);
3314
6247
  }, []);
3315
6248
 
3316
6249
  const openSystem = useCallback(
@@ -3349,6 +6282,7 @@ function App() {
3349
6282
  return next;
3350
6283
  });
3351
6284
  iframesRef.current.delete(path);
6285
+ setLoadingPath((p) => (p === path ? null : p));
3352
6286
  },
3353
6287
  [activePath]
3354
6288
  );
@@ -3435,15 +6369,17 @@ function App() {
3435
6369
  }, [activePath]);
3436
6370
 
3437
6371
  // ----- Push comments to iframe whenever they change for active file -----
6372
+ // Presentation Mode hides comment pins: post an empty list while present
6373
+ // (re-posting the real list on exit, since this effect re-runs on the flag).
3438
6374
  useEffect(() => {
3439
6375
  if (!activePath || activePath === SYSTEM_TAB) return;
3440
6376
  const el = iframesRef.current.get(activePath);
3441
6377
  if (!el || !el.contentWindow) return;
3442
- const list = commentsByFile[activePath] || [];
6378
+ const list = presentMode ? [] : commentsByFile[activePath] || [];
3443
6379
  try {
3444
6380
  el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*');
3445
6381
  } catch {}
3446
- }, [activePath, commentsByFile]);
6382
+ }, [activePath, commentsByFile, presentMode]);
3447
6383
 
3448
6384
  // ----- Inbound messages from iframes -----
3449
6385
  useEffect(() => {
@@ -3507,6 +6443,45 @@ function App() {
3507
6443
  } else if (m.dgn === 'clear-select') {
3508
6444
  wsSend({ type: 'clear-select' });
3509
6445
  setSelected(null);
6446
+ } else if (m.dgn === 'edit-text' && m.id) {
6447
+ // Phase 12 (DDR-103) — inline text edit committed in the canvas. POST to
6448
+ // the main-origin-only /_api/edit-text → editText writes the escaped
6449
+ // JSXText to source; the file-watcher HMR reload then shows the new text.
6450
+ // A refusal (mixed/expression content) is logged, not fatal.
6451
+ fetch('/_api/edit-text', {
6452
+ method: 'POST',
6453
+ headers: { 'content-type': 'application/json' },
6454
+ body: JSON.stringify({ canvas: m.file, id: m.id, text: m.text ?? '' }),
6455
+ })
6456
+ .then((r) => r.json().catch(() => ({})))
6457
+ .then((j) => {
6458
+ if (!j.ok) console.warn('[edit-text]', j.error || 'failed');
6459
+ })
6460
+ .catch(() => {});
6461
+ } else if (m.dgn === 'layers-tree') {
6462
+ // Phase 12 Task 4 — browsable layers tree for the active artboard.
6463
+ setLayersTree({ artboardId: m.artboardId, nodes: Array.isArray(m.tree) ? m.tree : [] });
6464
+ } else if (m.dgn === 'open-inspector') {
6465
+ // Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
6466
+ openRightPanel('inspector');
6467
+ } else if (m.dgn === 'present-enter') {
6468
+ // Canvas tool-palette "Presentation mode" button — Present Mode is a
6469
+ // shell-level state (hides the menubar / sidebar / panels), so the
6470
+ // canvas requests it here and the shell flips it on + broadcasts
6471
+ // dgn:'view-chrome' back to every iframe. Enter-only (the palette is
6472
+ // hidden while presenting); exit is Esc or the floating pill. The
6473
+ // inbound origin gate above (DDR-054) already authenticates the canvas.
6474
+ // Hardening (phase-28 audit F-2): honor it ONLY from the ACTIVE canvas
6475
+ // (a background tab's untrusted canvas must not flip the foreground),
6476
+ // and NEVER while a modal dialog is open — present mode hides Sidebar-
6477
+ // descendant modals (OAuth device-code / Share-invite), so an untrusted
6478
+ // canvas could otherwise blank an in-flight confirmation.
6479
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
6480
+ const modalOpen = !!document.querySelector('[role="dialog"][aria-modal="true"]');
6481
+ if (e.source === activeWin && !modalOpen && !presentMode) {
6482
+ setPresentMode(true);
6483
+ broadcastChrome({ present: true });
6484
+ }
3510
6485
  } else if (m.dgn === 'comment-compose' && m.selection) {
3511
6486
  // Phase 6 — the iframe overlay owns the composer surface now. The
3512
6487
  // shell just mirrors `selected` so the StatusBar / sidebar still
@@ -3555,6 +6530,15 @@ function App() {
3555
6530
  // inspector forwards the chord here since the iframe's keydown never
3556
6531
  // reaches the shell's window listener. Mirror that handler's toggle.
3557
6532
  setPaletteOpen((v) => !v);
6533
+ } else if (m.dgn === 'shell-shortcut') {
6534
+ // Same forwarding lane for the other shell chords (inspect.ts) — so
6535
+ // ⌘R / ⌘⇧I / ⌘⇧M / ⌘⇧E / ⌘⇧H behave identically wherever focus is.
6536
+ if (m.id === 'reload') reloadActive();
6537
+ else if (m.id === 'inspector') toggleRightPanel('inspector');
6538
+ else if (m.id === 'assistant' && isNativeApp()) toggleRightPanel('assistant');
6539
+ else if (m.id === 'comments') toggleRightPanel('comments');
6540
+ else if (m.id === 'export') setExportDialog({ mode: 'export' });
6541
+ else if (m.id === 'handoff') setExportDialog({ mode: 'handoff' });
3558
6542
  } else if (m.dgn === 'open-export') {
3559
6543
  // Plan C — the in-canvas toolbar / context menu route here so they open
3560
6544
  // the SAME shell Export dialog as the menubar (one look, all settings).
@@ -3564,8 +6548,12 @@ function App() {
3564
6548
  scope: m.detail && typeof m.detail.scope === 'string' ? m.detail.scope : undefined,
3565
6549
  });
3566
6550
  } else if (m.dgn === 'loaded' && m.file) {
3567
- // iframe finished loading — push current comments + carry over focused pin if any
3568
- const list = commentsByFile[m.file] || [];
6551
+ // iframe finished loading — drop the compile skeleton, push current
6552
+ // comments + carry over focused pin if any
6553
+ setLoadingPath((p) => (p === m.file ? null : p));
6554
+ // Presentation Mode suppresses comment pins (same gate as the push
6555
+ // effect above), so a canvas opened while presenting starts pin-free.
6556
+ const list = presentMode ? [] : commentsByFile[m.file] || [];
3569
6557
  const el = [...iframesRef.current.entries()].find(([k]) => k === m.file)?.[1];
3570
6558
  if (el && el.contentWindow) {
3571
6559
  try {
@@ -3577,11 +6565,40 @@ function App() {
3577
6565
  try {
3578
6566
  el.contentWindow.postMessage({ dgn: 'theme', theme }, '*');
3579
6567
  } catch {}
6568
+ // Seed the just-loaded canvas with the current chrome-visibility
6569
+ // state (minimap / zoom-controls toggles + Presentation Mode) so a
6570
+ // canvas opened after a toggle starts in the right state.
6571
+ try {
6572
+ el.contentWindow.postMessage(
6573
+ { dgn: 'view-chrome', minimap: minimapVisible, zoom: zoomCtlVisible, present: presentMode },
6574
+ '*'
6575
+ );
6576
+ } catch {}
3580
6577
  if (focusedCommentId && list.some((c) => c.id === focusedCommentId)) {
3581
6578
  try {
3582
6579
  el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*');
3583
6580
  } catch {}
3584
6581
  }
6582
+ // Phase 12.3 (W1.1) — an edit-css/edit-attr commit triggers the file
6583
+ // watcher's HMR reload, which remounts the canvas and drops the
6584
+ // in-canvas selection halo. Re-select the same element by its stable
6585
+ // data-cd-id so the user keeps focus on what they're editing. The
6586
+ // canvas-shell `select-by-id` handler re-emits select-set, which keeps
6587
+ // the Inspector panel + halo in sync. Guarded to the active file.
6588
+ const sel = selectedRef.current;
6589
+ if (sel && sel.id && sel.file === m.file) {
6590
+ try {
6591
+ el.contentWindow.postMessage(
6592
+ {
6593
+ dgn: 'select-by-id',
6594
+ id: sel.id,
6595
+ artboardId: sel.artboardId ?? null,
6596
+ index: sel.index ?? 0,
6597
+ },
6598
+ '*'
6599
+ );
6600
+ } catch {}
6601
+ }
3585
6602
  }
3586
6603
  } else if (m.dgn === 'export-request' && m.id && m.payload) {
3587
6604
  // The export dialog renders inside the canvas iframe (canvas origin),
@@ -3651,7 +6668,18 @@ function App() {
3651
6668
  }
3652
6669
  window.addEventListener('message', onMessage);
3653
6670
  return () => window.removeEventListener('message', onMessage);
3654
- }, [commentsByFile, focusedCommentId, cfg, theme]);
6671
+ }, [
6672
+ commentsByFile,
6673
+ focusedCommentId,
6674
+ cfg,
6675
+ theme,
6676
+ reloadActive,
6677
+ presentMode,
6678
+ minimapVisible,
6679
+ zoomCtlVisible,
6680
+ broadcastChrome,
6681
+ activePath,
6682
+ ]);
3655
6683
 
3656
6684
  // Tell the active canvas iframe to drop any persistent selection (canvas
3657
6685
  // SelectionSet) — used when the comment composer closes via submit /
@@ -3667,6 +6695,22 @@ function App() {
3667
6695
  }
3668
6696
  }, [activePath]);
3669
6697
 
6698
+ // Phase 12.3 (W1.1) — optimistic inline-style preview. The CSS panel calls this
6699
+ // on commit so the selected element updates instantly in the canvas before the
6700
+ // edit-css → HMR reload lands. `value` null = the reset path (remove the prop).
6701
+ const applyOptimisticStyle = useCallback(
6702
+ (payload) => {
6703
+ if (!activePath || activePath === SYSTEM_TAB) return;
6704
+ const el = iframesRef.current.get(activePath);
6705
+ if (el && el.contentWindow) {
6706
+ try {
6707
+ el.contentWindow.postMessage({ dgn: 'apply-style', ...payload }, '*');
6708
+ } catch {}
6709
+ }
6710
+ },
6711
+ [activePath]
6712
+ );
6713
+
3670
6714
  const resolveComment = useCallback((id) => {
3671
6715
  wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
3672
6716
  }, []);
@@ -3719,6 +6763,16 @@ function App() {
3719
6763
  // ⌘F) still fire regardless of focus, mirroring browser convention.
3720
6764
  const inCanvasIframe = document.activeElement?.tagName === 'IFRAME';
3721
6765
 
6766
+ // Esc exits Presentation Mode first — it's the primary way back to the
6767
+ // chrome (the menubar is hidden while presenting). Highest priority so it
6768
+ // wins over the focused-pin / deselect Esc handlers below, and fires even
6769
+ // when focus is inside the canvas iframe.
6770
+ if (presentMode && e.key === 'Escape') {
6771
+ e.preventDefault();
6772
+ exitPresent();
6773
+ return;
6774
+ }
6775
+
3722
6776
  // Cmd+K / Ctrl+K — toggle the command palette (works even in inputs).
3723
6777
  if (meta && (e.key === 'k' || e.key === 'K')) {
3724
6778
  e.preventDefault();
@@ -3734,7 +6788,39 @@ function App() {
3734
6788
  // Cmd+Shift+M / Ctrl+Shift+M — toggle right "Comments" panel
3735
6789
  if (meta && e.shiftKey && (e.key === 'm' || e.key === 'M')) {
3736
6790
  e.preventDefault();
3737
- setCommentsPanelOpen((v) => !v);
6791
+ toggleRightPanel('comments');
6792
+ return;
6793
+ }
6794
+ // Cmd+Shift+G — toggle the Changes (git) panel. Opening it closes the
6795
+ // other right-dock panels (one panel at a time).
6796
+ if (meta && e.shiftKey && (e.key === 'g' || e.key === 'G')) {
6797
+ e.preventDefault();
6798
+ toggleRightPanel('changes');
6799
+ return;
6800
+ }
6801
+ // Cmd+Shift+I — toggle Inspector. Was bare "I", which collided with the
6802
+ // canvas highlighter tool (same letter, different action by focus).
6803
+ if (meta && e.shiftKey && (e.key === 'i' || e.key === 'I')) {
6804
+ e.preventDefault();
6805
+ toggleRightPanel('inspector');
6806
+ return;
6807
+ }
6808
+ // Phase 31 (DDR-123) — Cmd+Shift+A opens the native ACP chat sidepanel.
6809
+ if (meta && e.shiftKey && (e.key === 'a' || e.key === 'A') && isNativeApp()) {
6810
+ e.preventDefault();
6811
+ toggleRightPanel('assistant');
6812
+ return;
6813
+ }
6814
+ // Cmd+Shift+E / Cmd+Shift+H — the File-menu chords, previously
6815
+ // advertised but never bound.
6816
+ if (meta && e.shiftKey && (e.key === 'e' || e.key === 'E')) {
6817
+ e.preventDefault();
6818
+ setExportDialog({ mode: 'export' });
6819
+ return;
6820
+ }
6821
+ if (meta && e.shiftKey && (e.key === 'h' || e.key === 'H')) {
6822
+ e.preventDefault();
6823
+ setExportDialog({ mode: 'handoff' });
3738
6824
  return;
3739
6825
  }
3740
6826
  // Cmd+C / Ctrl+C — Phase 4.1 removed the shell-side comment-drop chord.
@@ -3806,14 +6892,25 @@ function App() {
3806
6892
  }
3807
6893
  return;
3808
6894
  }
3809
- // Itoggle Inspector panel (T6, Plan C)
3810
- if ((e.key === 'i' || e.key === 'I') && !meta && !e.shiftKey) {
6895
+ // Nopen the new-brief-board composer (replaces the advertised ⌘N,
6896
+ // which the browser reserves for New Window and never delivers).
6897
+ if ((e.key === 'n' || e.key === 'N') && !meta && !e.shiftKey) {
6898
+ e.preventDefault();
6899
+ setSidebarOpen(true);
6900
+ setTimeout(
6901
+ () => document.querySelector('[aria-label="New blank brief board"]')?.click(),
6902
+ 60
6903
+ );
6904
+ return;
6905
+ }
6906
+ // ? — keyboard-shortcuts cheat sheet (DS shortcuts overlay)
6907
+ if (e.key === '?') {
3811
6908
  e.preventDefault();
3812
- setInspectorOpen((v) => !v);
6909
+ setShortcutsOpen((v) => !v);
3813
6910
  return;
3814
6911
  }
3815
- // ? or F1 — open Help modal
3816
- if (e.key === '?' || e.key === 'F1') {
6912
+ // F1 — the full Help modal (commands & flows)
6913
+ if (e.key === 'F1') {
3817
6914
  e.preventDefault();
3818
6915
  setHelpOpen(true);
3819
6916
  return;
@@ -3838,6 +6935,8 @@ function App() {
3838
6935
  openSystem,
3839
6936
  closeTab,
3840
6937
  clearActiveCanvasSelection,
6938
+ presentMode,
6939
+ exitPresent,
3841
6940
  ]);
3842
6941
 
3843
6942
  const registerIframe = useCallback((path, el) => {
@@ -3872,7 +6971,7 @@ function App() {
3872
6971
  group: 'Canvas',
3873
6972
  label: 'New canvas…',
3874
6973
  icon: 'plus',
3875
- kbd: 'N',
6974
+ kbd: 'N',
3876
6975
  run: () => {
3877
6976
  setSidebarOpen(true);
3878
6977
  setTimeout(
@@ -3912,15 +7011,15 @@ function App() {
3912
7011
  label: 'Toggle comments panel',
3913
7012
  icon: 'resolve',
3914
7013
  kbd: '⌘⇧M',
3915
- run: () => setCommentsPanelOpen((v) => !v),
7014
+ run: () => toggleRightPanel('comments'),
3916
7015
  },
3917
7016
  {
3918
7017
  id: 'inspector',
3919
7018
  group: 'View',
3920
7019
  label: 'Open inspector',
3921
7020
  icon: 'sliders',
3922
- kbd: 'I',
3923
- run: () => setInspectorOpen(true),
7021
+ kbd: '⌘⇧I',
7022
+ run: () => openRightPanel('inspector'),
3924
7023
  },
3925
7024
  {
3926
7025
  id: 'reload',
@@ -3960,11 +7059,19 @@ function App() {
3960
7059
  run: () => whatsNew.openPanel(),
3961
7060
  },
3962
7061
  {
3963
- id: 'help',
7062
+ id: 'shortcuts',
3964
7063
  group: 'Help',
3965
- label: 'Help · shortcuts & commands',
7064
+ label: 'Keyboard shortcuts',
3966
7065
  icon: 'help',
3967
7066
  kbd: '?',
7067
+ run: () => setShortcutsOpen(true),
7068
+ },
7069
+ {
7070
+ id: 'help',
7071
+ group: 'Help',
7072
+ label: 'Help · commands & flows',
7073
+ icon: 'help',
7074
+ kbd: 'F1',
3968
7075
  run: () => setHelpOpen(true),
3969
7076
  },
3970
7077
  ],
@@ -3972,7 +7079,13 @@ function App() {
3972
7079
  );
3973
7080
 
3974
7081
  return (
3975
- <div className="maude" data-theme={theme} onContextMenu={onShellContextMenu}>
7082
+ <div
7083
+ className={'maude' + (presentMode ? ' is-present' : '')}
7084
+ data-theme={theme}
7085
+ onContextMenu={onShellContextMenu}
7086
+ >
7087
+ {firstRun && <OnboardingWizard />}
7088
+ <UpdateBanner update={updateReady} onDismiss={() => setUpdateReady(null)} />
3976
7089
  <SyncBanner status={syncStatus} />
3977
7090
  {!usageNudge && !tourSteps && <WhatsNewToast wn={whatsNew} />}
3978
7091
  {gitLifecycle && (
@@ -4003,21 +7116,48 @@ function App() {
4003
7116
  openMenu={openMenu}
4004
7117
  setOpenMenu={setOpenMenu}
4005
7118
  commentsPanelOpen={commentsPanelOpen}
4006
- onToggleComments={() => setCommentsPanelOpen((v) => !v)}
7119
+ onToggleComments={() => toggleRightPanel('comments')}
7120
+ changesOpen={changesOpen}
7121
+ changesCount={unsavedCount}
7122
+ onToggleChanges={() => toggleRightPanel('changes')}
4007
7123
  onOpenSystem={openSystem}
4008
7124
  sidebarOpen={sidebarOpen}
4009
7125
  onToggleSidebar={() => setSidebarOpen((v) => !v)}
4010
7126
  showHidden={showHidden}
4011
7127
  onToggleShowHidden={() => setShowHidden((v) => !v)}
4012
7128
  onOpenHelp={() => setHelpOpen(true)}
7129
+ onOpenShortcuts={() => setShortcutsOpen(true)}
7130
+ onStartTour={() => startTour(USAGE_TOUR)}
7131
+ onStartCollabTour={() => startTour(COLLAB_TOUR)}
4013
7132
  annotationsVisible={annotationsVisible}
4014
7133
  onToggleAnnotations={toggleAnnotations}
7134
+ minimapVisible={minimapVisible}
7135
+ onToggleMinimap={toggleMinimap}
7136
+ zoomCtlVisible={zoomCtlVisible}
7137
+ onToggleZoomCtl={toggleZoomCtl}
7138
+ presentMode={presentMode}
7139
+ onTogglePresent={togglePresent}
4015
7140
  postToActiveCanvas={postToActiveCanvas}
4016
7141
  onOpenWhatsNew={whatsNew.openPanel}
4017
7142
  whatsNewCount={whatsNew.unseen.length}
4018
7143
  artboardCount={activeArtboards}
4019
7144
  inspectorOpen={inspectorOpen}
4020
- onToggleInspector={() => setInspectorOpen((v) => !v)}
7145
+ inspectorTab={inspectorTab}
7146
+ onToggleInspector={() => toggleRightPanel('inspector')}
7147
+ assistantOpen={assistantOpen}
7148
+ onToggleAssistant={() => toggleRightPanel('assistant')}
7149
+ assistantBusy={assistantBusy}
7150
+ assistantUnseen={assistantUnseen}
7151
+ onOpenLayers={() => {
7152
+ // Toggle: already open on Layers → close; otherwise open on Layers
7153
+ // (clearing the sibling panels — one dock slot).
7154
+ if (inspectorOpen && inspectorTab === 'layers') {
7155
+ setInspectorOpen(false);
7156
+ } else {
7157
+ setInspectorTab('layers');
7158
+ openRightPanel('inspector');
7159
+ }
7160
+ }}
4021
7161
  onNewCanvas={() => {
4022
7162
  setSidebarOpen(true);
4023
7163
  setTimeout(
@@ -4027,6 +7167,7 @@ function App() {
4027
7167
  }}
4028
7168
  onOpenExport={(mode) => setExportDialog({ mode })}
4029
7169
  onReload={reloadActive}
7170
+ onCloseCanvas={() => activePath && closeTab(activePath)}
4030
7171
  presence={
4031
7172
  <>
4032
7173
  <StAvatar
@@ -4035,12 +7176,17 @@ function App() {
4035
7176
  title={gitUser ? `${gitUser} (you)` : 'You'}
4036
7177
  />
4037
7178
  {agentActive && (
4038
- <StAvatar initials="C" hue="var(--presence-agent)" title="Claude · editing" />
7179
+ <StAvatar
7180
+ initials="C"
7181
+ hue="var(--presence-agent)"
7182
+ title="Claude · editing"
7183
+ pulse
7184
+ />
4039
7185
  )}
4040
7186
  </>
4041
7187
  }
4042
7188
  />
4043
- <div className="st-body">
7189
+ <div className={'st-body' + (dragSide ? ' is-resizing' : '')} ref={bodyRef}>
4044
7190
  <CollapsedRail
4045
7191
  shown={!sidebarOpen}
4046
7192
  onExpand={() => setSidebarOpen(true)}
@@ -4066,7 +7212,24 @@ function App() {
4066
7212
  onDeleteBoard={deleteBoard}
4067
7213
  collapsed={!sidebarOpen}
4068
7214
  onCollapse={() => setSidebarOpen(false)}
7215
+ width={sbSize.w}
7216
+ resizing={dragSide === 'sb'}
7217
+ dirtyByPath={dirtyByPath}
7218
+ project={project}
7219
+ gitBranch={gitStatus?.branch}
4069
7220
  />
7221
+ {sidebarOpen && (
7222
+ <PanelGrip
7223
+ label="Resize files panel"
7224
+ size={sbSize}
7225
+ active={dragSide === 'sb'}
7226
+ onPointerDown={(e) => {
7227
+ e.preventDefault();
7228
+ e.currentTarget.setPointerCapture?.(e.pointerId);
7229
+ setDragSide('sb');
7230
+ }}
7231
+ />
7232
+ )}
4070
7233
  <div className="main">
4071
7234
  <Viewport
4072
7235
  tabs={tabs}
@@ -4077,12 +7240,80 @@ function App() {
4077
7240
  onSelectDs={loadSystemData}
4078
7241
  project={project}
4079
7242
  cfg={cfg}
7243
+ loadingPath={loadingPath}
7244
+ onIframeLoad={onIframeLoad}
4080
7245
  />
4081
7246
  </div>
4082
- {/* Right dock one panel at a time. Inspector takes precedence when
4083
- open (T6); else the comments panel. */}
4084
- {inspectorOpen ? (
4085
- <InspectorPanel selected={selected} onClose={() => setInspectorOpen(false)} />
7247
+ {(inspectorOpen || commentsPanelOpen || changesOpen || assistantOpen) && (
7248
+ <PanelGrip
7249
+ label="Resize side panel"
7250
+ dir="rtl"
7251
+ size={rpSize}
7252
+ active={dragSide === 'rp'}
7253
+ onPointerDown={(e) => {
7254
+ e.preventDefault();
7255
+ e.currentTarget.setPointerCapture?.(e.pointerId);
7256
+ setDragSide('rp');
7257
+ }}
7258
+ />
7259
+ )}
7260
+ {/* Right dock — one panel at a time. Changes (E2) takes precedence,
7261
+ then Inspector (T6), then the comments panel. */}
7262
+ {changesOpen ? (
7263
+ <GitPanel
7264
+ status={
7265
+ gitStatus && remoteSync ? { ...gitStatus, ...remoteSync } : gitStatus
7266
+ }
7267
+ project={project}
7268
+ readOnly={!isNativeApp()}
7269
+ width={rpSize.w}
7270
+ resizing={dragSide === 'rp'}
7271
+ onClose={() => setChangesOpen(false)}
7272
+ onCommit={gitCommit}
7273
+ onDiscard={gitDiscard}
7274
+ onPublish={gitPublish}
7275
+ onGetLatest={gitGetLatest}
7276
+ loadLog={gitLoadLog}
7277
+ onOpenCanvas={(p) => openTab(p)}
7278
+ onOpenDiff={(file) => setDiffTarget({ file, beforeSha: 'HEAD', conflict: false })}
7279
+ activeCanvas={
7280
+ activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath)
7281
+ ? activePath
7282
+ : null
7283
+ }
7284
+ onPreviewVersion={(sha) =>
7285
+ setDiffTarget({ file: activePath, beforeSha: sha, conflict: false })
7286
+ }
7287
+ designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
7288
+ />
7289
+ ) : inspectorOpen ? (
7290
+ <InspectorPanel
7291
+ selected={selected}
7292
+ cfg={cfg}
7293
+ tab={inspectorTab}
7294
+ onTabChange={setInspectorTab}
7295
+ onClose={() => setInspectorOpen(false)}
7296
+ onOptimistic={applyOptimisticStyle}
7297
+ layersTree={layersTree}
7298
+ onSelectLayer={(n) =>
7299
+ postToActiveCanvas({
7300
+ dgn: 'select-by-id',
7301
+ id: n.id,
7302
+ artboardId: layersTree?.artboardId,
7303
+ index: n.index,
7304
+ })
7305
+ }
7306
+ onHoverLayer={(n) =>
7307
+ postToActiveCanvas({
7308
+ dgn: 'highlight',
7309
+ id: n ? n.id : null,
7310
+ artboardId: layersTree?.artboardId,
7311
+ index: n ? n.index : 0,
7312
+ })
7313
+ }
7314
+ width={rpSize.w}
7315
+ resizing={dragSide === 'rp'}
7316
+ />
4086
7317
  ) : commentsPanelOpen ? (
4087
7318
  <CommentsPanel
4088
7319
  commentsByFile={commentsByFile}
@@ -4094,8 +7325,28 @@ function App() {
4094
7325
  onResolve={resolveComment}
4095
7326
  onReopen={reopenComment}
4096
7327
  onDelete={deleteComment}
7328
+ width={rpSize.w}
7329
+ resizing={dragSide === 'rp'}
4097
7330
  />
4098
7331
  ) : null}
7332
+ {/* Phase 31 (DDR-123) — the ACP chat panel stays MOUNTED (display:none
7333
+ when inactive) so the chat keeps streaming + its history survives a
7334
+ switch to Changes/Inspector/Comments. Native-only. */}
7335
+ {isNativeApp() && (
7336
+ <ChatPanel
7337
+ hidden={!assistantOpen}
7338
+ activeCanvas={
7339
+ activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath)
7340
+ ? activePath
7341
+ : null
7342
+ }
7343
+ width={rpSize.w}
7344
+ resizing={dragSide === 'rp'}
7345
+ onClose={() => setAssistantOpen(false)}
7346
+ onBusyChange={setAssistantBusy}
7347
+ onFinished={handleAssistantFinished}
7348
+ />
7349
+ )}
4099
7350
  </div>
4100
7351
  <StatusBar
4101
7352
  activePath={activePath}
@@ -4106,8 +7357,32 @@ function App() {
4106
7357
  onToggleTheme={toggleTheme}
4107
7358
  onClearSelected={clearSelected}
4108
7359
  syncStatus={syncStatus}
7360
+ changesCount={unsavedCount}
7361
+ unpushed={gitStatus?.unpushed || 0}
7362
+ changesOpen={changesOpen}
7363
+ onOpenChanges={gitStatus?.repo ? () => setChangesOpen(true) : undefined}
4109
7364
  />
4110
7365
  </div>
7366
+ {presentMode && (
7367
+ <button
7368
+ type="button"
7369
+ className="st-present-exit"
7370
+ onClick={exitPresent}
7371
+ aria-label="Exit presentation mode"
7372
+ title="Exit presentation mode (Esc)"
7373
+ >
7374
+ <svg viewBox="0 0 16 16" width="13" height="13" fill="none" aria-hidden="true">
7375
+ <path
7376
+ d="M4 4l8 8M12 4l-8 8"
7377
+ stroke="currentColor"
7378
+ strokeWidth="1.6"
7379
+ strokeLinecap="round"
7380
+ />
7381
+ </svg>
7382
+ <span>Exit presentation</span>
7383
+ <kbd className="st-present-exit-kbd">Esc</kbd>
7384
+ </button>
7385
+ )}
4111
7386
  <CommandPalette
4112
7387
  open={paletteOpen}
4113
7388
  onClose={() => setPaletteOpen(false)}
@@ -4121,6 +7396,31 @@ function App() {
4121
7396
  onClose={() => setExportDialog(null)}
4122
7397
  />
4123
7398
  )}
7399
+ {diffTarget && (
7400
+ <DiffView
7401
+ target={diffTarget}
7402
+ cfg={cfg}
7403
+ loadLog={gitLoadLog}
7404
+ onClose={() => setDiffTarget(null)}
7405
+ onRestore={async (file) => {
7406
+ await gitDiscard([file]);
7407
+ setDiffTarget(null);
7408
+ }}
7409
+ onResolve={async (choice) => {
7410
+ // phase-28 (E3): apply the chosen side via /_api/git/resolve, which
7411
+ // completes the two-parent merge commit (and for "both" saves our
7412
+ // version as a "(mine)" copy — zero loss). Close on success; keep the
7413
+ // resolver open with the error otherwise.
7414
+ const res = await gitResolveConflict(choice);
7415
+ if (res.ok) {
7416
+ setDiffTarget(null);
7417
+ } else {
7418
+ window.alert(res.error || 'Could not finish the merge. Get the latest again, then retry.');
7419
+ }
7420
+ }}
7421
+ />
7422
+ )}
7423
+ <ShortcutsOverlay open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
4124
7424
  <HelpModal
4125
7425
  open={helpOpen}
4126
7426
  onClose={() => setHelpOpen(false)}
@@ -4130,7 +7430,7 @@ function App() {
4130
7430
  }}
4131
7431
  />
4132
7432
  <WhatsNewPanel wn={whatsNew} onStartTour={startTour} />
4133
- {usageNudge && !tourSteps && (
7433
+ {usageNudge && !tourSteps && !collabNudge && (
4134
7434
  <div className="mdcc-tour-nudge" role="status" aria-live="polite">
4135
7435
  <div className="mdcc-tour-nudge__body">
4136
7436
  New here? Take a 60-second tour of the canvas browser.
@@ -4155,11 +7455,40 @@ function App() {
4155
7455
  </button>
4156
7456
  </div>
4157
7457
  )}
7458
+ {/* Phase 29 (E4) — the collab "rychlý kurz", offered once after onboarding. */}
7459
+ {collabNudge && !tourSteps && (
7460
+ <div className="mdcc-tour-nudge" role="status" aria-live="polite">
7461
+ <div className="mdcc-tour-nudge__body">
7462
+ New to working with a team? See how saving &amp; sharing works — 60 seconds.
7463
+ </div>
7464
+ <button
7465
+ type="button"
7466
+ className="mdcc-tour-nudge__cta"
7467
+ onClick={() => {
7468
+ markCollabSeen();
7469
+ startTour(COLLAB_TOUR);
7470
+ }}
7471
+ >
7472
+ Start
7473
+ </button>
7474
+ <button
7475
+ type="button"
7476
+ className="mdcc-tour-nudge__skip"
7477
+ aria-label="Dismiss"
7478
+ onClick={markCollabSeen}
7479
+ >
7480
+ ×
7481
+ </button>
7482
+ </div>
7483
+ )}
4158
7484
  <TourOverlay
4159
7485
  steps={tourSteps ?? []}
4160
7486
  open={!!tourSteps}
4161
7487
  onClose={() => setTourSteps(null)}
4162
7488
  onComplete={markUsageSeen}
7489
+ bus={tourBus}
7490
+ hasSelection={!!selected}
7491
+ hasCanvas={tabs.length > 0}
4163
7492
  />
4164
7493
  </div>
4165
7494
  );