@1agh/maude 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +0 -1
  3. package/apps/studio/annotations-align.ts +149 -0
  4. package/apps/studio/annotations-bindings.ts +197 -0
  5. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  6. package/apps/studio/annotations-groups.ts +270 -0
  7. package/apps/studio/annotations-layer.tsx +1708 -1660
  8. package/apps/studio/annotations-model.ts +2077 -0
  9. package/apps/studio/annotations-snap.ts +125 -0
  10. package/apps/studio/api.ts +208 -116
  11. package/apps/studio/bin/_png-playwright.mjs +1 -1
  12. package/apps/studio/bin/annotate.mjs +732 -0
  13. package/apps/studio/bin/annotate.sh +17 -0
  14. package/apps/studio/bin/read-annotations.mjs +152 -17
  15. package/apps/studio/build.ts +1 -1
  16. package/apps/studio/canvas-arrowheads.ts +78 -9
  17. package/apps/studio/canvas-cursors.ts +2 -0
  18. package/apps/studio/canvas-edit.ts +257 -7
  19. package/apps/studio/canvas-icons.tsx +105 -0
  20. package/apps/studio/canvas-shell.tsx +305 -8
  21. package/apps/studio/client/app.jsx +2812 -238
  22. package/apps/studio/client/index.html +1 -1
  23. package/apps/studio/client/styles/3-shell-maude.css +713 -11
  24. package/apps/studio/client/styles/4-components.css +39 -0
  25. package/apps/studio/client/styles/5-maude-overrides.css +16 -2
  26. package/apps/studio/client/tour/overlay.jsx +105 -17
  27. package/apps/studio/client/tour/usage-tour.js +23 -10
  28. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  29. package/apps/studio/commands/move-artboards-command.ts +1 -1
  30. package/apps/studio/comments-overlay.tsx +7 -5
  31. package/apps/studio/context-menu.tsx +4 -3
  32. package/apps/studio/contextual-toolbar.tsx +14 -0
  33. package/apps/studio/cursors-overlay.tsx +20 -20
  34. package/apps/studio/dist/client.bundle.js +3322 -602
  35. package/apps/studio/dist/comment-mount.js +95 -9
  36. package/apps/studio/dist/styles.css +2490 -167
  37. package/apps/studio/dom-selection.ts +115 -0
  38. package/apps/studio/export-dialog.tsx +3 -3
  39. package/apps/studio/history.ts +20 -3
  40. package/apps/studio/http.ts +124 -5
  41. package/apps/studio/input-router.tsx +7 -0
  42. package/apps/studio/inspect.ts +37 -8
  43. package/apps/studio/participants-chrome.tsx +1 -7
  44. package/apps/studio/server.ts +5 -2
  45. package/apps/studio/sync/agent.ts +231 -57
  46. package/apps/studio/sync/codec.ts +45 -0
  47. package/apps/studio/sync/cold-start.ts +158 -0
  48. package/apps/studio/sync/connection-state.ts +58 -2
  49. package/apps/studio/sync/index.ts +563 -221
  50. package/apps/studio/sync/journal.ts +190 -0
  51. package/apps/studio/sync/migrate-seed.ts +202 -44
  52. package/apps/studio/sync/projection.ts +22 -3
  53. package/apps/studio/sync/status.ts +15 -4
  54. package/apps/studio/test/activity.test.ts +1 -7
  55. package/apps/studio/test/annotate-write.test.ts +184 -0
  56. package/apps/studio/test/annotations-align.test.ts +88 -0
  57. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  58. package/apps/studio/test/annotations-groups.test.ts +231 -0
  59. package/apps/studio/test/annotations-snap.test.ts +79 -0
  60. package/apps/studio/test/canvas-edit.test.ts +181 -1
  61. package/apps/studio/test/canvas-origin-gate.test.ts +8 -0
  62. package/apps/studio/test/collab-bridge.test.ts +0 -2
  63. package/apps/studio/test/collab-room.test.ts +2 -7
  64. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  65. package/apps/studio/test/csrf-write-guard.test.ts +52 -0
  66. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  67. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  68. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  69. package/apps/studio/test/sync-agent.test.ts +272 -2
  70. package/apps/studio/test/sync-codec.test.ts +65 -0
  71. package/apps/studio/test/sync-cold-start.test.ts +244 -0
  72. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  73. package/apps/studio/test/sync-hardening.test.ts +0 -8
  74. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  75. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  76. package/apps/studio/test/sync-journal.test.ts +176 -0
  77. package/apps/studio/test/sync-runtime.test.ts +360 -5
  78. package/apps/studio/test/sync-status.test.ts +66 -0
  79. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  80. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  81. package/apps/studio/test/use-collab.test.ts +0 -0
  82. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  83. package/apps/studio/tool-palette.tsx +12 -2
  84. package/apps/studio/undo-hud.tsx +0 -1
  85. package/apps/studio/use-agent-presence.tsx +4 -2
  86. package/apps/studio/use-annotation-resize.tsx +484 -54
  87. package/apps/studio/use-annotation-selection.tsx +16 -4
  88. package/apps/studio/use-canvas-activity.tsx +2 -2
  89. package/apps/studio/use-collab.tsx +20 -15
  90. package/apps/studio/use-selection-set.tsx +9 -0
  91. package/apps/studio/use-tool-mode.tsx +5 -1
  92. package/apps/studio/whats-new.json +135 -10
  93. package/apps/studio/whats-new.schema.json +18 -2
  94. package/cli/commands/design-link.test.mjs +84 -0
  95. package/cli/commands/design.mjs +2 -1
  96. package/cli/commands/design.test.mjs +5 -1
  97. package/cli/lib/design-link.mjs +51 -1
  98. package/package.json +8 -8
  99. package/plugins/design/templates/_shell.html +28 -4
@@ -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
@@ -319,6 +320,96 @@ const STICONS = {
319
320
  <polyline points="2.2 9 8 12.3 13.8 9" />
320
321
  </>
321
322
  ),
323
+ // Layer-type glyphs (Phase 12.3 W3.1) — one mark per LayerNode `type`.
324
+ box: <rect x="3" y="3" width="10" height="10" rx="1.2" />,
325
+ type: (
326
+ <>
327
+ <polyline points="4 4 12 4" />
328
+ <line x1="8" y1="4" x2="8" y2="12" />
329
+ </>
330
+ ),
331
+ button: (
332
+ <>
333
+ <rect x="2.5" y="5" width="11" height="6" rx="3" />
334
+ <line x1="6" y1="8" x2="10" y2="8" />
335
+ </>
336
+ ),
337
+ input: (
338
+ <>
339
+ <rect x="2.5" y="5" width="11" height="6" rx="1.2" />
340
+ <line x1="5" y1="8" x2="5" y2="8" />
341
+ </>
342
+ ),
343
+ link: (
344
+ <>
345
+ <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" />
346
+ <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" />
347
+ </>
348
+ ),
349
+ list: (
350
+ <>
351
+ <line x1="6" y1="4.5" x2="13" y2="4.5" />
352
+ <line x1="6" y1="8" x2="13" y2="8" />
353
+ <line x1="6" y1="11.5" x2="13" y2="11.5" />
354
+ <circle cx="3.2" cy="4.5" r="0.8" fill="currentColor" />
355
+ <circle cx="3.2" cy="8" r="0.8" fill="currentColor" />
356
+ <circle cx="3.2" cy="11.5" r="0.8" fill="currentColor" />
357
+ </>
358
+ ),
359
+ eye: (
360
+ <>
361
+ <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" />
362
+ <circle cx="8" cy="8" r="2" />
363
+ </>
364
+ ),
365
+ eyedropper: (
366
+ <>
367
+ <path d="M11 2.6a1.7 1.7 0 0 1 2.4 2.4l-1.2 1.2-2.4-2.4z" />
368
+ <path d="M9.5 4.6 4 10.1V12h1.9l5.5-5.5" />
369
+ </>
370
+ ),
371
+ // Figma-style property prefix glyphs (#2) — small marks INSIDE numeric fields.
372
+ 'p-corner': <path d="M3.5 12.5V7a3.5 3.5 0 0 1 3.5-3.5h5.5" />,
373
+ 'p-opacity': (
374
+ <>
375
+ <rect x="3" y="3" width="10" height="10" rx="1.5" />
376
+ <path d="M3 8h10M8 3v10" strokeWidth="0.9" opacity="0.55" />
377
+ </>
378
+ ),
379
+ 'p-lineheight': (
380
+ <>
381
+ <line x1="6.5" y1="4" x2="13" y2="4" />
382
+ <line x1="6.5" y1="8" x2="13" y2="8" />
383
+ <line x1="6.5" y1="12" x2="13" y2="12" />
384
+ <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" />
385
+ </>
386
+ ),
387
+ 'p-letterspacing': (
388
+ <>
389
+ <path d="M3 4v8M13 4v8" />
390
+ <path d="M6 11.5 8 5l2 6.5M6.7 9.3h2.6" strokeWidth="1.1" />
391
+ </>
392
+ ),
393
+ 'p-gap': (
394
+ <>
395
+ <rect x="2" y="4.5" width="3.6" height="7" rx="0.6" />
396
+ <rect x="10.4" y="4.5" width="3.6" height="7" rx="0.6" />
397
+ <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" />
398
+ </>
399
+ ),
400
+ 'p-border': <rect x="3" y="3" width="10" height="10" rx="1" />,
401
+ 'p-size': (
402
+ <>
403
+ <path d="M3 13 6.6 3l3.6 10" />
404
+ <path d="M4.3 9.6h4.6" />
405
+ </>
406
+ ),
407
+ 'eye-off': (
408
+ <>
409
+ <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" />
410
+ <line x1="2.5" y1="2.5" x2="13.5" y2="13.5" />
411
+ </>
412
+ ),
322
413
  sliders: (
323
414
  <>
324
415
  <line x1="3" y1="5" x2="13" y2="5" />
@@ -390,6 +481,22 @@ const STICONS = {
390
481
  <line x1="13" y1="3" x2="7.6" y2="8.4" />
391
482
  </>
392
483
  ),
484
+ share: (
485
+ <>
486
+ <circle cx="4" cy="8" r="1.9" />
487
+ <circle cx="11.6" cy="3.6" r="1.9" />
488
+ <circle cx="11.6" cy="12.4" r="1.9" />
489
+ <line x1="5.7" y1="7" x2="9.9" y2="4.6" />
490
+ <line x1="5.7" y1="9" x2="9.9" y2="11.4" />
491
+ </>
492
+ ),
493
+ pen: (
494
+ <>
495
+ <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" />
496
+ <line x1="9.6" y1="4.2" x2="11.8" y2="6.4" />
497
+ </>
498
+ ),
499
+ square: <rect x="3.5" y="3.5" width="9" height="9" rx="1" />,
393
500
  };
394
501
 
395
502
  // ⌘K command palette — the mockup's signature surface, wired to real shell
@@ -398,12 +505,19 @@ const STICONS = {
398
505
  function CommandPalette({ open, onClose, actions }) {
399
506
  const [q, setQ] = useState('');
400
507
  const [active, setActive] = useState(0);
508
+ const listRef = useRef(null);
401
509
  useEffect(() => {
402
510
  if (open) {
403
511
  setQ('');
404
512
  setActive(0);
405
513
  }
406
514
  }, [open]);
515
+ // Keep the keyboard-active row visible while arrowing through a scrolled list.
516
+ useEffect(() => {
517
+ listRef.current
518
+ ?.querySelector('.st-pal-item.is-active')
519
+ ?.scrollIntoView({ block: 'nearest' });
520
+ }, [active]);
407
521
  const filtered = useMemo(() => {
408
522
  const needle = q.trim().toLowerCase();
409
523
  if (!needle) return actions;
@@ -457,7 +571,7 @@ function CommandPalette({ open, onClose, actions }) {
457
571
  />
458
572
  <Kbd>⌘K</Kbd>
459
573
  </div>
460
- <div className="st-pal-list">
574
+ <div className="st-pal-list" ref={listRef}>
461
575
  {filtered.length === 0 ? (
462
576
  <div className="st-pal-empty">No matching command.</div>
463
577
  ) : (
@@ -530,9 +644,19 @@ function initialsOf(name) {
530
644
  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
531
645
  return ((parts[0][0] || '') + (parts[parts.length - 1][0] || '')).toUpperCase() || '?';
532
646
  }
533
- function StAvatar({ initials, hue, title }) {
647
+ function StAvatar({ initials, hue, title, pulse }) {
648
+ // Hue rides a custom property so CSS can mix it into the surface (DS avatar
649
+ // recipe: tinted bg + hue border + fg-0 text — solid fill + white text broke
650
+ // the accent-fg contrast rule and washed out in light theme). `pulse` plays
651
+ // the DS motion-presence role (scale+opacity ring) — the AI agent's "live"
652
+ // tell while it's editing.
534
653
  return (
535
- <span className="st-avatar" style={{ background: hue }} title={title} aria-label={title}>
654
+ <span
655
+ className={'st-avatar' + (pulse ? ' is-pulsing' : '')}
656
+ style={{ '--av-hue': hue }}
657
+ data-tip={title}
658
+ aria-label={title}
659
+ >
536
660
  {initials}
537
661
  </span>
538
662
  );
@@ -542,6 +666,78 @@ function Kbd({ children }) {
542
666
  return <span className="kbd">{children}</span>;
543
667
  }
544
668
 
669
+ // ───────── Resizable panel grip (DS components-resize-panels contract) ─────────
670
+ //
671
+ // 8px hit area on a 1px seam; grip dots + accent surface on hover/focus/drag;
672
+ // pointer drag (with capture, so moves keep arriving over the iframe), arrow-key
673
+ // nudge (8px, ⇧=24px), Home/End to min/max, double-click resets to default.
674
+ // Width persists per panel in localStorage.
675
+
676
+ function usePanelSize(storeKey, { min, max, def }) {
677
+ const clamp = useCallback((v) => Math.min(max, Math.max(min, v)), [min, max]);
678
+ const [w, setWRaw] = useState(() => {
679
+ try {
680
+ const v = parseInt(localStorage.getItem(storeKey) || '', 10);
681
+ return Number.isFinite(v) ? clamp(v) : def;
682
+ } catch {
683
+ return def;
684
+ }
685
+ });
686
+ useEffect(() => {
687
+ try {
688
+ localStorage.setItem(storeKey, String(w));
689
+ } catch {}
690
+ }, [storeKey, w]);
691
+ const setW = useCallback(
692
+ (next) => setWRaw((prev) => clamp(typeof next === 'function' ? next(prev) : next)),
693
+ [clamp]
694
+ );
695
+ return { w, setW, min, max, def };
696
+ }
697
+
698
+ function PanelGrip({ label, size, onPointerDown, active, dir = 'ltr' }) {
699
+ const { w, setW, min, max, def } = size;
700
+ // `dir` is grip-relative: 'ltr' (left panel) → ArrowRight widens; 'rtl'
701
+ // (right dock) → ArrowRight narrows, since the seam moves toward the panel.
702
+ const grow = dir === 'rtl' ? -1 : 1;
703
+ return (
704
+ <div
705
+ className={'st-grip' + (active ? ' is-active' : '')}
706
+ role="separator"
707
+ tabIndex={0}
708
+ aria-orientation="vertical"
709
+ aria-label={label}
710
+ aria-valuemin={min}
711
+ aria-valuemax={max}
712
+ aria-valuenow={Math.round(w)}
713
+ onPointerDown={onPointerDown}
714
+ onDoubleClick={() => setW(def)}
715
+ onKeyDown={(e) => {
716
+ const step = e.shiftKey ? 24 : 8;
717
+ if (e.key === 'ArrowRight') {
718
+ e.preventDefault();
719
+ setW((v) => v + step * grow);
720
+ } else if (e.key === 'ArrowLeft') {
721
+ e.preventDefault();
722
+ setW((v) => v - step * grow);
723
+ } else if (e.key === 'Home') {
724
+ e.preventDefault();
725
+ setW(min);
726
+ } else if (e.key === 'End') {
727
+ e.preventDefault();
728
+ setW(max);
729
+ }
730
+ }}
731
+ >
732
+ <svg className="st-grip-dots" viewBox="0 0 6 18" aria-hidden="true">
733
+ <circle cx="3" cy="3" r="1.1" fill="currentColor" />
734
+ <circle cx="3" cy="9" r="1.1" fill="currentColor" />
735
+ <circle cx="3" cy="15" r="1.1" fill="currentColor" />
736
+ </svg>
737
+ </div>
738
+ );
739
+ }
740
+
545
741
  // T5 (Plan C) — shell-level Export & Handoff dialog (maude `.st-dialog`), per
546
742
  // `.design/ui/Studio.tsx` HandoffBoard. Wired to the privileged main-origin
547
743
  // `POST /_api/export` (7 real formats × scopes). The shadcn card is HANDOFF —
@@ -817,7 +1013,7 @@ function DirRow({ name, depth, defaultOpen, children }) {
817
1013
  onClick={() => setOpen((v) => !v)}
818
1014
  >
819
1015
  <span className="st-row-glyph">
820
- <StIcon name={open ? 'chevron-down' : 'chevron-right'} size={13} />
1016
+ <StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
821
1017
  </span>
822
1018
  <span className="st-row-name">{name}</span>
823
1019
  </button>
@@ -847,7 +1043,7 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
847
1043
  aria-label={open ? 'Collapse design system' : 'Expand design system'}
848
1044
  title={open ? 'Collapse' : 'Expand'}
849
1045
  >
850
- <StIcon name={open ? 'chevron-down' : 'chevron-right'} size={13} />
1046
+ <StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
851
1047
  </button>
852
1048
  <button
853
1049
  type="button"
@@ -983,7 +1179,7 @@ function CanvasRow({
983
1179
  setOpenState((v) => !v);
984
1180
  }}
985
1181
  >
986
- <StIcon name={open ? 'chevron-down' : 'chevron-right'} size={13} />
1182
+ <StIcon name="chevron-right" className={'st-chev' + (open ? ' is-open' : '')} size={13} />
987
1183
  </span>
988
1184
  <span className="st-row-name">{displayName(primary.name)}</span>
989
1185
  {oc > 0 && <span className="st-row-badge">{oc}</span>}
@@ -1155,6 +1351,8 @@ function Sidebar({
1155
1351
  onDeleteBoard,
1156
1352
  collapsed,
1157
1353
  onCollapse,
1354
+ width,
1355
+ resizing,
1158
1356
  }) {
1159
1357
  const filteredGroups = useMemo(() => {
1160
1358
  if (!search) return groups;
@@ -1200,14 +1398,19 @@ function Sidebar({
1200
1398
  }, [filteredGroups]);
1201
1399
 
1202
1400
  return (
1203
- <nav className={'st-sidebar' + (collapsed ? ' is-collapsed' : '')} aria-label="Files">
1401
+ <nav
1402
+ className={'st-sidebar' + (collapsed ? ' is-collapsed' : '') + (resizing ? ' is-resizing' : '')}
1403
+ style={collapsed || !width ? undefined : { width, flexBasis: width }}
1404
+ aria-label="Files"
1405
+ data-tour="sidebar"
1406
+ >
1204
1407
  <div className="st-sb-hd">
1205
1408
  <span className="st-sb-title">Files</span>
1206
1409
  <div className="st-sb-hd-actions">
1207
1410
  <button
1208
1411
  type="button"
1209
1412
  className="st-iconbtn"
1210
- title="New blank brief board"
1413
+ data-tip="New blank brief board"
1211
1414
  aria-label="New blank brief board"
1212
1415
  aria-expanded={creating}
1213
1416
  onClick={() => {
@@ -1217,7 +1420,10 @@ function Sidebar({
1217
1420
  >
1218
1421
  <StIcon name="plus" size={15} />
1219
1422
  </button>
1220
- <span className="st-live" title={wsConnected ? 'live · file index synced' : 'reconnecting…'}>
1423
+ <span
1424
+ className="st-live"
1425
+ data-tip={wsConnected ? 'live · file index synced' : 'reconnecting…'}
1426
+ >
1221
1427
  <span className={'st-live-dot' + (wsConnected ? ' is-connected' : '')} aria-hidden="true" />
1222
1428
  {htmlShown} / {htmlCount}
1223
1429
  </span>
@@ -1226,7 +1432,7 @@ function Sidebar({
1226
1432
  type="button"
1227
1433
  className="st-iconbtn"
1228
1434
  aria-label="Collapse sidebar"
1229
- title="Collapse sidebar (T)"
1435
+ data-tip="Collapse sidebar · T"
1230
1436
  onClick={onCollapse}
1231
1437
  >
1232
1438
  <StIcon name="panel-left" size={15} />
@@ -1263,7 +1469,7 @@ function Sidebar({
1263
1469
  type="button"
1264
1470
  className="st-newboard-go"
1265
1471
  disabled={newBusy || !newName.trim()}
1266
- title="Create (Enter)"
1472
+ data-tip="Create · Enter"
1267
1473
  aria-label="Create brief board"
1268
1474
  onClick={submitNewBoard}
1269
1475
  >
@@ -1285,13 +1491,21 @@ function Sidebar({
1285
1491
  placeholder="Search canvases…"
1286
1492
  value={search}
1287
1493
  onChange={(e) => setSearch(e.target.value)}
1494
+ onKeyDown={(e) => {
1495
+ // Esc — clear the filter first; a second Esc leaves the field.
1496
+ if (e.key === 'Escape') {
1497
+ e.preventDefault();
1498
+ if (search) setSearch('');
1499
+ else e.currentTarget.blur();
1500
+ }
1501
+ }}
1288
1502
  aria-label="Filter files"
1289
1503
  />
1290
1504
  {search ? (
1291
1505
  <button
1292
1506
  className="st-search-clear"
1293
1507
  onClick={() => setSearch('')}
1294
- title="Clear (Esc)"
1508
+ data-tip="Clear · Esc"
1295
1509
  aria-label="Clear search"
1296
1510
  >
1297
1511
  ×
@@ -1336,7 +1550,7 @@ function Sidebar({
1336
1550
  aria-expanded={sectionOpen}
1337
1551
  title={sectionOpen ? 'Collapse section' : 'Expand section'}
1338
1552
  >
1339
- <StIcon name={sectionOpen ? 'chevron-down' : 'chevron-right'} size={13} />
1553
+ <StIcon name="chevron-right" className={'st-chev' + (sectionOpen ? ' is-open' : '')} size={13} />
1340
1554
  <span className="st-sec-name">{meta.title}</span>
1341
1555
  {pill && <span className="st-pill">{pill}</span>}
1342
1556
  </button>
@@ -1417,7 +1631,7 @@ function HelpModal({ open, onClose, onStartTour }) {
1417
1631
  <span className="title" id="help-modal-title">
1418
1632
  Help · shortcuts &amp; commands
1419
1633
  </span>
1420
- <span className="sku">MDCC-DEV-SRV / v{MDCC_VERSION}</span>
1634
+ <span className="sku">MAUDE-DEV-SRV / v{MDCC_VERSION}</span>
1421
1635
  {onStartTour && (
1422
1636
  <button
1423
1637
  type="button"
@@ -1506,22 +1720,28 @@ function HelpModal({ open, onClose, onStartTour }) {
1506
1720
  </ul>
1507
1721
  </details>
1508
1722
  <details>
1509
- <summary>Tabs &amp; canvas</summary>
1723
+ <summary>Canvas &amp; panels</summary>
1510
1724
  <ul>
1511
1725
  <li>
1512
- click in tree <span>open tab</span>
1726
+ click in tree <span>open canvas (replaces the active one)</span>
1513
1727
  </li>
1514
1728
  <li>
1515
- <kbd>×</kbd> on tab <span>close tab</span>
1729
+ File Close canvas <span>clear the stage</span>
1516
1730
  </li>
1517
1731
  <li>
1518
- <kbd>⌘R</kbd> <span>reload iframe</span>
1732
+ <kbd>⌘R</kbd> <span>reload canvas</span>
1519
1733
  </li>
1520
1734
  <li>
1521
1735
  <kbd>/</kbd> <span>focus search</span>
1522
1736
  </li>
1523
1737
  <li>
1524
- <kbd>⌘⇧M</kbd> <span>toggle comments panel</span>
1738
+ <kbd>⌘⇧M</kbd> <span>comments panel</span>
1739
+ </li>
1740
+ <li>
1741
+ <kbd>⌘⇧I</kbd> <span>inspector</span>
1742
+ </li>
1743
+ <li>
1744
+ <kbd>?</kbd> <span>keyboard-shortcuts cheat sheet</span>
1525
1745
  </li>
1526
1746
  </ul>
1527
1747
  </details>
@@ -1661,7 +1881,7 @@ function HelpModal({ open, onClose, onStartTour }) {
1661
1881
  <details>
1662
1882
  <summary>Pin-to-element flow</summary>
1663
1883
  <ol>
1664
- <li>Open canvas tab</li>
1884
+ <li>Open a canvas</li>
1665
1885
  <li>
1666
1886
  <kbd>⌘</kbd>+click element
1667
1887
  </li>
@@ -1701,13 +1921,152 @@ function HelpModal({ open, onClose, onStartTour }) {
1701
1921
  );
1702
1922
  }
1703
1923
 
1924
+ // ───────── Keyboard-shortcuts overlay (DS components-shortcuts-overlay) ─────
1925
+ //
1926
+ // The ? cheat-sheet: dim scrim, shared panel material, four dense mono-headed
1927
+ // columns, Esc chip in the footer. REAL bindings only — every row here is
1928
+ // wired in the shell handler, the canvas input-router, or canvas-lib's
1929
+ // viewport controller. Scope chips mark the rows that need canvas focus.
1930
+
1931
+ const SHORTCUT_GROUPS = [
1932
+ {
1933
+ id: 'canvas',
1934
+ label: 'Canvas',
1935
+ items: [
1936
+ { label: 'Command palette', kbd: '⌘ K' },
1937
+ { label: 'New brief board', kbd: 'N' },
1938
+ { label: 'Export…', kbd: '⇧ ⌘ E' },
1939
+ { label: 'Handoff to production', kbd: '⇧ ⌘ H' },
1940
+ { label: 'Reload canvas', kbd: '⌘ R' },
1941
+ { label: 'Search files', kbd: '/', alt: '⌘ F' },
1942
+ ],
1943
+ },
1944
+ {
1945
+ id: 'tools',
1946
+ label: 'Tools · canvas focus',
1947
+ items: [
1948
+ { label: 'Move · Hand · Comment', kbd: 'V', alt: 'H / C' },
1949
+ { label: 'Pen · Highlighter · Eraser', kbd: 'B', alt: 'I / E' },
1950
+ { label: 'Shape · Arrow', kbd: 'R', alt: 'A' },
1951
+ { label: 'Sticky · Text · Section', kbd: 'N', alt: 'T / ⇧S' },
1952
+ { label: 'Undo / redo', kbd: '⌘ Z', alt: '⇧ ⌘ Z' },
1953
+ ],
1954
+ },
1955
+ {
1956
+ id: 'selection',
1957
+ label: 'Selection & zoom',
1958
+ items: [
1959
+ { label: 'Select element', kbd: '⌘ click' },
1960
+ { label: 'Add to selection', kbd: '⌘ ⇧ click' },
1961
+ { label: 'Preview deepest', kbd: '⌘ hover' },
1962
+ { label: 'Deselect · close menu', kbd: 'Esc' },
1963
+ { label: 'Zoom in / out', kbd: '⌘ +', alt: '⌘ −' },
1964
+ { label: 'Fit · actual size', kbd: '⌘ 0', alt: '⌘ 1' },
1965
+ ],
1966
+ },
1967
+ {
1968
+ id: 'view',
1969
+ label: 'View',
1970
+ items: [
1971
+ { label: 'Project tree', kbd: 'T' },
1972
+ { label: 'Design system view', kbd: 'S' },
1973
+ { label: 'Inspector', kbd: '⌘ ⇧ I' },
1974
+ { label: 'Comments sidebar', kbd: '⌘ ⇧ M' },
1975
+ { label: 'Annotations', kbd: '⇧ P' },
1976
+ { label: 'Hidden files', kbd: 'H' },
1977
+ { label: 'This cheat sheet · help', kbd: '?', alt: 'F1' },
1978
+ ],
1979
+ },
1980
+ ];
1981
+
1982
+ function ShortcutCombo({ kbd, alt }) {
1983
+ const combo = (s, key) => (
1984
+ <span className="so-combo" key={key}>
1985
+ {s.split(' ').map((k, i) => (
1986
+ <Kbd key={`${k}-${i}`}>{k}</Kbd>
1987
+ ))}
1988
+ </span>
1989
+ );
1990
+ return (
1991
+ <span className="so-combos">
1992
+ {combo(kbd, 'main')}
1993
+ {alt
1994
+ ? alt.split(' / ').map((a) => (
1995
+ <Fragment key={a}>
1996
+ <span className="so-or">/</span>
1997
+ {combo(a, a)}
1998
+ </Fragment>
1999
+ ))
2000
+ : null}
2001
+ </span>
2002
+ );
2003
+ }
2004
+
2005
+ function ShortcutsOverlay({ open, onClose }) {
2006
+ useEffect(() => {
2007
+ if (!open) return;
2008
+ function onKey(e) {
2009
+ if (e.key === 'Escape') {
2010
+ e.preventDefault();
2011
+ onClose();
2012
+ }
2013
+ }
2014
+ window.addEventListener('keydown', onKey);
2015
+ return () => window.removeEventListener('keydown', onKey);
2016
+ }, [open, onClose]);
2017
+ if (!open) return null;
2018
+ const bindings = SHORTCUT_GROUPS.reduce((n, g) => n + g.items.length, 0);
2019
+ return (
2020
+ <div
2021
+ className="st-scrim"
2022
+ role="presentation"
2023
+ onMouseDown={(e) => {
2024
+ if (e.target === e.currentTarget) onClose();
2025
+ }}
2026
+ >
2027
+ <div className="so-overlay" role="dialog" aria-modal="true" aria-label="Keyboard shortcuts">
2028
+ <div className="so-overlay-hd">
2029
+ <span className="so-title">Keyboard shortcuts</span>
2030
+ <span className="so-trigger">
2031
+ press <Kbd>?</Kbd> to open
2032
+ </span>
2033
+ </div>
2034
+ <div className="so-columns">
2035
+ {SHORTCUT_GROUPS.map((g) => (
2036
+ <section key={g.id} className={'so-section so-section--' + g.id}>
2037
+ <h3 className="so-section-hd">{g.label}</h3>
2038
+ <dl className="so-list">
2039
+ {g.items.map((it) => (
2040
+ <div key={it.label} className="so-pair">
2041
+ <dt>{it.label}</dt>
2042
+ <dd>
2043
+ <ShortcutCombo kbd={it.kbd} alt={it.alt} />
2044
+ </dd>
2045
+ </div>
2046
+ ))}
2047
+ </dl>
2048
+ </section>
2049
+ ))}
2050
+ </div>
2051
+ <div className="so-overlay-ft">
2052
+ <span>
2053
+ close with <Kbd>Esc</Kbd>
2054
+ </span>
2055
+ <span className="so-count">
2056
+ {bindings} bindings · {SHORTCUT_GROUPS.length} groups
2057
+ </span>
2058
+ </div>
2059
+ </div>
2060
+ </div>
2061
+ );
2062
+ }
2063
+
1704
2064
  // ───────── Menubar (CV-01/CV-08 top chrome) ─────────
1705
2065
  //
1706
2066
  // Replaces the legacy `.header` action-button toolbar. Mirrors the shared
1707
2067
  // 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.
2068
+ // status. View dropdown is wired to the panels + zoom that exist today;
2069
+ // Presentation Mode stays phase-tagged until it ships.
1711
2070
 
1712
2071
  const MENU_NAMES = ['File', 'Edit', 'View', 'Selection', 'Tools', 'Help'];
1713
2072
 
@@ -1729,7 +2088,7 @@ function useDropdownClose(onClose) {
1729
2088
  }, [onClose]);
1730
2089
  }
1731
2090
 
1732
- function ViewDropdown({ panels, onToggle, onClose }) {
2091
+ function ViewDropdown({ panels, onToggle, onClose, onZoom, hasCanvas }) {
1733
2092
  useDropdownClose(onClose);
1734
2093
  return (
1735
2094
  <div className="st-dropdown" role="menu" aria-label="View" style={{ left: 152 }}>
@@ -1752,117 +2111,55 @@ function ViewDropdown({ panels, onToggle, onClose }) {
1752
2111
  <span className="st-dd-check">{p.checked ? <StIcon name="check" size={13} /> : null}</span>
1753
2112
  <span>{p.label}</span>
1754
2113
  </span>
1755
- {p.phase ? <span className="st-dd-phase">{p.phase}</span> : <Kbd>{p.shortcut || ''}</Kbd>}
2114
+ {p.phase ? (
2115
+ <span className="st-dd-phase">{p.phase}</span>
2116
+ ) : p.shortcut ? (
2117
+ <Kbd>{p.shortcut}</Kbd>
2118
+ ) : null}
1756
2119
  </button>
1757
2120
  ))}
1758
2121
  <div className="st-dd-sep" />
1759
2122
  <div className="st-dd-hd">Zoom</div>
1760
2123
  {[
1761
- { label: 'Zoom In', shortcut: '⌘ +' },
1762
- { label: 'Zoom Out', shortcut: '⌘ −' },
1763
- { label: 'Fit to Screen', shortcut: '⌘ 0' },
1764
- { label: 'Actual Size · 100 %', shortcut: '0' },
2124
+ { op: 'in', label: 'Zoom In', shortcut: '⌘ +' },
2125
+ { op: 'out', label: 'Zoom Out', shortcut: '⌘ −' },
2126
+ { op: 'fit', label: 'Fit to Screen', shortcut: '⌘ 0' },
2127
+ { op: 'actual', label: 'Actual Size · 100 %', shortcut: '⌘ 1' },
1765
2128
  ].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
2129
  <button
1833
- key={t.id}
2130
+ key={z.label}
1834
2131
  type="button"
1835
2132
  role="menuitem"
1836
2133
  className="st-dd-item"
2134
+ aria-disabled={hasCanvas ? undefined : 'true'}
1837
2135
  onClick={() => {
1838
- onAction(t.id);
2136
+ if (!hasCanvas) return;
2137
+ onZoom?.(z.op);
1839
2138
  onClose();
1840
2139
  }}
1841
2140
  >
1842
2141
  <span className="st-dd-lead">
1843
2142
  <span className="st-dd-check" />
1844
- <span>{t.label}</span>
2143
+ <span>{z.label}</span>
1845
2144
  </span>
1846
- <Kbd>{t.shortcut}</Kbd>
2145
+ <Kbd>{z.shortcut}</Kbd>
1847
2146
  </button>
1848
2147
  ))}
1849
2148
  </div>
1850
2149
  );
1851
2150
  }
1852
2151
 
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 }) {
2152
+ // Help dropdowncheat sheet · deep help · tour · what's new.
2153
+ // Shared menubar dropdown — File / Edit / Selection / Tools / Help all render
2154
+ // the identical {id,label,shortcut,sep?,disabled?} list over the same button
2155
+ // skeleton, differing only in aria-label, left offset, and an optional header.
2156
+ // (ViewDropdown stays separate — its checkbox/phase/zoom-op rows genuinely
2157
+ // diverge.) Per the /flow:done simplifier pass — collapsed 5 near-dupes.
2158
+ function DropdownMenu({ label, left, header, items, onAction, onClose }) {
1856
2159
  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
2160
  return (
1865
- <div className="st-dropdown" role="menu" aria-label="File" style={{ left: 40 }}>
2161
+ <div className="st-dropdown" role="menu" aria-label={label} style={{ left }}>
2162
+ {header ? <div className="st-dd-hd">{header}</div> : null}
1866
2163
  {items.map((it, i) =>
1867
2164
  it.sep ? (
1868
2165
  <div key={'s' + i} className="st-dd-sep" />
@@ -1872,7 +2169,9 @@ function FileDropdown({ onAction, onClose }) {
1872
2169
  type="button"
1873
2170
  role="menuitem"
1874
2171
  className="st-dd-item"
2172
+ aria-disabled={it.disabled ? 'true' : undefined}
1875
2173
  onClick={() => {
2174
+ if (it.disabled) return;
1876
2175
  onAction(it.id);
1877
2176
  onClose();
1878
2177
  }}
@@ -1881,7 +2180,7 @@ function FileDropdown({ onAction, onClose }) {
1881
2180
  <span className="st-dd-check" />
1882
2181
  <span>{it.label}</span>
1883
2182
  </span>
1884
- <Kbd>{it.shortcut}</Kbd>
2183
+ {it.shortcut ? <Kbd>{it.shortcut}</Kbd> : null}
1885
2184
  </button>
1886
2185
  )
1887
2186
  )}
@@ -1889,40 +2188,103 @@ function FileDropdown({ onAction, onClose }) {
1889
2188
  );
1890
2189
  }
1891
2190
 
2191
+ function HelpDropdown({ onAction, onClose }) {
2192
+ return (
2193
+ <DropdownMenu
2194
+ label="Help"
2195
+ left={320}
2196
+ onAction={onAction}
2197
+ onClose={onClose}
2198
+ items={[
2199
+ { id: 'shortcuts', label: 'Keyboard shortcuts', shortcut: '?' },
2200
+ { id: 'help', label: 'Help · commands & flows', shortcut: 'F1' },
2201
+ { sep: true },
2202
+ { id: 'tour', label: 'Take the tour' },
2203
+ { id: 'whatsnew', label: "What's new" },
2204
+ ]}
2205
+ />
2206
+ );
2207
+ }
2208
+
2209
+ function SelectionDropdown({ onAction, onClose }) {
2210
+ return (
2211
+ <DropdownMenu
2212
+ label="Selection"
2213
+ left={214}
2214
+ onAction={onAction}
2215
+ onClose={onClose}
2216
+ items={[
2217
+ { id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
2218
+ { id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⌘ ⇧ A' },
2219
+ ]}
2220
+ />
2221
+ );
2222
+ }
2223
+
2224
+ function ToolsDropdown({ onAction, onClose }) {
2225
+ // Mirrors DEFAULT_TOOLS in apps/studio/use-tool-mode.tsx — kept in sync by
2226
+ // hand because the menubar lives in the dev-server shell (no shared bundle
2227
+ // with the canvas iframes).
2228
+ return (
2229
+ <DropdownMenu
2230
+ label="Tools"
2231
+ left={290}
2232
+ header="Tool palette"
2233
+ onAction={onAction}
2234
+ onClose={onClose}
2235
+ items={[
2236
+ { id: 'move', label: 'Move', shortcut: 'V' },
2237
+ { id: 'hand', label: 'Hand', shortcut: 'H' },
2238
+ { id: 'comment', label: 'Comment', shortcut: 'C' },
2239
+ { id: 'pen', label: 'Pen', shortcut: 'B' },
2240
+ { id: 'rect', label: 'Rect', shortcut: 'R' },
2241
+ { id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
2242
+ { id: 'sticky', label: 'Sticky', shortcut: 'N' },
2243
+ { id: 'arrow', label: 'Arrow', shortcut: 'A' },
2244
+ { id: 'text', label: 'Text', shortcut: 'T' },
2245
+ { id: 'eraser', label: 'Eraser', shortcut: 'E' },
2246
+ ]}
2247
+ />
2248
+ );
2249
+ }
2250
+
2251
+ // Plan C follow-up — File + Edit menus, previously inert. Both dispatch to real
2252
+ // shell flows (File) or the in-canvas undo stack / selection bridges (Edit).
2253
+ function FileDropdown({ onAction, onClose, hasCanvas }) {
2254
+ return (
2255
+ <DropdownMenu
2256
+ label="File"
2257
+ left={40}
2258
+ onAction={onAction}
2259
+ onClose={onClose}
2260
+ items={[
2261
+ // Bare N — the browser reserves ⌘N (New Window) and never delivers it.
2262
+ { id: 'new', label: 'New canvas…', shortcut: 'N' },
2263
+ { id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
2264
+ { id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
2265
+ { sep: true },
2266
+ { id: 'reload', label: 'Reload canvas', shortcut: '⌘R', disabled: !hasCanvas },
2267
+ { id: 'close', label: 'Close canvas', disabled: !hasCanvas },
2268
+ ]}
2269
+ />
2270
+ );
2271
+ }
2272
+
1892
2273
  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
2274
  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>
2275
+ <DropdownMenu
2276
+ label="Edit"
2277
+ left={90}
2278
+ onAction={onAction}
2279
+ onClose={onClose}
2280
+ items={[
2281
+ { id: 'undo', label: 'Undo', shortcut: '⌘Z' },
2282
+ { id: 'redo', label: 'Redo', shortcut: '⇧⌘Z' },
2283
+ { sep: true },
2284
+ { id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
2285
+ { id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⇧⌘A' },
2286
+ ]}
2287
+ />
1926
2288
  );
1927
2289
  }
1928
2290
 
@@ -1940,6 +2302,8 @@ function Menubar({
1940
2302
  showHidden,
1941
2303
  onToggleShowHidden,
1942
2304
  onOpenHelp,
2305
+ onOpenShortcuts,
2306
+ onStartTour,
1943
2307
  annotationsVisible,
1944
2308
  onToggleAnnotations,
1945
2309
  postToActiveCanvas,
@@ -1948,10 +2312,13 @@ function Menubar({
1948
2312
  artboardCount = 0,
1949
2313
  presence = null,
1950
2314
  inspectorOpen,
2315
+ inspectorTab,
1951
2316
  onToggleInspector,
2317
+ onOpenLayers,
1952
2318
  onNewCanvas,
1953
2319
  onOpenExport,
1954
2320
  onReload,
2321
+ onCloseCanvas,
1955
2322
  }) {
1956
2323
  const isSystem = activePath === SYSTEM_TAB;
1957
2324
  const stamp = isSystem ? 'SYSTEM' : activePath ? 'CANVAS' : 'IDLE';
@@ -1981,8 +2348,20 @@ function Menubar({
1981
2348
  checked: showHidden,
1982
2349
  disabled: false,
1983
2350
  },
1984
- { id: 'layers', label: 'Layers Panel', phase: 'Phase 12', disabled: true },
1985
- { id: 'inspector', label: 'Inspector', shortcut: 'I', checked: inspectorOpen, disabled: false },
2351
+ {
2352
+ id: 'layers',
2353
+ label: 'Layers',
2354
+ shortcut: '',
2355
+ checked: inspectorOpen && inspectorTab === 'layers',
2356
+ disabled: false,
2357
+ },
2358
+ {
2359
+ id: 'inspector',
2360
+ label: 'Inspector',
2361
+ shortcut: '⌘ ⇧ I',
2362
+ checked: inspectorOpen,
2363
+ disabled: false,
2364
+ },
1986
2365
  {
1987
2366
  id: 'annotate',
1988
2367
  label: 'Annotations',
@@ -1993,25 +2372,67 @@ function Menubar({
1993
2372
  { id: 'present', label: 'Presentation Mode', phase: 'Phase 6', disabled: true },
1994
2373
  ];
1995
2374
 
1996
- const DROPDOWN_MENUS = ['file', 'edit', 'view', 'selection', 'tools'];
2375
+ const DROPDOWN_MENUS = ['file', 'edit', 'view', 'selection', 'tools', 'help'];
1997
2376
  function onMenuClick(key) {
1998
2377
  if (DROPDOWN_MENUS.includes(key)) {
1999
2378
  setOpenMenu(openMenu === key ? null : key);
2000
- } else if (key === 'help') {
2001
- setOpenMenu(null);
2002
- onOpenHelp();
2003
2379
  }
2004
2380
  }
2005
2381
 
2382
+ // Keyboard menubar (native-menu parity): while a dropdown is open, ↑/↓ rove
2383
+ // its items, ←/→ switch to the adjacent menu, Home/End jump, Esc returns
2384
+ // focus to the trigger (useDropdownClose handles the close itself).
2385
+ useEffect(() => {
2386
+ if (!openMenu || !DROPDOWN_MENUS.includes(openMenu)) return;
2387
+ // Move focus into the menu so ↑/↓ work immediately after a click.
2388
+ const t = setTimeout(() => {
2389
+ document
2390
+ .querySelector('.st-dropdown [role="menuitem"]:not([aria-disabled="true"])')
2391
+ ?.focus();
2392
+ }, 0);
2393
+ function onKey(e) {
2394
+ const items = [
2395
+ ...document.querySelectorAll('.st-dropdown [role="menuitem"]:not([aria-disabled="true"])'),
2396
+ ];
2397
+ if (!items.length) return;
2398
+ const idx = items.indexOf(document.activeElement);
2399
+ if (e.key === 'ArrowDown') {
2400
+ e.preventDefault();
2401
+ items[(idx + 1) % items.length].focus();
2402
+ } else if (e.key === 'ArrowUp') {
2403
+ e.preventDefault();
2404
+ items[(idx - 1 + items.length) % items.length].focus();
2405
+ } else if (e.key === 'Home') {
2406
+ e.preventDefault();
2407
+ items[0].focus();
2408
+ } else if (e.key === 'End') {
2409
+ e.preventDefault();
2410
+ items[items.length - 1].focus();
2411
+ } else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
2412
+ e.preventDefault();
2413
+ const dir = e.key === 'ArrowRight' ? 1 : -1;
2414
+ const cur = DROPDOWN_MENUS.indexOf(openMenu);
2415
+ setOpenMenu(DROPDOWN_MENUS[(cur + dir + DROPDOWN_MENUS.length) % DROPDOWN_MENUS.length]);
2416
+ } else if (e.key === 'Escape') {
2417
+ document.querySelector('.st-menu[aria-expanded="true"]')?.focus();
2418
+ }
2419
+ }
2420
+ window.addEventListener('keydown', onKey);
2421
+ return () => {
2422
+ clearTimeout(t);
2423
+ window.removeEventListener('keydown', onKey);
2424
+ };
2425
+ }, [openMenu, setOpenMenu]);
2426
+
2006
2427
  return (
2007
2428
  <header className="st-menubar" role="menubar" aria-label="Application menubar">
2008
- <span className="st-brand">
2429
+ <span className="st-brand" data-tour="brand">
2009
2430
  <span className="st-brand-mark">
2010
2431
  <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
2432
  </span>
2012
2433
  <span className="st-brand-name">maude</span>
2013
2434
  </span>
2014
- <nav className="st-menus" aria-label="Application menus">
2435
+ <nav className="st-menus" aria-label="Application menus" data-tour="menus">
2015
2436
  {MENU_NAMES.map((name) => {
2016
2437
  const key = name.toLowerCase();
2017
2438
  const hasDropdown = DROPDOWN_MENUS.includes(key);
@@ -2040,11 +2461,13 @@ function Menubar({
2040
2461
  </nav>
2041
2462
  {openMenu === 'file' && (
2042
2463
  <FileDropdown
2464
+ hasCanvas={!!activePath}
2043
2465
  onAction={(id) => {
2044
2466
  if (id === 'new') onNewCanvas?.();
2045
2467
  else if (id === 'export') onOpenExport?.('export');
2046
2468
  else if (id === 'handoff') onOpenExport?.('handoff');
2047
2469
  else if (id === 'reload') onReload?.();
2470
+ else if (id === 'close') onCloseCanvas?.();
2048
2471
  }}
2049
2472
  onClose={() => setOpenMenu(null)}
2050
2473
  />
@@ -2070,7 +2493,10 @@ function Menubar({
2070
2493
  else if (id === 'hidden') onToggleShowHidden();
2071
2494
  else if (id === 'annotate') onToggleAnnotations();
2072
2495
  else if (id === 'inspector') onToggleInspector();
2496
+ else if (id === 'layers') onOpenLayers?.();
2073
2497
  }}
2498
+ onZoom={(op) => postToActiveCanvas({ dgn: 'zoom', op })}
2499
+ hasCanvas={!!activePath && !isSystem}
2074
2500
  onClose={() => setOpenMenu(null)}
2075
2501
  />
2076
2502
  )}
@@ -2090,14 +2516,26 @@ function Menubar({
2090
2516
  onClose={() => setOpenMenu(null)}
2091
2517
  />
2092
2518
  )}
2093
- <div className="st-mb-right">
2519
+ {openMenu === 'help' && (
2520
+ <HelpDropdown
2521
+ onAction={(id) => {
2522
+ if (id === 'shortcuts') onOpenShortcuts?.();
2523
+ else if (id === 'help') onOpenHelp?.();
2524
+ else if (id === 'tour') onStartTour?.();
2525
+ else if (id === 'whatsnew') onOpenWhatsNew?.();
2526
+ }}
2527
+ onClose={() => setOpenMenu(null)}
2528
+ />
2529
+ )}
2530
+ <div className="st-mb-right" data-tour="status">
2094
2531
  {presence ? <div className="st-presence">{presence}</div> : null}
2095
2532
  <button
2096
2533
  type="button"
2097
2534
  className="st-whatsnew"
2535
+ data-tour="whatsnew"
2098
2536
  data-unseen={whatsNewCount > 0 ? 'true' : 'false'}
2099
2537
  aria-label={`What's new${whatsNewCount > 0 ? ` — ${whatsNewCount} unseen` : ''}`}
2100
- title="What's new"
2538
+ data-tip="What's new"
2101
2539
  onClick={onOpenWhatsNew}
2102
2540
  >
2103
2541
  <StIcon name="sparkle" size={15} />
@@ -2107,7 +2545,7 @@ function Menubar({
2107
2545
  {fileLabel}
2108
2546
  </span>
2109
2547
  <span className="st-mb-sep" />
2110
- <span className="st-mb-count" title="Artboards in the open canvas">
2548
+ <span className="st-mb-count" data-tip="Artboards in the open canvas">
2111
2549
  <span className="st-dot" style={{ background: 'var(--accent)' }} />
2112
2550
  {artboardCount} ARTBOARDS
2113
2551
  </span>
@@ -2127,9 +2565,11 @@ function Viewport({
2127
2565
  onSelectDs,
2128
2566
  project,
2129
2567
  cfg,
2568
+ loadingPath,
2569
+ onIframeLoad,
2130
2570
  }) {
2131
2571
  return (
2132
- <div className="viewport st-stage">
2572
+ <div className="viewport st-stage" data-tour="viewport">
2133
2573
  {tabs.length === 0 && (
2134
2574
  <div className="st-empty">
2135
2575
  <div className="st-empty-brand">
@@ -2148,8 +2588,8 @@ function Viewport({
2148
2588
  <strong>Design system</strong> view above it.
2149
2589
  <br />
2150
2590
  <br />
2151
- Tabs work like in an editor — close with the × on each tab. <Kbd>⌘R</Kbd> reloads the
2152
- active iframe.
2591
+ Opening a file replaces the active canvas. <Kbd>⌘R</Kbd> reloads it; File ▸ Close
2592
+ canvas clears the stage.
2153
2593
  <br />
2154
2594
  <br />
2155
2595
  <strong>Element selection:</strong> hold <Kbd>⌘</Kbd> inside the canvas and hover for a
@@ -2182,6 +2622,7 @@ function Viewport({
2182
2622
  src={canvasUrl(t.path, cfg)}
2183
2623
  className={t.path === activePath ? 'active' : ''}
2184
2624
  data-path={t.path}
2625
+ onLoad={() => onIframeLoad?.(t.path)}
2185
2626
  // T2 (9.1-A) — only sandbox + delegate clipboard when the canvas is
2186
2627
  // served cross-origin (canvasOrigin present = the split is on). In
2187
2628
  // the default same-origin mode these attrs are omitted so behavior
@@ -2193,6 +2634,19 @@ function Viewport({
2193
2634
  />
2194
2635
  );
2195
2636
  })}
2637
+ {loadingPath && loadingPath === activePath && (
2638
+ // DS skeletons recipe — calm .skel pulse while the canvas-shell compiles
2639
+ // the TSX. Cleared by the iframe's dgn:'loaded' message (or the onLoad
2640
+ // fallback timer for legacy .html canvases that never post it).
2641
+ <div className="st-canvas-loading" aria-hidden="true">
2642
+ <div className="st-skel-card">
2643
+ <div className="st-skel-cap st-mono">compiling canvas…</div>
2644
+ <span className="skel st-skel-thumb" />
2645
+ <span className="skel st-skel-line" style={{ width: '72%' }} />
2646
+ <span className="skel st-skel-line" style={{ width: '46%' }} />
2647
+ </div>
2648
+ </div>
2649
+ )}
2196
2650
  </div>
2197
2651
  );
2198
2652
  }
@@ -2372,7 +2826,7 @@ function SystemView({ data, onOpen, cfg, onSelectDs }) {
2372
2826
  return (
2373
2827
  <div className="sv">
2374
2828
  <header className="sv-header">
2375
- <span className="sv-sku">MDCC-DSN/01</span>
2829
+ <span className="sv-sku">MAUDE-DSN/01</span>
2376
2830
  <span className="sv-title">design system view</span>
2377
2831
  {hasPicker ? (
2378
2832
  <label className="sv-ds-picker">
@@ -2516,7 +2970,9 @@ function StatusBar({
2516
2970
  </span>
2517
2971
  </span>
2518
2972
 
2519
- {selected && selected.selector && !isSystem && (
2973
+ {/* Selection is meaningless without an open canvas — a stale _active.json
2974
+ selection used to leave a ghost SELECTED chip on the empty shell. */}
2975
+ {activePath && selected && selected.selector && !isSystem && (
2520
2976
  <span className="st-sb-slot st-sb-sel" role="group" aria-label="Selected element">
2521
2977
  <span className="lbl">selected</span>
2522
2978
  <span className="val" title={title}>
@@ -2526,7 +2982,8 @@ function StatusBar({
2526
2982
  type="button"
2527
2983
  className="st-sb-sel-clear"
2528
2984
  onClick={onClearSelected}
2529
- title="Clear (Esc inside iframe)"
2985
+ data-tip="Clear · Esc inside iframe"
2986
+ data-tip-pos="top"
2530
2987
  aria-label="Clear selection"
2531
2988
  >
2532
2989
  ×
@@ -2566,7 +3023,8 @@ function StatusBar({
2566
3023
  type="button"
2567
3024
  className="st-sb-theme"
2568
3025
  onClick={onToggleTheme}
2569
- title={`Switch to ${nextTheme} theme`}
3026
+ data-tip={`Switch to ${nextTheme} theme`}
3027
+ data-tip-pos="top"
2570
3028
  aria-label={`Switch to ${nextTheme} theme`}
2571
3029
  >
2572
3030
  <StIcon name={theme === 'dark' ? 'sun' : 'moon'} size={13} />
@@ -2588,6 +3046,8 @@ function CommentsPanel({
2588
3046
  onResolve,
2589
3047
  onReopen,
2590
3048
  onDelete,
3049
+ width,
3050
+ resizing,
2591
3051
  }) {
2592
3052
  const counts = totalCounts(commentsByFile);
2593
3053
  // Build groups: [{ file, comments: filtered }]
@@ -2613,7 +3073,11 @@ function CommentsPanel({
2613
3073
  }
2614
3074
 
2615
3075
  return (
2616
- <aside className="st-rpanel" aria-label="Comments">
3076
+ <aside
3077
+ className={'st-rpanel' + (resizing ? ' is-resizing' : '')}
3078
+ style={width ? { width, flexBasis: width } : undefined}
3079
+ aria-label="Comments"
3080
+ >
2617
3081
  <div className="st-rp-tabs st-rp-tabs--filters">
2618
3082
  <div className="st-cm-filters" role="tablist">
2619
3083
  <button
@@ -2748,37 +3212,66 @@ function SyncBanner({ status }) {
2748
3212
  if (!status || status.linked === false) return null;
2749
3213
  // DDR-060 / 9.1-D — the "linked but 0 syncable" state is surfaced in the
2750
3214
  // status bar (sb-sync slot), NOT as a floating banner. This component owns
2751
- // only the transient offline / reconnect-flash banner (Task 8).
3215
+ // the transient offline / reconnect-flash banner (Task 8) plus the DDR-102
3216
+ // rejected-docs chip and divergence-resolution toast.
2752
3217
  if (status.notSyncable) return null;
2753
3218
  const { state, queuedOps, flash, conflicts } = status;
2754
3219
  const showFlash = flash === 'synced';
2755
3220
  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
-
3221
+ // DDR-102 per-doc rollup + the latest divergence notice (additive fields;
3222
+ // an old payload without them renders exactly the pre-DDR-102 banner).
3223
+ const rejected = status.docs?.rejected ?? 0;
3224
+ const lastDiverged = Array.isArray(conflicts)
3225
+ ? [...conflicts].reverse().find((c) => c.kind === 'cold-start-diverged')
3226
+ : null;
3227
+ if (!offline && !showFlash && !lastDiverged && rejected === 0) return null;
3228
+
3229
+ // One banner at a time — priority: reconnect flash > offline > divergence
3230
+ // toast > rejected chip. Dismissal is keyed per state so a new event
3231
+ // (another conflict, a changed rejected count) re-surfaces it.
2760
3232
  let variant;
2761
3233
  let text;
3234
+ let dismissKey;
2762
3235
  if (showFlash) {
2763
3236
  variant = 'success';
2764
3237
  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.`;
3238
+ dismissKey = `${state}:flash`;
3239
+ } else if (offline) {
3240
+ const conflictNote =
3241
+ conflicts && conflicts.length > 0 ? ` (${conflicts.length} conflict notice(s))` : '';
3242
+ if (state === 'offline-long') {
3243
+ variant = 'error';
3244
+ text = `Long offline — ${queuedOps} edit(s) queued. Consider \`git commit && git push\` as backup.${conflictNote}`;
3245
+ } else {
3246
+ variant = 'warn';
3247
+ text = `Working offline · ${queuedOps} edit(s) queued · will sync when the hub reconnects.${conflictNote}`;
3248
+ }
3249
+ dismissKey = `${state}:offline`;
3250
+ } else if (lastDiverged) {
3251
+ // DDR-102 fail-closed: a snapshotFailed conflict means the hub-wins overwrite
3252
+ // was REFUSED (local kept) because _history couldn't be written — surface it
3253
+ // as an error, not a routine "kept newest" notice.
3254
+ if (lastDiverged.snapshotFailed) {
3255
+ variant = 'error';
3256
+ text = `Diverged on ${lastDiverged.slug}: kept local — the history snapshot FAILED, so the overwrite was refused. Check disk space / .design/_history write access.`;
3257
+ } else {
3258
+ variant = 'warn';
3259
+ text = `Diverged on ${lastDiverged.slug}: kept the ${
3260
+ lastDiverged.winner === 'local' ? 'local (newer)' : 'hub'
3261
+ } version — the other is snapshotted in history → /design:rollback ${lastDiverged.slug}`;
3262
+ }
3263
+ dismissKey = `diverged:${lastDiverged.slug}:${lastDiverged.at}`;
2768
3264
  } else {
2769
3265
  variant = 'warn';
2770
- text = `Working offline · ${queuedOps} edit(s) queued · will sync when the hub reconnects.`;
3266
+ text = `${rejected} canvas(es) not syncing the hub rejected auth. Details: maude design status`;
3267
+ dismissKey = `rejected:${rejected}`;
2771
3268
  }
2772
- const conflictNote =
2773
- conflicts && conflicts.length > 0 ? ` (${conflicts.length} conflict notice(s))` : '';
3269
+ if (dismissedKey === dismissKey) return null;
2774
3270
 
2775
3271
  return (
2776
3272
  <div role="status" aria-live="polite" className={`st-banner st-banner--${variant}`}>
2777
3273
  <span className="st-banner-dot" aria-hidden="true" />
2778
- <span>
2779
- {text}
2780
- {conflictNote}
2781
- </span>
3274
+ <span>{text}</span>
2782
3275
  <button
2783
3276
  type="button"
2784
3277
  className="st-banner-close"
@@ -2792,16 +3285,1819 @@ function SyncBanner({ status }) {
2792
3285
  );
2793
3286
  }
2794
3287
 
2795
- // ---------- Inspector panel (display-only) ----------
3288
+ // ---------- CSS knobs (Phase 12.2, DDR-104) — interactive panel ----------
2796
3289
  //
2797
- // T6 (Plan C) right-dock Inspect / Layers / CSS tabs per `.design/ui/Studio.tsx`
2798
- // InspectorPanel. DISPLAY-ONLY: reads the live `selected` payload from the
2799
- // inspector bridge (`bounds`, `tag`, `classes`, `dom_path`, `html`). The
2800
- // mockup's live-CSS-knob WRITEBACK is Phase 12 (needs a canvas-origin write
2801
- // bridge, DDR-054) the CSS tab shows markup read-only + keeps that callout, so
2802
- // it never implies functionality it lacks (the exact reason DDR-096 deferred it).
2803
- function InspectorPanel({ selected, onClose }) {
2804
- const [tab, setTab] = useState('inspect');
3290
+ // Hybrid vocabulary (friendly collapsible section headers + CSS-named rows),
3291
+ // per-field DS-token quick-pick, nested box-model widget, per-corner radius,
3292
+ // per-row provenance (token-bound / raw-override / inherited), per-field save
3293
+ // state, and two escape hatches: custom CSS property (via /_api/edit-css) +
3294
+ // custom HTML attribute (via /_api/edit-attr). Each knob pre-fills from the
3295
+ // AUTHORED inline value (`el.authored`); the resolved `computed` value is a
3296
+ // faint placeholder only (NOT editable the v1 UX bug). Ported from the
3297
+ // critic-approved + user-iterated `.design/ui/Studio.tsx` spec.
3298
+
3299
+ const CSS_DISPLAYS = ['block', 'inline-block', 'flex', 'inline-flex', 'grid', 'inline', 'none'];
3300
+ const CSS_FLEX_DIR = ['row', 'row-reverse', 'column', 'column-reverse'];
3301
+ const CSS_ALIGN = ['stretch', 'flex-start', 'center', 'flex-end', 'baseline'];
3302
+ const CSS_JUSTIFY = [
3303
+ 'flex-start',
3304
+ 'center',
3305
+ 'flex-end',
3306
+ 'space-between',
3307
+ 'space-around',
3308
+ 'space-evenly',
3309
+ ];
3310
+ const CSS_WEIGHTS = ['300', '400', '500', '600', '700', '800'];
3311
+ const CSS_FONTS = [
3312
+ 'inherit',
3313
+ 'system-ui',
3314
+ 'sans-serif',
3315
+ 'serif',
3316
+ 'monospace',
3317
+ 'Inter',
3318
+ 'Inter Tight',
3319
+ 'JetBrains Mono',
3320
+ ];
3321
+ const CSS_BORDER_STYLES = ['none', 'solid', 'dashed', 'dotted', 'double'];
3322
+ const CSS_UNITS = ['px', 'rem', 'em', '%', 'vw', 'vh', 'auto'];
3323
+ // Properties whose bare-number value is unitless — never append a unit suffix.
3324
+ const CSS_UNITLESS = new Set(['line-height', 'opacity', 'font-weight', 'z-index', 'flex-grow', 'flex-shrink', 'order']);
3325
+ // #2 — Figma-style property prefix inside numeric fields: a small glyph (icon) or
3326
+ // a mono letter (t). Only where it reads cleanly; selects/colours keep their own.
3327
+ const PROP_LEAD = {
3328
+ 'font-size': { icon: 'p-size' },
3329
+ 'line-height': { icon: 'p-lineheight' },
3330
+ 'letter-spacing': { icon: 'p-letterspacing' },
3331
+ gap: { icon: 'p-gap' },
3332
+ width: { t: 'W' },
3333
+ height: { t: 'H' },
3334
+ 'max-width': { t: 'W' },
3335
+ 'border-radius': { icon: 'p-corner' },
3336
+ 'border-width': { icon: 'p-border' },
3337
+ opacity: { icon: 'p-opacity' },
3338
+ };
3339
+ const CSS_ALIGN_OPTS = ['left', 'center', 'right', 'justify'];
3340
+
3341
+ let _cssColorCtx = null;
3342
+ // Normalize any CSS color string to #rrggbb for the native color input via a
3343
+ // throwaway canvas fillStyle round-trip (parses rgb/hsl/named). Unparseable
3344
+ // values (some oklch) fall back to '' → picker defaults to #000000; the swatch
3345
+ // still shows the true color string regardless.
3346
+ function cssColorToHex(c) {
3347
+ if (!c) return '';
3348
+ if (/^#[0-9a-f]{6}$/i.test(c)) return c.toLowerCase();
3349
+ try {
3350
+ if (!_cssColorCtx) _cssColorCtx = document.createElement('canvas').getContext('2d');
3351
+ if (!_cssColorCtx) return '';
3352
+ _cssColorCtx.fillStyle = '#000000';
3353
+ _cssColorCtx.fillStyle = c;
3354
+ const v = _cssColorCtx.fillStyle;
3355
+ if (/^#[0-9a-f]{6}$/i.test(v)) return v.toLowerCase();
3356
+ const m = v.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
3357
+ if (m) {
3358
+ return `#${[m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('')}`;
3359
+ }
3360
+ } catch {
3361
+ /* canvas unavailable */
3362
+ }
3363
+ return '';
3364
+ }
3365
+
3366
+ // ---- Colour math for the HSV picker (#6 — Figma-style colour control) ----
3367
+ const clamp01 = (n) => Math.min(1, Math.max(0, n));
3368
+ function hexToRgb(hex) {
3369
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
3370
+ if (!m) return { r: 0, g: 0, b: 0 };
3371
+ const n = Number.parseInt(m[1], 16);
3372
+ return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
3373
+ }
3374
+ function rgbToHex({ r, g, b }) {
3375
+ return `#${[r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`;
3376
+ }
3377
+ function rgbToHsv({ r, g, b }) {
3378
+ r /= 255;
3379
+ g /= 255;
3380
+ b /= 255;
3381
+ const max = Math.max(r, g, b);
3382
+ const min = Math.min(r, g, b);
3383
+ const d = max - min;
3384
+ let h = 0;
3385
+ if (d) {
3386
+ if (max === r) h = ((g - b) / d) % 6;
3387
+ else if (max === g) h = (b - r) / d + 2;
3388
+ else h = (r - g) / d + 4;
3389
+ h *= 60;
3390
+ if (h < 0) h += 360;
3391
+ }
3392
+ return { h, s: max ? d / max : 0, v: max };
3393
+ }
3394
+ function hsvToRgb({ h, s, v }) {
3395
+ const c = v * s;
3396
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
3397
+ const m = v - c;
3398
+ let r = 0;
3399
+ let g = 0;
3400
+ let b = 0;
3401
+ if (h < 60) [r, g, b] = [c, x, 0];
3402
+ else if (h < 120) [r, g, b] = [x, c, 0];
3403
+ else if (h < 180) [r, g, b] = [0, c, x];
3404
+ else if (h < 240) [r, g, b] = [0, x, c];
3405
+ else if (h < 300) [r, g, b] = [x, 0, c];
3406
+ else [r, g, b] = [c, 0, x];
3407
+ return { r: (r + m) * 255, g: (g + m) * 255, b: (b + m) * 255 };
3408
+ }
3409
+
3410
+ // Round bare px to whole numbers for the placeholder hint; pass other values through.
3411
+ function cssHint(v) {
3412
+ if (!v) return '';
3413
+ const m = /^(-?\d*\.?\d+)px$/.exec(v);
3414
+ return m ? `${Math.round(Number.parseFloat(m[1]))}px` : v;
3415
+ }
3416
+
3417
+ // Split "16px" → { n:"16", unit:"px" }; "auto"→{n:"",unit:"auto"}; var()/raw → {n:raw,unit:""}.
3418
+ function cssSplitUnit(v) {
3419
+ if (!v) return { n: '', unit: 'px' };
3420
+ const t = v.trim();
3421
+ const m = /^(-?\d*\.?\d+)\s*(px|rem|em|%|vw|vh)?$/.exec(t);
3422
+ if (m) return { n: m[1], unit: m[2] || 'px' };
3423
+ if (t === 'auto') return { n: '', unit: 'auto' };
3424
+ return { n: t, unit: '' };
3425
+ }
3426
+
3427
+ // Phase 12.2/12.3 — the WS `selected` echo is the server's projection
3428
+ // (SelectedElement) and LACKS the client-only DOM fields the CSS knobs pre-fill
3429
+ // from (`authored` / `computed` inline style + `customStyles` / `attrs` — all
3430
+ // captured in the iframe, never round-tripped through the server). When the echo
3431
+ // is for the SAME element we already hold locally, preserve those fields instead
3432
+ // of clobbering them to empty (else the server round-trip wipes the custom-CSS /
3433
+ // custom-attr rows + computed readout right after selection).
3434
+ function mergeSelClientFields(incoming, prev) {
3435
+ if (!incoming || Array.isArray(incoming) || Array.isArray(prev) || !prev) return incoming;
3436
+ if (!incoming.id || incoming.id !== prev.id) return incoming;
3437
+ return {
3438
+ ...incoming,
3439
+ authored: incoming.authored ?? prev.authored,
3440
+ computed: incoming.computed ?? prev.computed,
3441
+ customStyles: incoming.customStyles ?? prev.customStyles,
3442
+ attrs: incoming.attrs ?? prev.attrs,
3443
+ };
3444
+ }
3445
+
3446
+ // Resolve the active canvas's DS tokens CSS path (mirrors canvas-url.js / DDR-093):
3447
+ // the canvas's declared DS wins, else designSystems[0], else the legacy default.
3448
+ function cssTokensRelFor(file, cfg) {
3449
+ const ds0 = cfg?.designSystems?.[0];
3450
+ const name = file ? cfg?.canvasDesignSystems?.[file] : null;
3451
+ const ds = (name && cfg?.designSystems?.find((d) => d.name === name)) || ds0;
3452
+ return ds?.tokensCssRel || cfg?.tokensCssRel || ds0?.tokensCssRel || '';
3453
+ }
3454
+
3455
+ // The active canvas's DS NAME (mirrors cssTokensRelFor's resolution order).
3456
+ function activeDsNameFor(file, cfg) {
3457
+ const byCanvas = file ? cfg?.canvasDesignSystems?.[file] : null;
3458
+ return byCanvas || cfg?.defaultDesignSystem || cfg?.designSystems?.[0]?.name || null;
3459
+ }
3460
+
3461
+ // Parse a DS tokens CSS body → token names grouped by family + a name→value map
3462
+ // (resolving one level of var() aliasing so the popover renders real swatches +
3463
+ // values). Phase 12.3 (W2.1/W3 multi-DS).
3464
+ function parseTokensCss(css) {
3465
+ const raw = {};
3466
+ for (const m of css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;}]+)/gi)) {
3467
+ if (!(m[1] in raw)) raw[m[1]] = m[2].trim();
3468
+ }
3469
+ const vals = {};
3470
+ for (const name of Object.keys(raw)) {
3471
+ const v = raw[name];
3472
+ const ref = /^var\(\s*(--[a-z0-9-]+)\s*\)$/i.exec(v);
3473
+ vals[name] = ref && raw[ref[1]] ? raw[ref[1]] : v;
3474
+ }
3475
+ const names = Object.keys(raw);
3476
+ const g = (re) => names.filter((n) => re.test(n));
3477
+ // Colours detected by VALUE, not name — so EVERY colour token a DS defines is
3478
+ // offered (the name-prefix list dropped many). A token is a colour if its
3479
+ // resolved value reads as one. (#3 — "see all tokens the DS has".)
3480
+ const isColor = (v) =>
3481
+ /^(#[0-9a-f]{3,8}|rgba?\(|hsla?\(|oklch\(|oklab\(|lab\(|lch\(|hwb\(|color\()/i.test(v) ||
3482
+ /^(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(
3483
+ v
3484
+ );
3485
+ return {
3486
+ color: names.filter((n) => isColor(vals[n])),
3487
+ space: g(/^--space-/),
3488
+ radius: g(/^--radius-/),
3489
+ type: g(/^--type-/),
3490
+ shadow: g(/^--shadow-/),
3491
+ lh: g(/^--lh-/),
3492
+ vals,
3493
+ };
3494
+ }
3495
+
3496
+ // Fetch + parse the tokens CSS of EVERY design system in the config (main
3497
+ // origin), so the token popover can offer tokens grouped per DS (W3 multi-DS
3498
+ // feedback). The active canvas's DS is ordered first. Returns
3499
+ // `[{ name, color, space, radius, type, shadow, lh, vals }]`.
3500
+ function useAllDsTokens(cfg, designRel, activeName) {
3501
+ const list = cfg?.designSystems || [];
3502
+ // A stable key so the effect only re-fetches when the DS set / paths change.
3503
+ const sig = list.map((d) => `${d.name}:${d.tokensCssRel}`).join('|');
3504
+ const [byDs, setByDs] = useState([]);
3505
+ useEffect(() => {
3506
+ if (!list.length) return undefined;
3507
+ let cancelled = false;
3508
+ Promise.all(
3509
+ list.map(async (ds) => {
3510
+ if (!ds.tokensCssRel) return null;
3511
+ try {
3512
+ const r = await fetch(`/${designRel}/${ds.tokensCssRel}`);
3513
+ const css = r.ok ? await r.text() : '';
3514
+ return { name: ds.name, ...parseTokensCss(css) };
3515
+ } catch {
3516
+ return null;
3517
+ }
3518
+ })
3519
+ ).then((res) => {
3520
+ if (cancelled) return;
3521
+ const got = res.filter(Boolean);
3522
+ // Active DS first, rest in config order.
3523
+ got.sort((a, b) => (a.name === activeName ? -1 : b.name === activeName ? 1 : 0));
3524
+ setByDs(got);
3525
+ });
3526
+ return () => {
3527
+ cancelled = true;
3528
+ };
3529
+ }, [sig, designRel, activeName]);
3530
+ return byDs;
3531
+ }
3532
+
3533
+ // Phase 12.3 (#4) — the "Custom" tab of the colour popover: a normal colour
3534
+ // input (native OS picker via a large swatch) + a hex/value text field. Applies
3535
+ // LIVE as you adjust (onApply), so the canvas previews while the picker is open.
3536
+ // #6 — the unified colour picker (Custom tab). A real HSV control: a
3537
+ // saturation/value square + a hue slider + a hex field + an eyedropper — the
3538
+ // Figma model. Replaces BOTH the old native <input type="color"> on the swatch
3539
+ // AND the simple hex field, so colours have ONE popover (Custom · Variables).
3540
+ // `seed` is the resolved current colour (hex). Drag updates the picker UI live;
3541
+ // commits on pointer-up (one source write per drag); the hex field commits on
3542
+ // blur/Enter.
3543
+ function ColorPicker({ seed, onApply }) {
3544
+ const [hsv, setHsv] = useState(() => rgbToHsv(hexToRgb(seed || '#000000')));
3545
+ const hsvRef = useRef(hsv);
3546
+ hsvRef.current = hsv;
3547
+ const svRef = useRef(null);
3548
+ const hueRef = useRef(null);
3549
+ // Reseed when the selection's colour changes (but not while the user drags).
3550
+ const seedRef = useRef(seed);
3551
+ // biome-ignore lint/correctness/useExhaustiveDependencies: reseed on seed change only.
3552
+ useEffect(() => {
3553
+ if (seed && seed !== seedRef.current) {
3554
+ seedRef.current = seed;
3555
+ setHsv(rgbToHsv(hexToRgb(seed)));
3556
+ }
3557
+ }, [seed]);
3558
+ const hex = rgbToHex(hsvToRgb(hsv));
3559
+
3560
+ const dragSV = (e) => {
3561
+ e.preventDefault();
3562
+ const r = svRef.current?.getBoundingClientRect();
3563
+ if (!r) return;
3564
+ const h = hsvRef.current.h;
3565
+ const move = (ev) => {
3566
+ setHsv({
3567
+ h,
3568
+ s: clamp01((ev.clientX - r.left) / r.width),
3569
+ v: clamp01(1 - (ev.clientY - r.top) / r.height),
3570
+ });
3571
+ };
3572
+ move(e);
3573
+ const up = () => {
3574
+ document.removeEventListener('pointermove', move);
3575
+ document.removeEventListener('pointerup', up);
3576
+ onApply(rgbToHex(hsvToRgb(hsvRef.current)));
3577
+ };
3578
+ document.addEventListener('pointermove', move);
3579
+ document.addEventListener('pointerup', up);
3580
+ };
3581
+ const dragHue = (e) => {
3582
+ e.preventDefault();
3583
+ const r = hueRef.current?.getBoundingClientRect();
3584
+ if (!r) return;
3585
+ const { s, v } = hsvRef.current;
3586
+ const move = (ev) => {
3587
+ setHsv({ h: clamp01((ev.clientX - r.left) / r.width) * 360, s, v });
3588
+ };
3589
+ move(e);
3590
+ const up = () => {
3591
+ document.removeEventListener('pointermove', move);
3592
+ document.removeEventListener('pointerup', up);
3593
+ onApply(rgbToHex(hsvToRgb(hsvRef.current)));
3594
+ };
3595
+ document.addEventListener('pointermove', move);
3596
+ document.addEventListener('pointerup', up);
3597
+ };
3598
+ const eyedrop = async () => {
3599
+ try {
3600
+ // EyeDropper is Chromium-only; guarded.
3601
+ const ED = window.EyeDropper;
3602
+ if (!ED) return;
3603
+ const res = await new ED().open();
3604
+ if (res?.sRGBHex) {
3605
+ setHsv(rgbToHsv(hexToRgb(res.sRGBHex)));
3606
+ onApply(res.sRGBHex);
3607
+ }
3608
+ } catch {
3609
+ /* user cancelled */
3610
+ }
3611
+ };
3612
+
3613
+ return (
3614
+ <div className="st-cp-cpick">
3615
+ <button
3616
+ type="button"
3617
+ ref={svRef}
3618
+ className="st-cp-cpick-sv"
3619
+ aria-label="saturation and value"
3620
+ style={{ background: `hsl(${hsv.h} 100% 50%)` }}
3621
+ onPointerDown={dragSV}
3622
+ >
3623
+ <span className="st-cp-cpick-svwhite" />
3624
+ <span className="st-cp-cpick-svblack" />
3625
+ <span
3626
+ className="st-cp-cpick-knob"
3627
+ style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%`, background: hex }}
3628
+ />
3629
+ </button>
3630
+ <div className="st-cp-cpick-controls">
3631
+ {window.EyeDropper ? (
3632
+ <button
3633
+ type="button"
3634
+ className="st-cp-cpick-eye"
3635
+ aria-label="pick from screen"
3636
+ title="eyedropper"
3637
+ onClick={eyedrop}
3638
+ >
3639
+ <StIcon name="eyedropper" size={14} />
3640
+ </button>
3641
+ ) : null}
3642
+ <button
3643
+ type="button"
3644
+ ref={hueRef}
3645
+ className="st-cp-cpick-hue"
3646
+ aria-label="hue"
3647
+ onPointerDown={dragHue}
3648
+ >
3649
+ <span className="st-cp-cpick-huethumb" style={{ left: `${(hsv.h / 360) * 100}%` }} />
3650
+ </button>
3651
+ </div>
3652
+ <input
3653
+ className="st-cp-fin"
3654
+ type="text"
3655
+ value={hex}
3656
+ aria-label="hex value"
3657
+ onChange={(e) => {
3658
+ const v = e.target.value;
3659
+ if (/^#?[0-9a-f]{6}$/i.test(v)) setHsv(rgbToHsv(hexToRgb(v)));
3660
+ }}
3661
+ onKeyDown={(e) => {
3662
+ if (e.key === 'Enter') onApply(e.currentTarget.value);
3663
+ }}
3664
+ onBlur={(e) => onApply(e.currentTarget.value)}
3665
+ />
3666
+ </div>
3667
+ );
3668
+ }
3669
+
3670
+ // Phase 12.3 (W2.1) — token picker as a Figma-style popover instead of a native
3671
+ // <select>. `kind='color'` renders a swatch grid (resolved DS color values);
3672
+ // `kind='value'` a variable list (pretty name + resolved value, à la Figma's
3673
+ // variable picker). Picking commits `var(--token)`. Portals to <body> +
3674
+ // fixed-positions from the trigger rect so the panel's overflow never clips it.
3675
+ function TokenPopover({ kind, groups, current, onPick, label, swatchBg, seedHex, activeDs }) {
3676
+ const [open, setOpen] = useState(false);
3677
+ const [pos, setPos] = useState(null);
3678
+ // Phase 12.3 (#4) — colour popover gets two tabs: a normal colour input
3679
+ // (Custom) + the DS variables swatch list (Variables). Token-able non-colour
3680
+ // popovers stay single-mode.
3681
+ const [mode, setMode] = useState('custom');
3682
+ const [query, setQuery] = useState('');
3683
+ const btnRef = useRef(null);
3684
+ const popRef = useRef(null);
3685
+ const bound = typeof current === 'string' && /var\(\s*--/.test(current);
3686
+ const isOn = (n) => current === `var(${n})`;
3687
+ const pretty = (n) => n.replace(/^--/, '').replace(/-/g, ' ');
3688
+ const gs = groups || [];
3689
+ const total = gs.reduce((s, g) => s + (g.names?.length || 0), 0);
3690
+ const showDsHeaders = gs.length > 1; // group by DS only when there's >1
3691
+ // Search filter over the token name + resolved value, per group.
3692
+ const q = query.trim().toLowerCase();
3693
+ const filteredGs = !q
3694
+ ? gs
3695
+ : gs
3696
+ .map((g) => ({
3697
+ ...g,
3698
+ names: (g.names || []).filter(
3699
+ (n) =>
3700
+ pretty(n).toLowerCase().includes(q) ||
3701
+ n.toLowerCase().includes(q) ||
3702
+ (g.vals?.[n] || '').toLowerCase().includes(q)
3703
+ ),
3704
+ }))
3705
+ .filter((g) => g.names.length);
3706
+
3707
+ useEffect(() => {
3708
+ if (!open) {
3709
+ setQuery('');
3710
+ return undefined;
3711
+ }
3712
+ const place = () => {
3713
+ const r = btnRef.current?.getBoundingClientRect();
3714
+ if (!r) return;
3715
+ const W = 224;
3716
+ const MAXH = 300;
3717
+ let left = Math.min(r.right - W, window.innerWidth - W - 8);
3718
+ if (left < 8) left = 8;
3719
+ const below = window.innerHeight - r.bottom;
3720
+ const top = below > MAXH + 8 ? r.bottom + 4 : Math.max(8, r.top - MAXH - 4);
3721
+ setPos({ left, top, width: W, maxHeight: MAXH });
3722
+ };
3723
+ place();
3724
+ const onDoc = (e) => {
3725
+ if (popRef.current?.contains(e.target) || btnRef.current?.contains(e.target)) return;
3726
+ setOpen(false);
3727
+ };
3728
+ const onKey = (e) => {
3729
+ if (e.key === 'Escape') setOpen(false);
3730
+ };
3731
+ const dismiss = () => setOpen(false);
3732
+ // The popover is fixed-positioned from the trigger rect, so a scroll of the
3733
+ // PANEL detaches it → dismiss. But scrolling INSIDE the popover (its own
3734
+ // overflow list) must NOT close it (the user couldn't scroll the variables).
3735
+ const onScroll = (e) => {
3736
+ if (popRef.current?.contains(e.target)) return;
3737
+ setOpen(false);
3738
+ };
3739
+ document.addEventListener('pointerdown', onDoc, true);
3740
+ document.addEventListener('keydown', onKey);
3741
+ window.addEventListener('resize', dismiss);
3742
+ document.addEventListener('scroll', onScroll, true);
3743
+ return () => {
3744
+ document.removeEventListener('pointerdown', onDoc, true);
3745
+ document.removeEventListener('keydown', onKey);
3746
+ window.removeEventListener('resize', dismiss);
3747
+ document.removeEventListener('scroll', onScroll, true);
3748
+ };
3749
+ }, [open]);
3750
+
3751
+ // Pick a token. #3 — apply CORRECTLY across design systems: a token from the
3752
+ // canvas's OWN active DS commits `var(--token)` (round-trips + resolves right);
3753
+ // a token from ANOTHER DS commits its RESOLVED value (literal), because
3754
+ // `var(--token)` would resolve against the canvas's DS scope and paint the WRONG
3755
+ // colour. So what you click is always what's applied ("natvrdo").
3756
+ // SECURITY (ethical-hacker A3) — a cross-DS token value is committed as a
3757
+ // LITERAL, and its source is a (possibly hub-pushed, untrusted) tokens CSS.
3758
+ // A colour-shaped value can still smuggle a fetch primitive (e.g.
3759
+ // `rgb(1 2 3) url(//x)` passes the colour sniff). Refuse to write a literal
3760
+ // carrying url()/image-set()/expression()/@import — fall back to var(), which
3761
+ // resolves against the CANVAS's own DS (never the attacker's value).
3762
+ const UNSAFE_TOKEN_VALUE = /url\(|image-set\(|cross-fade\(|element\(|expression\(|@import|javascript:/i;
3763
+ const pickFrom = (ds, n, resolved) => {
3764
+ if (activeDs && ds && ds !== activeDs && resolved && !UNSAFE_TOKEN_VALUE.test(resolved)) {
3765
+ onPick(resolved);
3766
+ } else {
3767
+ onPick(`var(${n})`);
3768
+ }
3769
+ setOpen(false);
3770
+ };
3771
+ // Custom colour / hex applies LIVE without closing, so the user can keep
3772
+ // tweaking; the popover dismisses on outside-click / Esc like everything else.
3773
+ const applyRaw = (v) => {
3774
+ const val = (v || '').trim();
3775
+ if (val) onPick(val);
3776
+ };
3777
+ // A search field over the token list (name + value). Auto-focuses so the user
3778
+ // can type straight away.
3779
+ const searchBar = (
3780
+ <div className="st-cp-pop-search">
3781
+ <StIcon name="search" size={12} />
3782
+ <input
3783
+ value={query}
3784
+ onChange={(e) => setQuery(e.target.value)}
3785
+ placeholder="Search variables"
3786
+ aria-label="search variables"
3787
+ // biome-ignore lint/a11y/noAutofocus: focusing the search on open is the intent.
3788
+ autoFocus
3789
+ />
3790
+ </div>
3791
+ );
3792
+ // #2 — colour Variables as a scannable LIST (swatch · name · value), per DS.
3793
+ const swatchList = (grps) =>
3794
+ grps.map((g) => (
3795
+ <div className="st-cp-pop-group" key={g.ds}>
3796
+ {showDsHeaders ? <div className="st-cp-pop-ds">{g.ds}</div> : null}
3797
+ <div className="st-cp-pop-list">
3798
+ {g.names.map((n) => (
3799
+ <button
3800
+ key={`${g.ds}:${n}`}
3801
+ type="button"
3802
+ className={`st-cp-pop-row st-cp-pop-crow${isOn(n) ? ' is-on' : ''}`}
3803
+ onClick={() => pickFrom(g.ds, n, g.vals?.[n])}
3804
+ >
3805
+ <span
3806
+ className="st-cp-pop-cswatch"
3807
+ style={{ background: g.vals?.[n] || 'transparent' }}
3808
+ aria-hidden="true"
3809
+ />
3810
+ <span className="st-cp-pop-name">{pretty(n)}</span>
3811
+ <span className="st-cp-pop-val">{g.vals?.[n] || ''}</span>
3812
+ </button>
3813
+ ))}
3814
+ </div>
3815
+ </div>
3816
+ ));
3817
+ // The value-token list (non-colour), per DS.
3818
+ const valueList = (grps) =>
3819
+ grps.map((g) => (
3820
+ <div className="st-cp-pop-group" key={g.ds}>
3821
+ {showDsHeaders ? <div className="st-cp-pop-ds">{g.ds}</div> : null}
3822
+ <div className="st-cp-pop-list">
3823
+ {g.names.map((n) => (
3824
+ <button
3825
+ key={`${g.ds}:${n}`}
3826
+ type="button"
3827
+ className={`st-cp-pop-row${isOn(n) ? ' is-on' : ''}`}
3828
+ onClick={() => pickFrom(g.ds, n, g.vals?.[n])}
3829
+ >
3830
+ <span className="st-cp-pop-name">{pretty(n)}</span>
3831
+ <span className="st-cp-pop-val">{g.vals?.[n] || ''}</span>
3832
+ </button>
3833
+ ))}
3834
+ </div>
3835
+ </div>
3836
+ ));
3837
+ const noMatch = <div className="st-cp-pop-empty">No match</div>;
3838
+
3839
+ return (
3840
+ <>
3841
+ {swatchBg !== undefined ? (
3842
+ // Colour rows: the swatch IS the trigger — one popover, no separate native
3843
+ // OS picker + ◇ (the "two popovers" the user flagged). Shows the current
3844
+ // colour; bound-to-token gets the accent ring.
3845
+ <button
3846
+ type="button"
3847
+ ref={btnRef}
3848
+ className={`st-cp-swatch st-cp-swatch--mini st-cp-swatch--trigger${bound ? ' is-bound' : ''}`}
3849
+ aria-haspopup="dialog"
3850
+ aria-expanded={open}
3851
+ aria-label={label || 'pick a colour'}
3852
+ title={current || 'pick a colour'}
3853
+ onClick={() => setOpen((v) => !v)}
3854
+ >
3855
+ <span style={{ position: 'absolute', inset: 0, background: swatchBg || 'transparent' }} />
3856
+ </button>
3857
+ ) : (
3858
+ <button
3859
+ type="button"
3860
+ ref={btnRef}
3861
+ className={`st-cp-tokbtn${bound ? ' is-bound' : ''}`}
3862
+ aria-haspopup="dialog"
3863
+ aria-expanded={open}
3864
+ aria-label={label || 'pick a design token'}
3865
+ title="design tokens"
3866
+ onClick={() => setOpen((v) => !v)}
3867
+ >
3868
+ <span className="st-cp-tokbtn-glyph" aria-hidden="true" />
3869
+ </button>
3870
+ )}
3871
+ {open && pos
3872
+ ? createPortal(
3873
+ <div
3874
+ ref={popRef}
3875
+ // Portalled to <body> (outside the App's `.maude` div), so it must
3876
+ // re-establish the maude token scope itself — otherwise var(--bg-*)
3877
+ // resolves to the legacy :root project palette (the cream popover bug).
3878
+ className="maude st-cp-pop"
3879
+ data-theme={
3880
+ (typeof document !== 'undefined' &&
3881
+ document.documentElement.getAttribute('data-theme')) ||
3882
+ 'dark'
3883
+ }
3884
+ role="dialog"
3885
+ aria-label={label || 'design tokens'}
3886
+ style={{
3887
+ left: pos.left,
3888
+ top: pos.top,
3889
+ width: pos.width,
3890
+ maxHeight: pos.maxHeight,
3891
+ }}
3892
+ >
3893
+ {kind === 'color' ? (
3894
+ <>
3895
+ <div className="st-cp-poptabs" role="tablist">
3896
+ <button
3897
+ type="button"
3898
+ role="tab"
3899
+ aria-selected={mode === 'custom'}
3900
+ className={`st-cp-poptab${mode === 'custom' ? ' is-active' : ''}`}
3901
+ onClick={() => setMode('custom')}
3902
+ >
3903
+ Custom
3904
+ </button>
3905
+ <button
3906
+ type="button"
3907
+ role="tab"
3908
+ aria-selected={mode === 'vars'}
3909
+ className={`st-cp-poptab${mode === 'vars' ? ' is-active' : ''}`}
3910
+ onClick={() => setMode('vars')}
3911
+ >
3912
+ Variables
3913
+ </button>
3914
+ </div>
3915
+ {mode === 'custom' ? (
3916
+ <ColorPicker seed={seedHex || cssColorToHex(current) || '#000000'} onApply={applyRaw} />
3917
+ ) : !total ? (
3918
+ <div className="st-cp-pop-empty">No color tokens</div>
3919
+ ) : (
3920
+ <>
3921
+ {searchBar}
3922
+ {filteredGs.length ? swatchList(filteredGs) : noMatch}
3923
+ </>
3924
+ )}
3925
+ </>
3926
+ ) : !total ? (
3927
+ <div className="st-cp-pop-empty">No tokens for this property</div>
3928
+ ) : (
3929
+ <>
3930
+ {searchBar}
3931
+ {filteredGs.length ? valueList(filteredGs) : noMatch}
3932
+ </>
3933
+ )}
3934
+ </div>,
3935
+ document.body
3936
+ )
3937
+ : null}
3938
+ </>
3939
+ );
3940
+ }
3941
+
3942
+ function CssKnobs({ el, cfg, onOptimistic }) {
3943
+ const editable = !!el.id;
3944
+ const computed = el.computed || {};
3945
+ // Phase 12.3 — optimistic local overlay over the selection's authored / custom
3946
+ // / attr maps. With the redundant-reload suppression (the flicker fix), an edit
3947
+ // no longer triggers a reselect that would re-post fresh `authored` values — so
3948
+ // the panel must reflect its own commits immediately or it shows the stale
3949
+ // pre-edit value until the user re-selects. Each commit/reset writes here;
3950
+ // `null` marks a removed key. Cleared when a different element is selected.
3951
+ const [overlay, setOverlay] = useState({ a: {}, c: {}, t: {} });
3952
+ // biome-ignore lint/correctness/useExhaustiveDependencies: clear only on element change.
3953
+ useEffect(() => {
3954
+ setOverlay({ a: {}, c: {}, t: {} });
3955
+ }, [el.id]);
3956
+ const mergeOverlay = (base, ov) => {
3957
+ const out = { ...(base || {}) };
3958
+ for (const [k, v] of Object.entries(ov)) {
3959
+ if (v === null) delete out[k];
3960
+ else out[k] = v;
3961
+ }
3962
+ return out;
3963
+ };
3964
+ const authored = mergeOverlay(el.authored, overlay.a);
3965
+ const customStyles = mergeOverlay(el.customStyles, overlay.c);
3966
+ const attrs = mergeOverlay(el.attrs, overlay.t);
3967
+ const setA = (prop, v) => setOverlay((o) => ({ ...o, a: { ...o.a, [prop]: v } }));
3968
+ const setC = (prop, v) => setOverlay((o) => ({ ...o, c: { ...o.c, [prop]: v } }));
3969
+ const setT = (attr, v) => setOverlay((o) => ({ ...o, t: { ...o.t, [attr]: v } }));
3970
+ // Token CSS is served from the MAIN origin at the repo-relative path, i.e.
3971
+ // WITH the designRoot prefix (`/.design/system/<ds>/colors_and_type.css`) —
3972
+ // `tokensCssRel` from config is DS-root-relative (no `.design/`), so prepend it.
3973
+ const _designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '');
3974
+ const _activeDs = activeDsNameFor(el.file, cfg);
3975
+ // W3 — tokens from EVERY configured DS, active one first, so the popover can
3976
+ // offer them grouped per design system.
3977
+ const allDs = useAllDsTokens(cfg, _designRel, _activeDs);
3978
+ // Build per-DS popover groups for one token family (color/space/radius/…).
3979
+ const tokenGroups = (familyKey) =>
3980
+ allDs
3981
+ .map((d) => ({ ds: d.name, names: d[familyKey] || [], vals: d.vals }))
3982
+ .filter((g) => g.names.length);
3983
+ const [status, setStatus] = useState({});
3984
+ const [open, setOpen] = useState({
3985
+ Layout: true,
3986
+ Typography: true,
3987
+ Spacing: true,
3988
+ Size: true,
3989
+ Appearance: true,
3990
+ Advanced: false,
3991
+ });
3992
+ const [split, setSplit] = useState(false);
3993
+
3994
+ // Phase 12.3 — auto-expand Advanced when the selected element carries custom
3995
+ // CSS props / HTML attrs, so a just-added (or pre-existing) custom value is
3996
+ // visible without hunting for the disclosure. Keyed on el.id so it re-runs per
3997
+ // selection (CssKnobs persists across selections — the el prop changes).
3998
+ const hasCustom =
3999
+ Object.keys(customStyles).length > 0 || Object.keys(attrs).length > 0;
4000
+ useEffect(() => {
4001
+ if (hasCustom) setOpen((o) => (o.Advanced ? o : { ...o, Advanced: true }));
4002
+ }, [el.id, hasCustom]);
4003
+
4004
+ async function post(url, payload, key) {
4005
+ setStatus((s) => ({ ...s, [key]: 'saving' }));
4006
+ try {
4007
+ const res = await fetch(url, {
4008
+ method: 'POST',
4009
+ headers: { 'content-type': 'application/json' },
4010
+ body: JSON.stringify(payload),
4011
+ });
4012
+ const j = await res.json().catch(() => ({}));
4013
+ setStatus((s) => ({
4014
+ ...s,
4015
+ [key]: !res.ok || !j.ok ? `err:${(j && j.error) || `HTTP ${res.status}`}` : 'saved',
4016
+ }));
4017
+ } catch (err) {
4018
+ setStatus((s) => ({ ...s, [key]: `err:${err && err.message ? err.message : String(err)}` }));
4019
+ }
4020
+ }
4021
+ // Optimistic preview: nudge the live element so the change shows before the
4022
+ // edit → HMR reload lands. `value` null = remove (reset path). No-op when the
4023
+ // selection has no stable id (can't be resolved in the canvas).
4024
+ const optimistic = (prop, value) => {
4025
+ if (!onOptimistic || !el.id) return;
4026
+ onOptimistic({
4027
+ id: el.id,
4028
+ artboardId: el.artboardId ?? null,
4029
+ index: el.index ?? 0,
4030
+ prop,
4031
+ value,
4032
+ });
4033
+ };
4034
+ const commit = (property, raw) => {
4035
+ const value = (raw || '').trim();
4036
+ if (!editable || !value) return;
4037
+ if (value === (authored[property] ?? '').trim()) return; // no-op
4038
+ optimistic(property, value);
4039
+ setA(property, value); // reflect in the panel immediately (no reload → no reselect)
4040
+ post('/_api/edit-css', { canvas: el.file, id: el.id, property, value }, property);
4041
+ };
4042
+ // A custom CSS property (Advanced) — same write, but the panel surfaces it from
4043
+ // the customStyles map, so overlay THERE.
4044
+ const commitCustom = (property, raw) => {
4045
+ const value = (raw || '').trim();
4046
+ if (!editable || !property.trim() || !value) return;
4047
+ optimistic(property.trim(), value);
4048
+ setC(property.trim(), value);
4049
+ post('/_api/edit-css', { canvas: el.file, id: el.id, property: property.trim(), value }, property.trim());
4050
+ };
4051
+ const commitAttr = (attr, raw) => {
4052
+ const a = (attr || '').trim();
4053
+ const value = (raw || '').trim();
4054
+ if (!editable || !a || !value) return;
4055
+ setT(a, value);
4056
+ post('/_api/edit-attr', { canvas: el.file, id: el.id, attr: a, value }, `@${a}`);
4057
+ };
4058
+ // Phase 12.3 — reset (remove the inline prop / attr → back to class/inherited).
4059
+ const reset = (property) => {
4060
+ if (!editable) return;
4061
+ optimistic(property, null);
4062
+ setA(property, null);
4063
+ post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
4064
+ };
4065
+ const resetCustom = (property) => {
4066
+ if (!editable) return;
4067
+ optimistic(property, null);
4068
+ setC(property, null);
4069
+ post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
4070
+ };
4071
+ const resetAttr = (attr) => {
4072
+ if (!editable) return;
4073
+ setT(attr, null);
4074
+ post('/_api/edit-attr', { canvas: el.file, id: el.id, attr, reset: true }, `@${attr}`);
4075
+ };
4076
+ // Phase 12.3 (W2.2) — Figma/Webflow scrub: drag a number field horizontally to
4077
+ // change its value. Live preview via optimistic apply on every move (no source
4078
+ // write); commits ONCE on release. A pointer that doesn't pass a 3px threshold
4079
+ // is a normal click (focus to type). `opts.step` modifiers: shift = ×10, alt =
4080
+ // ×0.1. `opts.sides` enables Webflow box-model modifiers: alt = symmetric pair,
4081
+ // alt+shift = all four (else just this side). `opts.min` clamps (default 0).
4082
+ const makeScrub = (prop, opts = {}) => (e) => {
4083
+ if (e.button !== 0) return;
4084
+ const input = e.currentTarget;
4085
+ const startX = e.clientX;
4086
+ const baseN =
4087
+ Number.parseFloat(
4088
+ cssSplitUnit(authored[prop] ?? cssHint(computed[prop]) ?? '0').n || '0'
4089
+ ) || 0;
4090
+ const unit = opts.unitless
4091
+ ? ''
4092
+ : opts.unit || cssSplitUnit(authored[prop] ?? '').unit || 'px';
4093
+ const min = opts.min ?? 0;
4094
+ const fmt = (n) => (opts.unitless ? `${n}` : `${n}${unit}`);
4095
+ const sidesFor = (ev) => {
4096
+ if (!opts.sides) return [prop];
4097
+ if (ev.altKey && ev.shiftKey) return opts.sides.all;
4098
+ if (ev.altKey) return opts.sides.pair;
4099
+ return [prop];
4100
+ };
4101
+ let scrubbing = false;
4102
+ let last = baseN;
4103
+ const move = (ev) => {
4104
+ const dx = ev.clientX - startX;
4105
+ if (!scrubbing && Math.abs(dx) < 3) return;
4106
+ if (!scrubbing) {
4107
+ scrubbing = true;
4108
+ document.body.classList.add('st-scrubbing');
4109
+ }
4110
+ ev.preventDefault();
4111
+ const granular = opts.sides ? 1 : ev.shiftKey ? 10 : ev.altKey ? 0.1 : 1;
4112
+ last = Math.round((baseN + dx * granular) * 100) / 100;
4113
+ if (last < min) last = min;
4114
+ const sides = sidesFor(ev);
4115
+ // Live-update the dragged field AND, for a box-model multi-side scrub, the
4116
+ // sibling box inputs so the whole pair / four-up move shows in the panel —
4117
+ // not just the one being dragged (W2.2 feedback).
4118
+ if (input) input.value = String(last);
4119
+ if (opts.sides && sides.length > 1) {
4120
+ const box = input?.closest('.st-cp-box');
4121
+ for (const p of sides) {
4122
+ if (p === prop) continue;
4123
+ const sib = box?.querySelector(`.st-cp-boxv[aria-label="${p}"]`);
4124
+ if (sib) sib.value = String(last);
4125
+ }
4126
+ }
4127
+ for (const p of sides) optimistic(p, fmt(last));
4128
+ };
4129
+ const up = (ev) => {
4130
+ document.removeEventListener('pointermove', move);
4131
+ document.removeEventListener('pointerup', up);
4132
+ if (!scrubbing) return;
4133
+ document.body.classList.remove('st-scrubbing');
4134
+ for (const p of sidesFor(ev)) commit(p, fmt(last));
4135
+ };
4136
+ document.addEventListener('pointermove', move);
4137
+ document.addEventListener('pointerup', up);
4138
+ };
4139
+ const provOf = (prop) => {
4140
+ const v = authored[prop];
4141
+ if (!v) return 'inherit';
4142
+ return /var\(\s*--/.test(v) ? 'bound' : 'raw';
4143
+ };
4144
+
4145
+ if (!editable) {
4146
+ return (
4147
+ <div className="st-cp">
4148
+ <div className="st-cp-id">
4149
+ <span className="st-cp-idtag">{el.tag || 'element'}</span>
4150
+ </div>
4151
+ <div className="st-css-disabled">
4152
+ This selection has no stable element id (a legacy canvas, or a non-element target). Edit
4153
+ it with <code>/design:edit</code>.
4154
+ </div>
4155
+ </div>
4156
+ );
4157
+ }
4158
+
4159
+ const PROVLABEL = { bound: 'token-bound', raw: 'raw override', inherit: 'inherited' };
4160
+ const prov = (p) => (
4161
+ <span className={`st-cp-prov st-cp-prov--${p}`} role="img" aria-label={PROVLABEL[p]} />
4162
+ );
4163
+
4164
+ // Phase 12.3 (#4) — the LEADING dot carries it all: provenance (shape) + save
4165
+ // status (a success/error/saving glow) + reset (double-click an authored row).
4166
+ // No trailing ✓/⟲ that shift the input rightward (the user's gripe). A tooltip
4167
+ // hints the double-click-to-reset.
4168
+ const provDot = (prop, provKind) => {
4169
+ const k = provKind ?? provOf(prop);
4170
+ const s = status[prop];
4171
+ const errMsg = typeof s === 'string' && s.startsWith('err:') ? s.slice(4) : '';
4172
+ const stCls = errMsg ? ' is-err' : s === 'saved' ? ' is-saved' : s === 'saving' ? ' is-saving' : '';
4173
+ const canReset = !!authored[prop];
4174
+ const tip = errMsg
4175
+ ? `error: ${errMsg}`
4176
+ : canReset
4177
+ ? `${PROVLABEL[k]} · double-click to reset`
4178
+ : PROVLABEL[k];
4179
+ return (
4180
+ <button
4181
+ type="button"
4182
+ className={`st-cp-prov st-cp-prov--${k}${stCls}${canReset ? ' is-resettable' : ''}`}
4183
+ aria-label={tip}
4184
+ title={tip}
4185
+ tabIndex={canReset ? 0 : -1}
4186
+ onDoubleClick={canReset ? () => reset(prop) : undefined}
4187
+ onKeyDown={
4188
+ canReset
4189
+ ? (e) => {
4190
+ if (e.key === 'Backspace' || e.key === 'Delete') {
4191
+ e.preventDefault();
4192
+ reset(prop);
4193
+ }
4194
+ }
4195
+ : undefined
4196
+ }
4197
+ />
4198
+ );
4199
+ };
4200
+
4201
+ const row = (prop, control, provKind) => {
4202
+ // #1 bigger-bet — scannable diff: a fully-unset single-prop row is dimmed so
4203
+ // the handful of overridden rows pop (Webflow/Framer model). Composite rows
4204
+ // (border — they pass an explicit provKind) are never dimmed.
4205
+ const unset = provKind === undefined && !authored[prop];
4206
+ return (
4207
+ <div className={`st-cp-row${unset ? ' is-unset' : ''}`} key={prop}>
4208
+ {provDot(prop, provKind)}
4209
+ <label className="st-cp-label" title={prop}>
4210
+ {prop}
4211
+ </label>
4212
+ <div className="st-cp-ctl">{control}</div>
4213
+ </div>
4214
+ );
4215
+ };
4216
+
4217
+ // Props each section owns — drives the per-section "reset section" affordance.
4218
+ const SECTION_PROPS = {
4219
+ Layout: ['display', 'flex-direction', 'align-items', 'justify-content', 'gap'],
4220
+ Typography: [
4221
+ 'font-family',
4222
+ 'color',
4223
+ 'font-size',
4224
+ 'font-weight',
4225
+ 'line-height',
4226
+ 'letter-spacing',
4227
+ 'text-align',
4228
+ ],
4229
+ Spacing: [
4230
+ 'margin-top',
4231
+ 'margin-right',
4232
+ 'margin-bottom',
4233
+ 'margin-left',
4234
+ 'padding-top',
4235
+ 'padding-right',
4236
+ 'padding-bottom',
4237
+ 'padding-left',
4238
+ ],
4239
+ Size: ['width', 'height', 'max-width'],
4240
+ Appearance: [
4241
+ 'background-color',
4242
+ 'border-radius',
4243
+ 'border-top-left-radius',
4244
+ 'border-top-right-radius',
4245
+ 'border-bottom-left-radius',
4246
+ 'border-bottom-right-radius',
4247
+ 'border-width',
4248
+ 'border-style',
4249
+ 'border-color',
4250
+ 'box-shadow',
4251
+ 'opacity',
4252
+ ],
4253
+ };
4254
+ const resetSection = (name) => {
4255
+ (SECTION_PROPS[name] || []).forEach((p) => {
4256
+ if (authored[p]) reset(p);
4257
+ });
4258
+ };
4259
+
4260
+ const sec = (name, body) => {
4261
+ const dirty = (SECTION_PROPS[name] || []).some((p) => authored[p]);
4262
+ return (
4263
+ <section className="st-cp-sec" key={name}>
4264
+ <div className="st-cp-sechd-row">
4265
+ <button
4266
+ type="button"
4267
+ className="st-cp-sechd"
4268
+ aria-expanded={!!open[name]}
4269
+ onClick={() => setOpen((o) => ({ ...o, [name]: !o[name] }))}
4270
+ >
4271
+ <span className="st-cp-caret" aria-hidden="true">
4272
+ {open[name] ? '▾' : '▸'}
4273
+ </span>
4274
+ {name}
4275
+ </button>
4276
+ {dirty ? (
4277
+ <button
4278
+ type="button"
4279
+ className="st-cp-secreset"
4280
+ aria-label={`reset ${name} section to original`}
4281
+ title={`reset ${name}`}
4282
+ onClick={() => resetSection(name)}
4283
+ >
4284
+
4285
+ </button>
4286
+ ) : null}
4287
+ </div>
4288
+ {open[name] ? body : null}
4289
+ </section>
4290
+ );
4291
+ };
4292
+
4293
+ // native <select> committing a CSS value directly
4294
+ const csel = (prop, list) => (
4295
+ <select
4296
+ className="st-cp-nsel"
4297
+ aria-label={prop}
4298
+ value={list.includes(authored[prop]) ? authored[prop] : ''}
4299
+ onChange={(e) => commit(prop, e.target.value)}
4300
+ >
4301
+ <option value="" disabled>
4302
+ {cssHint(computed[prop]) || '—'}
4303
+ </option>
4304
+ {list.map((v) => (
4305
+ <option key={v} value={v}>
4306
+ {v}
4307
+ </option>
4308
+ ))}
4309
+ </select>
4310
+ );
4311
+
4312
+ // token quick-pick — Figma-style POPOVER (W2.1) listing the DS variables for
4313
+ // this property (name + resolved value), grouped per design system (W3);
4314
+ // picking writes var(--token). `familyKey` selects the token family.
4315
+ const tok = (prop, familyKey) => {
4316
+ const groups = tokenGroups(familyKey);
4317
+ return groups.length ? (
4318
+ <TokenPopover
4319
+ kind="value"
4320
+ groups={groups}
4321
+ current={authored[prop]}
4322
+ activeDs={_activeDs}
4323
+ onPick={(v) => commit(prop, v)}
4324
+ label={`${prop} design token`}
4325
+ />
4326
+ ) : null;
4327
+ };
4328
+
4329
+ // free text input — raw value or var(--token), commits on blur/Enter
4330
+ const text = (prop) => (
4331
+ <input
4332
+ className="st-cp-fin"
4333
+ key={`${prop}:${authored[prop] ?? ''}`}
4334
+ aria-label={prop}
4335
+ defaultValue={authored[prop] ?? ''}
4336
+ placeholder={cssHint(computed[prop]) || '—'}
4337
+ onKeyDown={(e) => {
4338
+ if (e.key === 'Enter') e.currentTarget.blur();
4339
+ }}
4340
+ onBlur={(e) => commit(prop, e.currentTarget.value)}
4341
+ />
4342
+ );
4343
+
4344
+ // number + steppers + unit-select (+ optional token quick-pick after)
4345
+ const num = (prop, tokenList, opts = {}) => {
4346
+ const cur = cssSplitUnit(authored[prop] ?? '');
4347
+ // Unitless CSS properties — a bare number must commit WITHOUT a unit suffix
4348
+ // (line-height: 1.5px ≠ 1.5 — knob-smoke finding, 2026-06-12).
4349
+ const unitless = CSS_UNITLESS.has(prop);
4350
+ const unit = unitless ? '' : cur.unit && cur.unit !== 'auto' ? cur.unit : 'px';
4351
+ const bump = (d) => {
4352
+ const base = Number.parseFloat(cur.n || cssHint(computed[prop]) || '0') || 0;
4353
+ commit(prop, `${Math.round((base + d) * 100) / 100}${unit}`);
4354
+ };
4355
+ const lead = PROP_LEAD[prop];
4356
+ return (
4357
+ <>
4358
+ <div className="st-cp-num">
4359
+ {lead ? (
4360
+ <span className="st-cp-numlead" aria-hidden="true">
4361
+ {lead.t ? lead.t : <StIcon name={lead.icon} size={12} />}
4362
+ </span>
4363
+ ) : null}
4364
+ <input
4365
+ className="st-cp-numin st-cp-scrub"
4366
+ key={`${prop}:${authored[prop] ?? ''}`}
4367
+ aria-label={prop}
4368
+ defaultValue={cur.unit && cur.unit !== '' ? cur.n : (authored[prop] ?? '')}
4369
+ placeholder={cssHint(computed[prop]) || '—'}
4370
+ onPointerDown={makeScrub(prop, { unitless, unit, min: opts.min })}
4371
+ onKeyDown={(e) => {
4372
+ if (e.key === 'Enter') e.currentTarget.blur();
4373
+ }}
4374
+ onBlur={(e) => {
4375
+ const raw = e.currentTarget.value.trim();
4376
+ if (!raw) return;
4377
+ commit(prop, /[a-z%(]/i.test(raw) ? raw : `${raw}${unit}`);
4378
+ }}
4379
+ />
4380
+ <span className="st-cp-step">
4381
+ <button
4382
+ type="button"
4383
+ className="st-cp-stepb"
4384
+ tabIndex={-1}
4385
+ aria-label={`increase ${prop}`}
4386
+ onClick={() => bump(1)}
4387
+ >
4388
+
4389
+ </button>
4390
+ <button
4391
+ type="button"
4392
+ className="st-cp-stepb"
4393
+ tabIndex={-1}
4394
+ aria-label={`decrease ${prop}`}
4395
+ onClick={() => bump(-1)}
4396
+ >
4397
+
4398
+ </button>
4399
+ </span>
4400
+ {unitless ? null : (
4401
+ <select
4402
+ className="st-cp-unitsel"
4403
+ aria-label={`${prop} unit`}
4404
+ value={cur.unit || 'px'}
4405
+ onChange={(e) =>
4406
+ commit(prop, e.target.value === 'auto' ? 'auto' : `${cur.n || '0'}${e.target.value}`)
4407
+ }
4408
+ >
4409
+ {CSS_UNITS.map((u) => (
4410
+ <option key={u} value={u}>
4411
+ {u}
4412
+ </option>
4413
+ ))}
4414
+ </select>
4415
+ )}
4416
+ </div>
4417
+ {tok(prop, tokenList)}
4418
+ </>
4419
+ );
4420
+ };
4421
+
4422
+ // color swatch (native picker → hex) + raw text + token quick-pick
4423
+ const color = (prop) => {
4424
+ // ONE colour control: the swatch is the trigger for a single popover with a
4425
+ // full HSV picker (Custom) + the DS swatches (Variables). No separate native
4426
+ // OS picker (#6 — was two popovers doing the same thing).
4427
+ const resolved = computed[prop] || authored[prop] || '';
4428
+ return (
4429
+ <>
4430
+ <TokenPopover
4431
+ kind="color"
4432
+ groups={tokenGroups('color')}
4433
+ current={authored[prop]}
4434
+ activeDs={_activeDs}
4435
+ swatchBg={resolved}
4436
+ seedHex={cssColorToHex(computed[prop] || authored[prop]) || '#000000'}
4437
+ onPick={(v) => commit(prop, v)}
4438
+ label={`${prop} colour`}
4439
+ />
4440
+ {text(prop)}
4441
+ </>
4442
+ );
4443
+ };
4444
+
4445
+ // a box-model side input (margin/padding longhand). Phase 12.3 — Webflow-style:
4446
+ // always shows the RESOLVED value (0 instead of blank) and a faint `is-zero`
4447
+ // styling for an unset/zero side. Edits the single side (the old "link all
4448
+ // sides" toggle was removed — DDR-104 Phase 12.3 W1.5).
4449
+ const side = (prop, group) => {
4450
+ const a = authored[prop];
4451
+ const shown =
4452
+ a != null && a !== ''
4453
+ ? cssSplitUnit(a).n || a
4454
+ : cssSplitUnit(cssHint(computed[prop]) ?? '').n || '0';
4455
+ const isZero = !a || a === '0' || a === '0px' || a === 'auto';
4456
+ // Webflow scrub modifiers — alt = symmetric pair (block for top/bottom,
4457
+ // inline for left/right), alt+shift = all four.
4458
+ const edge = prop.split('-').pop();
4459
+ const pair =
4460
+ edge === 'top' || edge === 'bottom'
4461
+ ? [`${group}-top`, `${group}-bottom`]
4462
+ : [`${group}-left`, `${group}-right`];
4463
+ const all = [`${group}-top`, `${group}-right`, `${group}-bottom`, `${group}-left`];
4464
+ return (
4465
+ <input
4466
+ className={`st-cp-boxv st-cp-scrub st-cp-boxv--${group[0]}${prop.split('-').pop()[0]}${
4467
+ isZero ? ' is-zero' : ''
4468
+ }`}
4469
+ key={`${prop}:${a ?? ''}`}
4470
+ aria-label={prop}
4471
+ defaultValue={shown}
4472
+ title="drag to scrub · alt = symmetric · alt+shift = all sides"
4473
+ onPointerDown={makeScrub(prop, { sides: { pair, all } })}
4474
+ onKeyDown={(e) => {
4475
+ if (e.key === 'Enter') e.currentTarget.blur();
4476
+ }}
4477
+ onBlur={(e) => {
4478
+ const raw = e.currentTarget.value.trim();
4479
+ if (!raw) return;
4480
+ const val = /[a-z%]/i.test(raw) ? raw : `${raw}px`;
4481
+ commit(prop, val);
4482
+ }}
4483
+ />
4484
+ );
4485
+ };
4486
+
4487
+ const corner = (label, prop) => (
4488
+ <label className="st-cp-cornerf">
4489
+ <span>{label}</span>
4490
+ <input
4491
+ key={`${prop}:${authored[prop] ?? ''}`}
4492
+ aria-label={prop}
4493
+ defaultValue={cssSplitUnit(authored[prop] ?? '').n || ''}
4494
+ placeholder={cssHint(computed[prop]) || '0'}
4495
+ onKeyDown={(e) => {
4496
+ if (e.key === 'Enter') e.currentTarget.blur();
4497
+ }}
4498
+ onBlur={(e) => {
4499
+ const raw = e.currentTarget.value.trim();
4500
+ if (raw) commit(prop, /[a-z%]/i.test(raw) ? raw : `${raw}px`);
4501
+ }}
4502
+ />
4503
+ </label>
4504
+ );
4505
+
4506
+ // Phase 12.3 — authored inline props with no curated row + custom HTML attrs,
4507
+ // surfaced in Advanced so the user can see/edit/remove what they added.
4508
+ const customStyleRows = Object.entries(customStyles);
4509
+ const attrRows = Object.entries(attrs);
4510
+
4511
+ return (
4512
+ <div className="st-cp" key={el.id} data-tour="css-panel">
4513
+ <div className="st-cp-id">
4514
+ <span className="st-cp-idtag">
4515
+ {el.tag || 'element'}
4516
+ {el.classes ? <span className="st-cp-idcls">.{el.classes.split(/\s+/)[0]}</span> : null}
4517
+ </span>
4518
+ <span className="st-cp-idmeta">inline style</span>
4519
+ </div>
4520
+
4521
+ {sec(
4522
+ 'Layout',
4523
+ <>
4524
+ {row('display', csel('display', CSS_DISPLAYS))}
4525
+ {row('flex-direction', csel('flex-direction', CSS_FLEX_DIR))}
4526
+ {row('align-items', csel('align-items', CSS_ALIGN))}
4527
+ {row('justify-content', csel('justify-content', CSS_JUSTIFY))}
4528
+ {row('gap', num('gap', 'space'))}
4529
+ </>
4530
+ )}
4531
+
4532
+ {sec(
4533
+ 'Typography',
4534
+ <>
4535
+ {row('font-family', csel('font-family', CSS_FONTS))}
4536
+ {row('color', color('color'))}
4537
+ {row('font-size', num('font-size', 'type'))}
4538
+ {row('font-weight', csel('font-weight', CSS_WEIGHTS))}
4539
+ {row('line-height', num('line-height', 'lh'))}
4540
+ {row('letter-spacing', num('letter-spacing', null, { min: -Infinity }))}
4541
+ {row(
4542
+ 'text-align',
4543
+ <div className="st-cp-seg" role="group" aria-label="text-align">
4544
+ {CSS_ALIGN_OPTS.map((a) => (
4545
+ <button
4546
+ key={a}
4547
+ type="button"
4548
+ className={`st-cp-segbtn${(authored['text-align'] || computed['text-align']) === a ? ' is-active' : ''}`}
4549
+ aria-label={`align ${a}`}
4550
+ aria-pressed={(authored['text-align'] || computed['text-align']) === a}
4551
+ onClick={() => commit('text-align', a)}
4552
+ >
4553
+ <span className={`st-cp-bars st-cp-bars--${a === 'justify' ? 'just' : a}`} aria-hidden="true">
4554
+ <i />
4555
+ <i />
4556
+ <i />
4557
+ </span>
4558
+ </button>
4559
+ ))}
4560
+ </div>
4561
+ )}
4562
+ </>
4563
+ )}
4564
+
4565
+ {sec(
4566
+ 'Spacing',
4567
+ <>
4568
+ <div className="st-cp-box" aria-label="margin and padding">
4569
+ <span className="st-cp-boxtag st-cp-boxtag--m">
4570
+ {prov(provOf('margin-top'))}margin
4571
+ </span>
4572
+ {side('margin-top', 'margin')}
4573
+ {side('margin-right', 'margin')}
4574
+ {side('margin-bottom', 'margin')}
4575
+ {side('margin-left', 'margin')}
4576
+ <div className="st-cp-boxpad">
4577
+ <span className="st-cp-boxtag st-cp-boxtag--p">
4578
+ {prov(provOf('padding-top'))}padding
4579
+ </span>
4580
+ {side('padding-top', 'padding')}
4581
+ {side('padding-right', 'padding')}
4582
+ {side('padding-bottom', 'padding')}
4583
+ {side('padding-left', 'padding')}
4584
+ <div className="st-cp-boxcore">
4585
+ {Math.round(el.bounds?.w || 0)} × {Math.round(el.bounds?.h || 0)}
4586
+ </div>
4587
+ </div>
4588
+ </div>
4589
+ </>
4590
+ )}
4591
+
4592
+ {sec(
4593
+ 'Size',
4594
+ <>
4595
+ {row('width', num('width'))}
4596
+ {row('height', num('height'))}
4597
+ {row('max-width', num('max-width'))}
4598
+ </>
4599
+ )}
4600
+
4601
+ {sec(
4602
+ 'Appearance',
4603
+ <>
4604
+ {row('background-color', color('background-color'))}
4605
+ <div className="st-cp-row">
4606
+ {prov(provOf('border-radius'))}
4607
+ <label className="st-cp-label" title="border-radius">
4608
+ border-radius
4609
+ </label>
4610
+ <div className="st-cp-ctl">
4611
+ {num('border-radius', 'radius')}
4612
+ <button
4613
+ type="button"
4614
+ className={`st-cp-split${split ? ' is-on' : ''}`}
4615
+ aria-pressed={split}
4616
+ aria-label="set each corner separately"
4617
+ title="set each corner separately"
4618
+ onClick={() => setSplit((v) => !v)}
4619
+ />
4620
+ </div>
4621
+ </div>
4622
+ {split ? (
4623
+ <div className="st-cp-corners" aria-label="per-corner radius">
4624
+ {corner('TL', 'border-top-left-radius')}
4625
+ {corner('TR', 'border-top-right-radius')}
4626
+ {corner('BL', 'border-bottom-left-radius')}
4627
+ {corner('BR', 'border-bottom-right-radius')}
4628
+ </div>
4629
+ ) : null}
4630
+ {row(
4631
+ 'border',
4632
+ <div className="st-cp-border">
4633
+ {num('border-width')}
4634
+ <select
4635
+ className="st-cp-nsel st-cp-nsel--mini"
4636
+ aria-label="border-style"
4637
+ value={CSS_BORDER_STYLES.includes(authored['border-style']) ? authored['border-style'] : ''}
4638
+ onChange={(e) => commit('border-style', e.target.value)}
4639
+ >
4640
+ <option value="" disabled>
4641
+ style
4642
+ </option>
4643
+ {CSS_BORDER_STYLES.map((s) => (
4644
+ <option key={s} value={s}>
4645
+ {s}
4646
+ </option>
4647
+ ))}
4648
+ </select>
4649
+ <TokenPopover
4650
+ kind="color"
4651
+ groups={tokenGroups('color')}
4652
+ current={authored['border-color']}
4653
+ activeDs={_activeDs}
4654
+ swatchBg={computed['border-color'] || authored['border-color'] || ''}
4655
+ seedHex={
4656
+ cssColorToHex(computed['border-color'] || authored['border-color']) || '#000000'
4657
+ }
4658
+ onPick={(v) => commit('border-color', v)}
4659
+ label="border colour"
4660
+ />
4661
+ </div>,
4662
+ provOf('border-width')
4663
+ )}
4664
+ {row('box-shadow', tok('box-shadow', 'shadow') || text('box-shadow'))}
4665
+ {row(
4666
+ 'opacity',
4667
+ <div className="st-cp-num">
4668
+ <span className="st-cp-numlead" aria-hidden="true">
4669
+ <StIcon name="p-opacity" size={12} />
4670
+ </span>
4671
+ <input
4672
+ className="st-cp-numin"
4673
+ key={`opacity:${authored.opacity ?? ''}`}
4674
+ aria-label="opacity"
4675
+ defaultValue={authored.opacity ?? ''}
4676
+ placeholder={cssHint(computed.opacity) || '1'}
4677
+ onKeyDown={(e) => {
4678
+ if (e.key === 'Enter') e.currentTarget.blur();
4679
+ }}
4680
+ onBlur={(e) => commit('opacity', e.currentTarget.value)}
4681
+ />
4682
+ </div>
4683
+ )}
4684
+ </>
4685
+ )}
4686
+
4687
+ {/* #5 — the idle/saved status now lives in each row's leading dot (a glow),
4688
+ so the panel no longer carries a confusing standing 'written to source'
4689
+ line. Only a hard ERROR surfaces here, with the failing property. */}
4690
+ {(() => {
4691
+ const err = Object.entries(status).find(
4692
+ ([, s]) => typeof s === 'string' && s.startsWith('err:')
4693
+ );
4694
+ return err ? (
4695
+ <div className="st-cp-save is-err" role="status">
4696
+ <StIcon name="x" size={12} />
4697
+ {err[0]}: {err[1].slice(4)}
4698
+ </div>
4699
+ ) : null;
4700
+ })()}
4701
+
4702
+ {sec(
4703
+ 'Advanced',
4704
+ <div className="st-cp-advbody">
4705
+ {customStyleRows.length ? (
4706
+ <>
4707
+ <div className="st-cp-advgrp">Custom CSS properties</div>
4708
+ {customStyleRows.map(([p, v]) => (
4709
+ <div className="st-cp-kv" key={`cs:${p}`}>
4710
+ <input
4711
+ className="st-cp-fin st-cp-fin--ro"
4712
+ readOnly
4713
+ value={p}
4714
+ aria-label={`custom property ${p} name`}
4715
+ />
4716
+ <input
4717
+ className="st-cp-fin"
4718
+ key={`cs:${p}:${v}`}
4719
+ defaultValue={v}
4720
+ aria-label={`${p} value`}
4721
+ onKeyDown={(e) => {
4722
+ if (e.key === 'Enter') e.currentTarget.blur();
4723
+ }}
4724
+ onBlur={(e) => commitCustom(p, e.currentTarget.value)}
4725
+ />
4726
+ <button
4727
+ type="button"
4728
+ className="st-cp-kvx"
4729
+ aria-label={`remove ${p}`}
4730
+ title="remove"
4731
+ onClick={() => resetCustom(p)}
4732
+ >
4733
+ <StIcon name="x" size={11} />
4734
+ </button>
4735
+ </div>
4736
+ ))}
4737
+ </>
4738
+ ) : null}
4739
+ <div className="st-cp-advgrp">Add CSS property</div>
4740
+ <RawKnob commit={commitCustom} />
4741
+ <div className="st-cp-note">applied as-is — not token-bound</div>
4742
+ {attrRows.length ? (
4743
+ <>
4744
+ <div className="st-cp-advgrp">Custom HTML attributes</div>
4745
+ {attrRows.map(([a, v]) => (
4746
+ <div className="st-cp-kv" key={`at:${a}`}>
4747
+ <input
4748
+ className="st-cp-fin st-cp-fin--ro"
4749
+ readOnly
4750
+ value={a}
4751
+ aria-label={`attribute ${a} name`}
4752
+ />
4753
+ <input
4754
+ className="st-cp-fin"
4755
+ key={`at:${a}:${v}`}
4756
+ defaultValue={v}
4757
+ aria-label={`${a} value`}
4758
+ onKeyDown={(e) => {
4759
+ if (e.key === 'Enter') e.currentTarget.blur();
4760
+ }}
4761
+ onBlur={(e) => commitAttr(a, e.currentTarget.value)}
4762
+ />
4763
+ <button
4764
+ type="button"
4765
+ className="st-cp-kvx"
4766
+ aria-label={`remove ${a}`}
4767
+ title="remove"
4768
+ onClick={() => resetAttr(a)}
4769
+ >
4770
+ <StIcon name="x" size={11} />
4771
+ </button>
4772
+ </div>
4773
+ ))}
4774
+ </>
4775
+ ) : null}
4776
+ <div className="st-cp-advgrp">Add HTML attribute</div>
4777
+ <AttrKnob commit={commitAttr} />
4778
+ </div>
4779
+ )}
4780
+
4781
+ <div className="st-cp-legend">
4782
+ <span>
4783
+ <i className="st-cp-prov st-cp-prov--bound" aria-hidden="true" />
4784
+ token
4785
+ </span>
4786
+ <span>
4787
+ <i className="st-cp-prov st-cp-prov--raw" aria-hidden="true" />
4788
+ override
4789
+ </span>
4790
+ <span>
4791
+ <i className="st-cp-prov st-cp-prov--inherit" aria-hidden="true" />
4792
+ inherited
4793
+ </span>
4794
+ </div>
4795
+ </div>
4796
+ );
4797
+ }
4798
+
4799
+ // Custom CSS property hatch — writes an arbitrary `property: value` to inline style.
4800
+ function RawKnob({ commit }) {
4801
+ const [prop, setProp] = useState('');
4802
+ const [val, setVal] = useState('');
4803
+ const submit = () => {
4804
+ if (prop.trim() && val.trim()) {
4805
+ commit(prop.trim(), val);
4806
+ setProp('');
4807
+ setVal('');
4808
+ }
4809
+ };
4810
+ return (
4811
+ <div className="st-cp-kv">
4812
+ <input
4813
+ className="st-cp-fin"
4814
+ aria-label="custom property name"
4815
+ placeholder="property"
4816
+ value={prop}
4817
+ onChange={(e) => setProp(e.target.value)}
4818
+ />
4819
+ <input
4820
+ className="st-cp-fin"
4821
+ aria-label="custom property value"
4822
+ placeholder="value"
4823
+ value={val}
4824
+ onChange={(e) => setVal(e.target.value)}
4825
+ onKeyDown={(e) => {
4826
+ if (e.key === 'Enter') submit();
4827
+ }}
4828
+ onBlur={submit}
4829
+ />
4830
+ </div>
4831
+ );
4832
+ }
4833
+
4834
+ // Custom HTML attribute hatch — writes a plain JSX attribute (data-*, aria-*, …).
4835
+ function AttrKnob({ commit }) {
4836
+ const [attr, setAttr] = useState('');
4837
+ const [val, setVal] = useState('');
4838
+ const submit = () => {
4839
+ if (attr.trim() && val.trim()) {
4840
+ commit(attr.trim(), val);
4841
+ setAttr('');
4842
+ setVal('');
4843
+ }
4844
+ };
4845
+ return (
4846
+ <div className="st-cp-kv">
4847
+ <input
4848
+ className="st-cp-fin"
4849
+ aria-label="custom attribute name"
4850
+ placeholder="data-…"
4851
+ value={attr}
4852
+ onChange={(e) => setAttr(e.target.value)}
4853
+ />
4854
+ <input
4855
+ className="st-cp-fin"
4856
+ aria-label="custom attribute value"
4857
+ placeholder="value"
4858
+ value={val}
4859
+ onChange={(e) => setVal(e.target.value)}
4860
+ onKeyDown={(e) => {
4861
+ if (e.key === 'Enter') submit();
4862
+ }}
4863
+ onBlur={submit}
4864
+ />
4865
+ </div>
4866
+ );
4867
+ }
4868
+
4869
+ // ---------- Inspector panel (display-only) ----------
4870
+ //
4871
+ // T6 (Plan C) — right-dock Inspect / Layers / CSS tabs per `.design/ui/Studio.tsx`
4872
+ // InspectorPanel. DISPLAY-ONLY: reads the live `selected` payload from the
4873
+ // inspector bridge (`bounds`, `tag`, `classes`, `dom_path`, `html`). The
4874
+ // mockup's live-CSS-knob WRITEBACK is Phase 12 (needs a canvas-origin write
4875
+ // bridge, DDR-054) — the CSS tab shows markup read-only + keeps that callout, so
4876
+ // it never implies functionality it lacks (the exact reason DDR-096 deferred it).
4877
+ // ---------- Layers tree row (Phase 12 Task 4) ----------
4878
+ // Phase 12.3 (W3.1) — map a LayerNode `type` (classified in canvas-shell) to a
4879
+ // type-distinct icon, matching the Studio.tsx layers design.
4880
+ const LAYER_TYPE_ICON = {
4881
+ button: 'button',
4882
+ heading: 'type',
4883
+ text: 'type',
4884
+ input: 'input',
4885
+ form: 'input',
4886
+ image: 'image',
4887
+ link: 'link',
4888
+ list: 'list',
4889
+ nav: 'layers',
4890
+ box: 'box',
4891
+ };
4892
+
4893
+ function LayerRow({
4894
+ node,
4895
+ depth,
4896
+ selectedId,
4897
+ collapsed,
4898
+ hidden,
4899
+ onToggle,
4900
+ onSelect,
4901
+ onHover,
4902
+ onToggleVisibility,
4903
+ }) {
4904
+ const key = `${node.id}:${node.index}`;
4905
+ const hasKids = node.children && node.children.length > 0;
4906
+ const isCollapsed = collapsed.has(key);
4907
+ const isSel = node.id === selectedId;
4908
+ const isHidden = hidden?.has(key);
4909
+ return (
4910
+ <>
4911
+ <div
4912
+ className={
4913
+ 'st-layer st-layer--row' + (isSel ? ' is-sel' : '') + (isHidden ? ' is-hidden' : '')
4914
+ }
4915
+ style={{ paddingLeft: 6 + depth * 14 }}
4916
+ role="treeitem"
4917
+ aria-selected={isSel}
4918
+ aria-expanded={hasKids ? !isCollapsed : undefined}
4919
+ tabIndex={0}
4920
+ title={`${node.tag} · ${node.type}`}
4921
+ onClick={() => onSelect(node)}
4922
+ onMouseEnter={() => onHover(node)}
4923
+ onMouseLeave={() => onHover(null)}
4924
+ onKeyDown={(e) => {
4925
+ if (e.key === 'Enter' || e.key === ' ') {
4926
+ e.preventDefault();
4927
+ onSelect(node);
4928
+ }
4929
+ }}
4930
+ >
4931
+ {hasKids ? (
4932
+ <button
4933
+ type="button"
4934
+ className="st-layer-caret"
4935
+ aria-label={isCollapsed ? 'Expand' : 'Collapse'}
4936
+ onClick={(e) => {
4937
+ e.stopPropagation();
4938
+ onToggle(key);
4939
+ }}
4940
+ >
4941
+ {isCollapsed ? '▸' : '▾'}
4942
+ </button>
4943
+ ) : (
4944
+ <span className="st-layer-caret" aria-hidden="true" />
4945
+ )}
4946
+ <StIcon name={LAYER_TYPE_ICON[node.type] || 'box'} size={12} className="st-layer-ticon" />
4947
+ <span className="st-layer-label">{node.label}</span>
4948
+ <span className="st-layer-type">{node.type}</span>
4949
+ {onToggleVisibility ? (
4950
+ <button
4951
+ type="button"
4952
+ className="st-layer-eye"
4953
+ aria-label={isHidden ? `Show ${node.label}` : `Hide ${node.label}`}
4954
+ aria-pressed={isHidden}
4955
+ title={isHidden ? 'Show' : 'Hide'}
4956
+ onClick={(e) => {
4957
+ e.stopPropagation();
4958
+ onToggleVisibility(node);
4959
+ }}
4960
+ >
4961
+ <StIcon name={isHidden ? 'eye-off' : 'eye'} size={13} />
4962
+ </button>
4963
+ ) : null}
4964
+ </div>
4965
+ {hasKids && !isCollapsed
4966
+ ? node.children.map((c) => (
4967
+ <LayerRow
4968
+ key={`${c.id}:${c.index}`}
4969
+ node={c}
4970
+ depth={depth + 1}
4971
+ selectedId={selectedId}
4972
+ collapsed={collapsed}
4973
+ hidden={hidden}
4974
+ onToggle={onToggle}
4975
+ onSelect={onSelect}
4976
+ onHover={onHover}
4977
+ onToggleVisibility={onToggleVisibility}
4978
+ />
4979
+ ))
4980
+ : null}
4981
+ </>
4982
+ );
4983
+ }
4984
+
4985
+ // Phase 12.3 — live computed readout for the Inspect tab (replaces the stale
4986
+ // "lands with the live CSS bridge (Phase 12)" callout — that bridge shipped).
4987
+ // Reads the resolved values the selection already carries (dom-selection
4988
+ // styleMapsFor → el.computed). Read-only; the CSS tab is where you edit.
4989
+ function InspectComputed({ el }) {
4990
+ const c = el?.computed || {};
4991
+ const a = el?.authored || {};
4992
+ // Prefer the authored token name (var(--accent) → "--accent") as the label;
4993
+ // fall back to the resolved value. The swatch always shows the RESOLVED color.
4994
+ const valueLabel = (prop) => {
4995
+ const av = a[prop];
4996
+ if (av && /var\(\s*--/.test(av)) return av.replace(/^var\(\s*|\s*\)$/g, '');
4997
+ return c[prop] || av || '';
4998
+ };
4999
+ const colorRow = (lbl, prop) => {
5000
+ const resolved = c[prop] || a[prop];
5001
+ if (!resolved) return null;
5002
+ return (
5003
+ <div className="st-insp-row" key={lbl}>
5004
+ <span className="st-insp-label">{lbl}</span>
5005
+ <div className="st-swatch-row">
5006
+ <span className="st-insp-swatch" style={{ background: resolved }} aria-hidden="true" />
5007
+ <span className="st-mono" style={{ fontSize: 11, color: 'var(--fg-1)' }}>
5008
+ {valueLabel(prop)}
5009
+ </span>
5010
+ </div>
5011
+ </div>
5012
+ );
5013
+ };
5014
+ const hasRadius = c['border-radius'] && c['border-radius'] !== '0px';
5015
+ const radiusN = hasRadius ? cssSplitUnit(c['border-radius']).n || c['border-radius'] : null;
5016
+ const font =
5017
+ c['font-size'] || c['font-weight']
5018
+ ? [c['font-size'], c['font-weight']].filter(Boolean).join(' / ')
5019
+ : null;
5020
+ const anyType = c['background-color'] || c.color || hasRadius || font;
5021
+ if (!anyType) return null;
5022
+ return (
5023
+ <>
5024
+ {hasRadius ? (
5025
+ <div className="st-insp-row">
5026
+ <span className="st-insp-label">Radius</span>
5027
+ <div className="st-insp-fields">
5028
+ <span className="st-fmini" style={{ flex: '0 0 auto', maxWidth: 84 }}>
5029
+ <span className="st-mtag">r</span>
5030
+ <input value={radiusN} readOnly aria-label="border radius" />
5031
+ </span>
5032
+ <span className="st-insp-unit">px</span>
5033
+ </div>
5034
+ </div>
5035
+ ) : null}
5036
+ {colorRow('Fill', 'background-color')}
5037
+ {colorRow('Text', 'color')}
5038
+ {font ? (
5039
+ <div className="st-insp-row">
5040
+ <span className="st-insp-label">Font</span>
5041
+ <div className="st-insp-fields">
5042
+ <span className="st-mono" style={{ fontSize: 11, color: 'var(--fg-0)' }}>{font}</span>
5043
+ </div>
5044
+ </div>
5045
+ ) : null}
5046
+ </>
5047
+ );
5048
+ }
5049
+
5050
+ function InspectorPanel({
5051
+ selected,
5052
+ onClose,
5053
+ layersTree,
5054
+ onSelectLayer,
5055
+ onHoverLayer,
5056
+ cfg,
5057
+ onOptimistic,
5058
+ tab: tabProp,
5059
+ onTabChange,
5060
+ width,
5061
+ resizing,
5062
+ }) {
5063
+ // Tab is controllable from the parent (the guided tour drives it to 'css' /
5064
+ // 'layers' so a spotlight step lands on a real row) but falls back to local
5065
+ // state for normal use. A user click both updates local state and notifies the
5066
+ // parent, so the two stay in lockstep whichever owns it.
5067
+ const [tabState, setTabState] = useState('inspect');
5068
+ const tab = tabProp ?? tabState;
5069
+ const setTab = (t) => {
5070
+ setTabState(t);
5071
+ onTabChange?.(t);
5072
+ };
5073
+ const [collapsed, setCollapsed] = useState(() => new Set());
5074
+ // Phase 12.3 (W3.1) — per-layer visibility toggle. Live-only (display:none via
5075
+ // the optimistic apply bus); not persisted to source. Keyed by `${id}:${index}`.
5076
+ const [hiddenLayers, setHiddenLayers] = useState(() => new Set());
5077
+ const toggleCollapse = (key) =>
5078
+ setCollapsed((prev) => {
5079
+ const next = new Set(prev);
5080
+ if (next.has(key)) next.delete(key);
5081
+ else next.add(key);
5082
+ return next;
5083
+ });
5084
+ const toggleVisibility = (node) => {
5085
+ const key = `${node.id}:${node.index}`;
5086
+ const willHide = !hiddenLayers.has(key);
5087
+ onOptimistic?.({
5088
+ id: node.id,
5089
+ artboardId: layersTree?.artboardId ?? null,
5090
+ index: node.index,
5091
+ prop: 'display',
5092
+ value: willHide ? 'none' : null,
5093
+ });
5094
+ setHiddenLayers((prev) => {
5095
+ const next = new Set(prev);
5096
+ if (willHide) next.add(key);
5097
+ else next.delete(key);
5098
+ return next;
5099
+ });
5100
+ };
2805
5101
  // `selected` may be a single element, an array (multi-select), or null.
2806
5102
  const el = Array.isArray(selected) ? selected[0] : selected;
2807
5103
  const tabBtn = (id, label, icon) => (
@@ -2816,8 +5112,13 @@ function InspectorPanel({ selected, onClose }) {
2816
5112
  );
2817
5113
  const b = el?.bounds || null;
2818
5114
  return (
2819
- <aside className="st-rpanel" aria-label="Inspector">
2820
- <div className="st-rp-tabs">
5115
+ <aside
5116
+ className={'st-rpanel' + (resizing ? ' is-resizing' : '')}
5117
+ style={width ? { width, flexBasis: width } : undefined}
5118
+ aria-label="Inspector"
5119
+ data-tour="inspector"
5120
+ >
5121
+ <div className="st-rp-tabs" data-tour="inspector-tabs">
2821
5122
  {tabBtn('inspect', 'Inspect', 'sliders')}
2822
5123
  {tabBtn('layers', 'Layers', 'layers')}
2823
5124
  {tabBtn('css', 'CSS', 'code')}
@@ -2834,7 +5135,11 @@ function InspectorPanel({ selected, onClose }) {
2834
5135
  <div className="st-rp-body">
2835
5136
  {!el ? (
2836
5137
  <div className="st-rp-empty">
2837
- Hold <Kbd>⌘</Kbd> inside the canvas and click an element to inspect it.
5138
+ {/* <p> wrapper st-rp-empty is a flex column, bare text nodes +
5139
+ kbd would stack as stretched flex items. */}
5140
+ <p>
5141
+ Hold <Kbd>⌘</Kbd> inside the canvas and click an element to inspect it.
5142
+ </p>
2838
5143
  </div>
2839
5144
  ) : tab === 'inspect' ? (
2840
5145
  <>
@@ -2842,26 +5147,26 @@ function InspectorPanel({ selected, onClose }) {
2842
5147
  <div className="st-insp-row">
2843
5148
  <span className="st-insp-label">Pos</span>
2844
5149
  <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 />
5150
+ <span className="st-fmini">
5151
+ <span className="st-mtag">X</span>
5152
+ <input value={b ? Math.round(b.x) : '—'} readOnly aria-label="x position" />
2848
5153
  </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 />
5154
+ <span className="st-fmini">
5155
+ <span className="st-mtag">Y</span>
5156
+ <input value={b ? Math.round(b.y) : '—'} readOnly aria-label="y position" />
2852
5157
  </span>
2853
5158
  </div>
2854
5159
  </div>
2855
5160
  <div className="st-insp-row">
2856
5161
  <span className="st-insp-label">Size</span>
2857
5162
  <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 />
5163
+ <span className="st-fmini">
5164
+ <span className="st-mtag">W</span>
5165
+ <input value={b ? Math.round(b.w) : '—'} readOnly aria-label="width" />
2861
5166
  </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 />
5167
+ <span className="st-fmini">
5168
+ <span className="st-mtag">H</span>
5169
+ <input value={b ? Math.round(b.h) : '—'} readOnly aria-label="height" />
2865
5170
  </span>
2866
5171
  </div>
2867
5172
  </div>
@@ -2883,14 +5188,29 @@ function InspectorPanel({ selected, onClose }) {
2883
5188
  </div>
2884
5189
  </div>
2885
5190
  ) : 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>
5191
+ <InspectComputed el={el} />
2889
5192
  </>
2890
5193
  ) : tab === 'layers' ? (
2891
5194
  <>
2892
- <div className="st-rp-hd">Layers · ancestry</div>
2893
- {Array.isArray(el.dom_path) && el.dom_path.length ? (
5195
+ <div className="st-rp-hd">Layers{layersTree?.nodes?.length ? '' : ' · ancestry'}</div>
5196
+ {layersTree?.nodes?.length ? (
5197
+ <div role="tree" aria-label="Artboard layers">
5198
+ {layersTree.nodes.map((n) => (
5199
+ <LayerRow
5200
+ key={`${n.id}:${n.index}`}
5201
+ node={n}
5202
+ depth={0}
5203
+ selectedId={el.id}
5204
+ collapsed={collapsed}
5205
+ hidden={hiddenLayers}
5206
+ onToggle={toggleCollapse}
5207
+ onSelect={(node) => onSelectLayer?.(node)}
5208
+ onHover={(node) => onHoverLayer?.(node)}
5209
+ onToggleVisibility={toggleVisibility}
5210
+ />
5211
+ ))}
5212
+ </div>
5213
+ ) : Array.isArray(el.dom_path) && el.dom_path.length ? (
2894
5214
  el.dom_path.map((node, i) => (
2895
5215
  <div
2896
5216
  key={i}
@@ -2902,25 +5222,13 @@ function InspectorPanel({ selected, onClose }) {
2902
5222
  </div>
2903
5223
  ))
2904
5224
  ) : (
2905
- <div className="st-rp-empty">No ancestry path for this selection.</div>
5225
+ <div className="st-rp-empty">
5226
+ Select an element (⌘-click in the canvas) to see its layer tree.
5227
+ </div>
2906
5228
  )}
2907
5229
  </>
2908
5230
  ) : (
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
- </>
5231
+ <CssKnobs el={el} cfg={cfg} onOptimistic={onOptimistic} />
2924
5232
  )}
2925
5233
  </div>
2926
5234
  </aside>
@@ -2935,6 +5243,15 @@ function App() {
2935
5243
  const [tabs, setTabs] = useState([]);
2936
5244
  const [activePath, setActivePath] = useState(null);
2937
5245
  const [selected, setSelected] = useState(null);
5246
+ // Phase 12.3 — latest selection, readable from the (stale-closure) onMessage
5247
+ // handler so an HMR reload (triggered by a CSS/attr edit) can re-select the
5248
+ // same element and restore the in-canvas halo the remount dropped.
5249
+ const selectedRef = useRef(null);
5250
+ useEffect(() => {
5251
+ selectedRef.current = selected;
5252
+ }, [selected]);
5253
+ // Phase 12 Task 4 — Layers tree for the active artboard (posted by canvas-shell).
5254
+ const [layersTree, setLayersTree] = useState(null);
2938
5255
  const [wsConnected, setWsConnected] = useState(false);
2939
5256
  // Phase 8 Task 7 — git lifecycle reload prompt. Server has already flushed
2940
5257
  // every dirty Y.Doc to disk by the time this state populates, so accepting
@@ -2945,6 +5262,49 @@ function App() {
2945
5262
  const [syncStatus, setSyncStatus] = useState(null);
2946
5263
  const [search, setSearch] = useState('');
2947
5264
  const [systemData, setSystemData] = useState(null);
5265
+ // Canvas-compile skeleton (single-canvas model → one path at a time).
5266
+ const [loadingPath, setLoadingPath] = useState(null);
5267
+ const loadFallbackTimer = useRef(null);
5268
+ // Resizable side panels (DS components-resize-panels) + the active drag side.
5269
+ const sbSize = usePanelSize('maude-sb-w', { min: 200, max: 420, def: 252 });
5270
+ const rpSize = usePanelSize('maude-rp-w', { min: 260, max: 480, def: 304 });
5271
+ const [dragSide, setDragSide] = useState(null); // 'sb' | 'rp' | null
5272
+ const bodyRef = useRef(null);
5273
+
5274
+ // Pointer drag for the panel grips — window listeners while dragging (the
5275
+ // grip also pointer-captures, and `.st-body.is-resizing iframe` drops pointer
5276
+ // events so the canvas iframe can't swallow the move stream mid-drag).
5277
+ useEffect(() => {
5278
+ if (!dragSide) return;
5279
+ const onMove = (e) => {
5280
+ const rect = bodyRef.current?.getBoundingClientRect();
5281
+ if (!rect) return;
5282
+ if (dragSide === 'sb') sbSize.setW(e.clientX - rect.left);
5283
+ else rpSize.setW(rect.right - e.clientX);
5284
+ };
5285
+ const onUp = () => setDragSide(null);
5286
+ window.addEventListener('pointermove', onMove);
5287
+ window.addEventListener('pointerup', onUp);
5288
+ return () => {
5289
+ window.removeEventListener('pointermove', onMove);
5290
+ window.removeEventListener('pointerup', onUp);
5291
+ };
5292
+ }, [dragSide, sbSize.setW, rpSize.setW]);
5293
+
5294
+ // Loading-skeleton lifecycle: dgn:'loaded' clears it instantly (TSX canvases);
5295
+ // the iframe load event arms a short fallback for legacy .html canvases that
5296
+ // never post it; a hard cap guards against a canvas that dies mid-compile.
5297
+ const onIframeLoad = useCallback((path) => {
5298
+ clearTimeout(loadFallbackTimer.current);
5299
+ loadFallbackTimer.current = setTimeout(() => {
5300
+ setLoadingPath((p) => (p === path ? null : p));
5301
+ }, 2500);
5302
+ }, []);
5303
+ useEffect(() => {
5304
+ if (!loadingPath) return;
5305
+ const cap = setTimeout(() => setLoadingPath(null), 15000);
5306
+ return () => clearTimeout(cap);
5307
+ }, [loadingPath]);
2948
5308
  // Loaded once at boot from /_config — informs canvasUrl() so TSX iframes
2949
5309
  // can pass the right ?designRel + ?tokens query to the canvas mount shell.
2950
5310
  const [cfg, setCfg] = useState({ designRel: '.design' });
@@ -3008,17 +5368,43 @@ function App() {
3008
5368
  const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
3009
5369
  const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
3010
5370
  const [helpOpen, setHelpOpen] = useState(false);
5371
+ // ? cheat-sheet (DS components-shortcuts-overlay) — separate from the deep
5372
+ // Help modal (F1), which keeps commands & flows.
5373
+ const [shortcutsOpen, setShortcutsOpen] = useState(false);
3011
5374
  const [paletteOpen, setPaletteOpen] = useState(false);
3012
5375
  // T5/T6 (Plan C) — shell-level export/handoff dialog + inspector panel state.
3013
5376
  // The palette (T4) drives them; the dialog (T5) + panel (T6) consume them.
3014
5377
  const [exportDialog, setExportDialog] = useState(null); // null | { mode: 'export'|'handoff', scope? }
3015
5378
  const [inspectorOpen, setInspectorOpen] = useState(false);
5379
+ // Inspector tab is lifted so View ▸ Layers can open the panel ON the Layers
5380
+ // tab (the menu item sat disabled as "Phase 12" long after the tab shipped).
5381
+ const [inspectorTab, setInspectorTab] = useState('inspect');
3016
5382
  const whatsNew = useWhatsNew(MDCC_VERSION);
3017
5383
  const [tourSteps, setTourSteps] = useState(null);
3018
5384
  const [usageNudge, setUsageNudge] = useState(() => !readBoolStore(USAGE_TOUR_STORE, false));
3019
5385
  const startTour = useCallback((steps) => {
3020
5386
  setTourSteps(Array.isArray(steps) && steps.length ? steps : null);
3021
5387
  }, []);
5388
+ // Guided-tour bus — the overlay calls setup() before each step to put the shell
5389
+ // into the state the step spotlights: open a canvas, open the Inspector, switch
5390
+ // its tab. The canvas iframe is cross-origin (DDR-054) so the tour can't select
5391
+ // an element for the user; requireSelection steps instead wait for a real
5392
+ // ⌘-click. Plain object (the overlay refs it), so per-render churn is harmless.
5393
+ const tourBus = {
5394
+ setup: (step) => {
5395
+ if (!step) return;
5396
+ if ((step.canvas || step.requireSelection) && tabs.length === 0) {
5397
+ setSidebarOpen(true);
5398
+ setTimeout(() => {
5399
+ try {
5400
+ document.querySelector('.st-sidebar [role="treeitem"]')?.click();
5401
+ } catch {}
5402
+ }, 80);
5403
+ }
5404
+ if (step.inspector || step.tab || step.requireSelection) setInspectorOpen(true);
5405
+ if (step.tab) setInspectorTab(step.tab);
5406
+ },
5407
+ };
3022
5408
  const markUsageSeen = useCallback(() => {
3023
5409
  setUsageNudge(false);
3024
5410
  try {
@@ -3245,9 +5631,9 @@ function App() {
3245
5631
  try {
3246
5632
  const m = JSON.parse(e.data);
3247
5633
  if (m.type === 'snapshot' && m.state) {
3248
- setSelected(m.state.selected);
5634
+ setSelected((prev) => mergeSelClientFields(m.state.selected, prev));
3249
5635
  } else if (m.type === 'selected') {
3250
- setSelected(m.selected);
5636
+ setSelected((prev) => mergeSelClientFields(m.selected, prev));
3251
5637
  } else if (m.type === 'comments' && typeof m.file === 'string') {
3252
5638
  setCommentsByFile((prev) => ({ ...prev, [m.file]: m.comments || [] }));
3253
5639
  } else if (m.type === 'ai-activity' && typeof m.file === 'string') {
@@ -3311,6 +5697,9 @@ function App() {
3311
5697
  });
3312
5698
  setActivePath(path);
3313
5699
  setFocusedCommentId(null);
5700
+ // Canvas-compile skeleton — cleared by the iframe's dgn:'loaded' message,
5701
+ // the onLoad fallback timer (legacy .html), or a hard 15s cap.
5702
+ if (path !== SYSTEM_TAB) setLoadingPath(path);
3314
5703
  }, []);
3315
5704
 
3316
5705
  const openSystem = useCallback(
@@ -3349,6 +5738,7 @@ function App() {
3349
5738
  return next;
3350
5739
  });
3351
5740
  iframesRef.current.delete(path);
5741
+ setLoadingPath((p) => (p === path ? null : p));
3352
5742
  },
3353
5743
  [activePath]
3354
5744
  );
@@ -3507,6 +5897,27 @@ function App() {
3507
5897
  } else if (m.dgn === 'clear-select') {
3508
5898
  wsSend({ type: 'clear-select' });
3509
5899
  setSelected(null);
5900
+ } else if (m.dgn === 'edit-text' && m.id) {
5901
+ // Phase 12 (DDR-103) — inline text edit committed in the canvas. POST to
5902
+ // the main-origin-only /_api/edit-text → editText writes the escaped
5903
+ // JSXText to source; the file-watcher HMR reload then shows the new text.
5904
+ // A refusal (mixed/expression content) is logged, not fatal.
5905
+ fetch('/_api/edit-text', {
5906
+ method: 'POST',
5907
+ headers: { 'content-type': 'application/json' },
5908
+ body: JSON.stringify({ canvas: m.file, id: m.id, text: m.text ?? '' }),
5909
+ })
5910
+ .then((r) => r.json().catch(() => ({})))
5911
+ .then((j) => {
5912
+ if (!j.ok) console.warn('[edit-text]', j.error || 'failed');
5913
+ })
5914
+ .catch(() => {});
5915
+ } else if (m.dgn === 'layers-tree') {
5916
+ // Phase 12 Task 4 — browsable layers tree for the active artboard.
5917
+ setLayersTree({ artboardId: m.artboardId, nodes: Array.isArray(m.tree) ? m.tree : [] });
5918
+ } else if (m.dgn === 'open-inspector') {
5919
+ // Phase 12 — context-menu "Inspect" / tool-palette Inspect opens the right panel.
5920
+ setInspectorOpen(true);
3510
5921
  } else if (m.dgn === 'comment-compose' && m.selection) {
3511
5922
  // Phase 6 — the iframe overlay owns the composer surface now. The
3512
5923
  // shell just mirrors `selected` so the StatusBar / sidebar still
@@ -3555,6 +5966,14 @@ function App() {
3555
5966
  // inspector forwards the chord here since the iframe's keydown never
3556
5967
  // reaches the shell's window listener. Mirror that handler's toggle.
3557
5968
  setPaletteOpen((v) => !v);
5969
+ } else if (m.dgn === 'shell-shortcut') {
5970
+ // Same forwarding lane for the other shell chords (inspect.ts) — so
5971
+ // ⌘R / ⌘⇧I / ⌘⇧M / ⌘⇧E / ⌘⇧H behave identically wherever focus is.
5972
+ if (m.id === 'reload') reloadActive();
5973
+ else if (m.id === 'inspector') setInspectorOpen((v) => !v);
5974
+ else if (m.id === 'comments') setCommentsPanelOpen((v) => !v);
5975
+ else if (m.id === 'export') setExportDialog({ mode: 'export' });
5976
+ else if (m.id === 'handoff') setExportDialog({ mode: 'handoff' });
3558
5977
  } else if (m.dgn === 'open-export') {
3559
5978
  // Plan C — the in-canvas toolbar / context menu route here so they open
3560
5979
  // the SAME shell Export dialog as the menubar (one look, all settings).
@@ -3564,7 +5983,9 @@ function App() {
3564
5983
  scope: m.detail && typeof m.detail.scope === 'string' ? m.detail.scope : undefined,
3565
5984
  });
3566
5985
  } else if (m.dgn === 'loaded' && m.file) {
3567
- // iframe finished loading — push current comments + carry over focused pin if any
5986
+ // iframe finished loading — drop the compile skeleton, push current
5987
+ // comments + carry over focused pin if any
5988
+ setLoadingPath((p) => (p === m.file ? null : p));
3568
5989
  const list = commentsByFile[m.file] || [];
3569
5990
  const el = [...iframesRef.current.entries()].find(([k]) => k === m.file)?.[1];
3570
5991
  if (el && el.contentWindow) {
@@ -3582,6 +6003,26 @@ function App() {
3582
6003
  el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*');
3583
6004
  } catch {}
3584
6005
  }
6006
+ // Phase 12.3 (W1.1) — an edit-css/edit-attr commit triggers the file
6007
+ // watcher's HMR reload, which remounts the canvas and drops the
6008
+ // in-canvas selection halo. Re-select the same element by its stable
6009
+ // data-cd-id so the user keeps focus on what they're editing. The
6010
+ // canvas-shell `select-by-id` handler re-emits select-set, which keeps
6011
+ // the Inspector panel + halo in sync. Guarded to the active file.
6012
+ const sel = selectedRef.current;
6013
+ if (sel && sel.id && sel.file === m.file) {
6014
+ try {
6015
+ el.contentWindow.postMessage(
6016
+ {
6017
+ dgn: 'select-by-id',
6018
+ id: sel.id,
6019
+ artboardId: sel.artboardId ?? null,
6020
+ index: sel.index ?? 0,
6021
+ },
6022
+ '*'
6023
+ );
6024
+ } catch {}
6025
+ }
3585
6026
  }
3586
6027
  } else if (m.dgn === 'export-request' && m.id && m.payload) {
3587
6028
  // The export dialog renders inside the canvas iframe (canvas origin),
@@ -3651,7 +6092,7 @@ function App() {
3651
6092
  }
3652
6093
  window.addEventListener('message', onMessage);
3653
6094
  return () => window.removeEventListener('message', onMessage);
3654
- }, [commentsByFile, focusedCommentId, cfg, theme]);
6095
+ }, [commentsByFile, focusedCommentId, cfg, theme, reloadActive]);
3655
6096
 
3656
6097
  // Tell the active canvas iframe to drop any persistent selection (canvas
3657
6098
  // SelectionSet) — used when the comment composer closes via submit /
@@ -3667,6 +6108,22 @@ function App() {
3667
6108
  }
3668
6109
  }, [activePath]);
3669
6110
 
6111
+ // Phase 12.3 (W1.1) — optimistic inline-style preview. The CSS panel calls this
6112
+ // on commit so the selected element updates instantly in the canvas before the
6113
+ // edit-css → HMR reload lands. `value` null = the reset path (remove the prop).
6114
+ const applyOptimisticStyle = useCallback(
6115
+ (payload) => {
6116
+ if (!activePath || activePath === SYSTEM_TAB) return;
6117
+ const el = iframesRef.current.get(activePath);
6118
+ if (el && el.contentWindow) {
6119
+ try {
6120
+ el.contentWindow.postMessage({ dgn: 'apply-style', ...payload }, '*');
6121
+ } catch {}
6122
+ }
6123
+ },
6124
+ [activePath]
6125
+ );
6126
+
3670
6127
  const resolveComment = useCallback((id) => {
3671
6128
  wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
3672
6129
  }, []);
@@ -3737,6 +6194,25 @@ function App() {
3737
6194
  setCommentsPanelOpen((v) => !v);
3738
6195
  return;
3739
6196
  }
6197
+ // Cmd+Shift+I — toggle Inspector. Was bare "I", which collided with the
6198
+ // canvas highlighter tool (same letter, different action by focus).
6199
+ if (meta && e.shiftKey && (e.key === 'i' || e.key === 'I')) {
6200
+ e.preventDefault();
6201
+ setInspectorOpen((v) => !v);
6202
+ return;
6203
+ }
6204
+ // Cmd+Shift+E / Cmd+Shift+H — the File-menu chords, previously
6205
+ // advertised but never bound.
6206
+ if (meta && e.shiftKey && (e.key === 'e' || e.key === 'E')) {
6207
+ e.preventDefault();
6208
+ setExportDialog({ mode: 'export' });
6209
+ return;
6210
+ }
6211
+ if (meta && e.shiftKey && (e.key === 'h' || e.key === 'H')) {
6212
+ e.preventDefault();
6213
+ setExportDialog({ mode: 'handoff' });
6214
+ return;
6215
+ }
3740
6216
  // Cmd+C / Ctrl+C — Phase 4.1 removed the shell-side comment-drop chord.
3741
6217
  // Canvas comment-drop is the `C` tool letter (press C in the canvas,
3742
6218
  // then click the element) or right-click "Add comment". Cmd+C now
@@ -3806,14 +6282,25 @@ function App() {
3806
6282
  }
3807
6283
  return;
3808
6284
  }
3809
- // Itoggle Inspector panel (T6, Plan C)
3810
- if ((e.key === 'i' || e.key === 'I') && !meta && !e.shiftKey) {
6285
+ // Nopen the new-brief-board composer (replaces the advertised ⌘N,
6286
+ // which the browser reserves for New Window and never delivers).
6287
+ if ((e.key === 'n' || e.key === 'N') && !meta && !e.shiftKey) {
3811
6288
  e.preventDefault();
3812
- setInspectorOpen((v) => !v);
6289
+ setSidebarOpen(true);
6290
+ setTimeout(
6291
+ () => document.querySelector('[aria-label="New blank brief board"]')?.click(),
6292
+ 60
6293
+ );
6294
+ return;
6295
+ }
6296
+ // ? — keyboard-shortcuts cheat sheet (DS shortcuts overlay)
6297
+ if (e.key === '?') {
6298
+ e.preventDefault();
6299
+ setShortcutsOpen((v) => !v);
3813
6300
  return;
3814
6301
  }
3815
- // ? or F1 — open Help modal
3816
- if (e.key === '?' || e.key === 'F1') {
6302
+ // F1 — the full Help modal (commands & flows)
6303
+ if (e.key === 'F1') {
3817
6304
  e.preventDefault();
3818
6305
  setHelpOpen(true);
3819
6306
  return;
@@ -3872,7 +6359,7 @@ function App() {
3872
6359
  group: 'Canvas',
3873
6360
  label: 'New canvas…',
3874
6361
  icon: 'plus',
3875
- kbd: 'N',
6362
+ kbd: 'N',
3876
6363
  run: () => {
3877
6364
  setSidebarOpen(true);
3878
6365
  setTimeout(
@@ -3919,7 +6406,7 @@ function App() {
3919
6406
  group: 'View',
3920
6407
  label: 'Open inspector',
3921
6408
  icon: 'sliders',
3922
- kbd: 'I',
6409
+ kbd: '⌘⇧I',
3923
6410
  run: () => setInspectorOpen(true),
3924
6411
  },
3925
6412
  {
@@ -3960,11 +6447,19 @@ function App() {
3960
6447
  run: () => whatsNew.openPanel(),
3961
6448
  },
3962
6449
  {
3963
- id: 'help',
6450
+ id: 'shortcuts',
3964
6451
  group: 'Help',
3965
- label: 'Help · shortcuts & commands',
6452
+ label: 'Keyboard shortcuts',
3966
6453
  icon: 'help',
3967
6454
  kbd: '?',
6455
+ run: () => setShortcutsOpen(true),
6456
+ },
6457
+ {
6458
+ id: 'help',
6459
+ group: 'Help',
6460
+ label: 'Help · commands & flows',
6461
+ icon: 'help',
6462
+ kbd: 'F1',
3968
6463
  run: () => setHelpOpen(true),
3969
6464
  },
3970
6465
  ],
@@ -4010,6 +6505,8 @@ function App() {
4010
6505
  showHidden={showHidden}
4011
6506
  onToggleShowHidden={() => setShowHidden((v) => !v)}
4012
6507
  onOpenHelp={() => setHelpOpen(true)}
6508
+ onOpenShortcuts={() => setShortcutsOpen(true)}
6509
+ onStartTour={() => startTour(USAGE_TOUR)}
4013
6510
  annotationsVisible={annotationsVisible}
4014
6511
  onToggleAnnotations={toggleAnnotations}
4015
6512
  postToActiveCanvas={postToActiveCanvas}
@@ -4017,7 +6514,17 @@ function App() {
4017
6514
  whatsNewCount={whatsNew.unseen.length}
4018
6515
  artboardCount={activeArtboards}
4019
6516
  inspectorOpen={inspectorOpen}
6517
+ inspectorTab={inspectorTab}
4020
6518
  onToggleInspector={() => setInspectorOpen((v) => !v)}
6519
+ onOpenLayers={() => {
6520
+ // Toggle: already open on Layers → close; otherwise open on Layers.
6521
+ if (inspectorOpen && inspectorTab === 'layers') {
6522
+ setInspectorOpen(false);
6523
+ } else {
6524
+ setInspectorTab('layers');
6525
+ setInspectorOpen(true);
6526
+ }
6527
+ }}
4021
6528
  onNewCanvas={() => {
4022
6529
  setSidebarOpen(true);
4023
6530
  setTimeout(
@@ -4027,6 +6534,7 @@ function App() {
4027
6534
  }}
4028
6535
  onOpenExport={(mode) => setExportDialog({ mode })}
4029
6536
  onReload={reloadActive}
6537
+ onCloseCanvas={() => activePath && closeTab(activePath)}
4030
6538
  presence={
4031
6539
  <>
4032
6540
  <StAvatar
@@ -4035,12 +6543,17 @@ function App() {
4035
6543
  title={gitUser ? `${gitUser} (you)` : 'You'}
4036
6544
  />
4037
6545
  {agentActive && (
4038
- <StAvatar initials="C" hue="var(--presence-agent)" title="Claude · editing" />
6546
+ <StAvatar
6547
+ initials="C"
6548
+ hue="var(--presence-agent)"
6549
+ title="Claude · editing"
6550
+ pulse
6551
+ />
4039
6552
  )}
4040
6553
  </>
4041
6554
  }
4042
6555
  />
4043
- <div className="st-body">
6556
+ <div className={'st-body' + (dragSide ? ' is-resizing' : '')} ref={bodyRef}>
4044
6557
  <CollapsedRail
4045
6558
  shown={!sidebarOpen}
4046
6559
  onExpand={() => setSidebarOpen(true)}
@@ -4066,7 +6579,21 @@ function App() {
4066
6579
  onDeleteBoard={deleteBoard}
4067
6580
  collapsed={!sidebarOpen}
4068
6581
  onCollapse={() => setSidebarOpen(false)}
6582
+ width={sbSize.w}
6583
+ resizing={dragSide === 'sb'}
4069
6584
  />
6585
+ {sidebarOpen && (
6586
+ <PanelGrip
6587
+ label="Resize files panel"
6588
+ size={sbSize}
6589
+ active={dragSide === 'sb'}
6590
+ onPointerDown={(e) => {
6591
+ e.preventDefault();
6592
+ e.currentTarget.setPointerCapture?.(e.pointerId);
6593
+ setDragSide('sb');
6594
+ }}
6595
+ />
6596
+ )}
4070
6597
  <div className="main">
4071
6598
  <Viewport
4072
6599
  tabs={tabs}
@@ -4077,12 +6604,53 @@ function App() {
4077
6604
  onSelectDs={loadSystemData}
4078
6605
  project={project}
4079
6606
  cfg={cfg}
6607
+ loadingPath={loadingPath}
6608
+ onIframeLoad={onIframeLoad}
4080
6609
  />
4081
6610
  </div>
6611
+ {(inspectorOpen || commentsPanelOpen) && (
6612
+ <PanelGrip
6613
+ label="Resize side panel"
6614
+ dir="rtl"
6615
+ size={rpSize}
6616
+ active={dragSide === 'rp'}
6617
+ onPointerDown={(e) => {
6618
+ e.preventDefault();
6619
+ e.currentTarget.setPointerCapture?.(e.pointerId);
6620
+ setDragSide('rp');
6621
+ }}
6622
+ />
6623
+ )}
4082
6624
  {/* Right dock — one panel at a time. Inspector takes precedence when
4083
6625
  open (T6); else the comments panel. */}
4084
6626
  {inspectorOpen ? (
4085
- <InspectorPanel selected={selected} onClose={() => setInspectorOpen(false)} />
6627
+ <InspectorPanel
6628
+ selected={selected}
6629
+ cfg={cfg}
6630
+ tab={inspectorTab}
6631
+ onTabChange={setInspectorTab}
6632
+ onClose={() => setInspectorOpen(false)}
6633
+ onOptimistic={applyOptimisticStyle}
6634
+ layersTree={layersTree}
6635
+ onSelectLayer={(n) =>
6636
+ postToActiveCanvas({
6637
+ dgn: 'select-by-id',
6638
+ id: n.id,
6639
+ artboardId: layersTree?.artboardId,
6640
+ index: n.index,
6641
+ })
6642
+ }
6643
+ onHoverLayer={(n) =>
6644
+ postToActiveCanvas({
6645
+ dgn: 'highlight',
6646
+ id: n ? n.id : null,
6647
+ artboardId: layersTree?.artboardId,
6648
+ index: n ? n.index : 0,
6649
+ })
6650
+ }
6651
+ width={rpSize.w}
6652
+ resizing={dragSide === 'rp'}
6653
+ />
4086
6654
  ) : commentsPanelOpen ? (
4087
6655
  <CommentsPanel
4088
6656
  commentsByFile={commentsByFile}
@@ -4094,6 +6662,8 @@ function App() {
4094
6662
  onResolve={resolveComment}
4095
6663
  onReopen={reopenComment}
4096
6664
  onDelete={deleteComment}
6665
+ width={rpSize.w}
6666
+ resizing={dragSide === 'rp'}
4097
6667
  />
4098
6668
  ) : null}
4099
6669
  </div>
@@ -4121,6 +6691,7 @@ function App() {
4121
6691
  onClose={() => setExportDialog(null)}
4122
6692
  />
4123
6693
  )}
6694
+ <ShortcutsOverlay open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
4124
6695
  <HelpModal
4125
6696
  open={helpOpen}
4126
6697
  onClose={() => setHelpOpen(false)}
@@ -4160,6 +6731,9 @@ function App() {
4160
6731
  open={!!tourSteps}
4161
6732
  onClose={() => setTourSteps(null)}
4162
6733
  onComplete={markUsageSeen}
6734
+ bus={tourBus}
6735
+ hasSelection={!!selected}
6736
+ hasCanvas={tabs.length > 0}
4163
6737
  />
4164
6738
  </div>
4165
6739
  );