@1agh/maude 0.24.0 → 0.25.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 (50) hide show
  1. package/package.json +8 -8
  2. package/plugins/design/dependencies.json +30 -2
  3. package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
  4. package/plugins/design/dev-server/annotations-layer.tsx +817 -170
  5. package/plugins/design/dev-server/api.ts +15 -1
  6. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  7. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  8. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  9. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  10. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  11. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  12. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  13. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  14. package/plugins/design/dev-server/bin/smoke.sh +114 -12
  15. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  16. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  17. package/plugins/design/dev-server/canvas-cursors.ts +130 -82
  18. package/plugins/design/dev-server/canvas-icons.tsx +169 -0
  19. package/plugins/design/dev-server/canvas-shell.tsx +113 -89
  20. package/plugins/design/dev-server/client/app.jsx +1084 -417
  21. package/plugins/design/dev-server/dist/client.bundle.js +242 -20
  22. package/plugins/design/dev-server/dist/comment-mount.js +40 -62
  23. package/plugins/design/dev-server/export-dialog.tsx +189 -1
  24. package/plugins/design/dev-server/exporters/html.ts +4 -1
  25. package/plugins/design/dev-server/exporters/index.ts +4 -1
  26. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  27. package/plugins/design/dev-server/exporters/png.ts +21 -2
  28. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  29. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  30. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  31. package/plugins/design/dev-server/http.ts +40 -9
  32. package/plugins/design/dev-server/input-router.tsx +9 -2
  33. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  34. package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
  35. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
  36. package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
  37. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  38. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  39. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  40. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  41. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  42. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  43. package/plugins/design/dev-server/test/input-router.test.ts +11 -4
  44. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
  45. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  46. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +9 -11
  47. package/plugins/design/dev-server/tool-palette.tsx +140 -11
  48. package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
  49. package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
  50. package/plugins/design/templates/_shell.html +36 -9
@@ -3,9 +3,15 @@
3
3
  // Renders: file tree, tabs, viewport (iframes), status bar, design-system view, comments.
4
4
  // Universal — no project tokens needed; styling lives in client/styles/.
5
5
 
6
- import { useState, useEffect, useRef, useMemo, useCallback, Fragment } from 'react';
6
+ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
7
7
  import { createRoot } from 'react-dom/client';
8
8
 
9
+ // Trusted tool→cursor resolver (shares the single TOOL_CURSORS source with the
10
+ // canvas runtime). canvas-cursors.ts is dependency-free (a type-only Tool
11
+ // import that Bun erases), so this pulls only string constants into the client
12
+ // bundle — no React, no input-router. See the tool-cursor handler below.
13
+ import { resolveToolCursor } from '../canvas-cursors.ts';
14
+
9
15
  const SYSTEM_TAB = '__system__';
10
16
  const THEME_STORE = 'mdcc-theme';
11
17
  const SHOW_HIDDEN_STORE = 'mdcc-show-hidden';
@@ -41,7 +47,9 @@ function readJsonStore(key, fallback) {
41
47
  try {
42
48
  const v = localStorage.getItem(key);
43
49
  return v ? JSON.parse(v) : fallback;
44
- } catch { return fallback; }
50
+ } catch {
51
+ return fallback;
52
+ }
45
53
  }
46
54
 
47
55
  // Section default-open: working sections (project + non-DS canvas groups)
@@ -98,8 +106,8 @@ function canvasUrl(p, cfg, opts) {
98
106
  // tokens file. The top-level cfg.tokensCssRel is the legacy default
99
107
  // (`system/colors_and_type.css`) and usually doesn't exist post-bootstrap.
100
108
  const tokens = specMatch
101
- ? (specDsEntry?.tokensCssRel || `system/${specMatch[1]}/colors_and_type.css`)
102
- : (ds0?.tokensCssRel || cfg?.tokensCssRel);
109
+ ? specDsEntry?.tokensCssRel || `system/${specMatch[1]}/colors_and_type.css`
110
+ : ds0?.tokensCssRel || cfg?.tokensCssRel;
103
111
  if (tokens) params.set('tokens', tokens);
104
112
  if (cfg?.componentsCssRel) params.set('components', cfg.componentsCssRel);
105
113
  if (specMatch) {
@@ -192,7 +200,9 @@ function groupBySidecar(files) {
192
200
  function buildTree(paths, stripPrefix) {
193
201
  const root = {};
194
202
  for (const p of paths) {
195
- const stripped = p.startsWith(stripPrefix) ? p.slice(stripPrefix.length).replace(/^\/+/, '') : p;
203
+ const stripped = p.startsWith(stripPrefix)
204
+ ? p.slice(stripPrefix.length).replace(/^\/+/, '')
205
+ : p;
196
206
  const parts = stripped.split('/');
197
207
  let node = root;
198
208
  for (let i = 0; i < parts.length; i++) {
@@ -215,22 +225,28 @@ function filterTree(node, query) {
215
225
  const q = query.toLowerCase();
216
226
  const out = {};
217
227
  let any = false;
218
- const dirs = Object.keys(node).filter(k => k !== '_files');
228
+ const dirs = Object.keys(node).filter((k) => k !== '_files');
219
229
  for (const d of dirs) {
220
230
  const filtered = filterTree(node[d], query);
221
- if (filtered) { out[d] = filtered; any = true; }
231
+ if (filtered) {
232
+ out[d] = filtered;
233
+ any = true;
234
+ }
222
235
  }
223
236
  if (node._files) {
224
- const files = node._files.filter(f =>
225
- f.name.toLowerCase().includes(q) || f.path.toLowerCase().includes(q)
237
+ const files = node._files.filter(
238
+ (f) => f.name.toLowerCase().includes(q) || f.path.toLowerCase().includes(q)
226
239
  );
227
- if (files.length) { out._files = files; any = true; }
240
+ if (files.length) {
241
+ out._files = files;
242
+ any = true;
243
+ }
228
244
  }
229
245
  return any ? out : null;
230
246
  }
231
247
 
232
248
  function openCount(comments) {
233
- return (comments || []).filter(c => c.status !== 'resolved').length;
249
+ return (comments || []).filter((c) => c.status !== 'resolved').length;
234
250
  }
235
251
 
236
252
  function timeAgo(iso) {
@@ -249,7 +265,9 @@ function timeAgo(iso) {
249
265
  }
250
266
 
251
267
  function totalCounts(commentsByFile) {
252
- let all = 0, open = 0, resolved = 0;
268
+ let all = 0,
269
+ open = 0,
270
+ resolved = 0;
253
271
  for (const list of Object.values(commentsByFile || {})) {
254
272
  for (const c of list || []) {
255
273
  all++;
@@ -264,7 +282,17 @@ function totalCounts(commentsByFile) {
264
282
 
265
283
  function Icon({ d, size = 14, color }) {
266
284
  return (
267
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color || 'currentColor'} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flex: 'none' }}>
285
+ <svg
286
+ width={size}
287
+ height={size}
288
+ viewBox="0 0 24 24"
289
+ fill="none"
290
+ stroke={color || 'currentColor'}
291
+ strokeWidth="1.5"
292
+ strokeLinecap="round"
293
+ strokeLinejoin="round"
294
+ style={{ flex: 'none' }}
295
+ >
268
296
  <path d={d} />
269
297
  </svg>
270
298
  );
@@ -291,9 +319,11 @@ function DirRow({ name, depth, defaultOpen, children }) {
291
319
  tabIndex={-1}
292
320
  className="tp-row dir"
293
321
  style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
294
- onClick={() => setOpen(v => !v)}
322
+ onClick={() => setOpen((v) => !v)}
295
323
  >
296
- <span className="glyph" aria-hidden="true">{open ? '▾' : '▸'}</span>
324
+ <span className="glyph" aria-hidden="true">
325
+ {open ? '▾' : '▸'}
326
+ </span>
297
327
  <span className="name">{name}</span>
298
328
  </button>
299
329
  {open && children}
@@ -322,7 +352,9 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
322
352
  aria-label={open ? 'Collapse design system' : 'Expand design system'}
323
353
  title={open ? 'Collapse' : 'Expand'}
324
354
  >
325
- <span className="glyph" aria-hidden="true">{open ? '▾' : '▸'}</span>
355
+ <span className="glyph" aria-hidden="true">
356
+ {open ? '▾' : '▸'}
357
+ </span>
326
358
  </button>
327
359
  <button
328
360
  type="button"
@@ -354,19 +386,38 @@ function FileRow({ file, activePath, onOpen, openCount: oc, depth, kind, sidecar
354
386
  aria-selected={isSel}
355
387
  aria-disabled={inert ? 'true' : undefined}
356
388
  tabIndex={isSel ? 0 : -1}
357
- className={'tp-row' + (isSel ? ' sel' : '') + (kind === 'runtime' ? ' muted' : '') + (sidecar ? ' sidecar' : '')}
389
+ className={
390
+ 'tp-row' +
391
+ (isSel ? ' sel' : '') +
392
+ (kind === 'runtime' ? ' muted' : '') +
393
+ (sidecar ? ' sidecar' : '')
394
+ }
358
395
  style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
359
- title={file.path + (oc ? ` — ${oc} open` : (inert ? ' (file index only)' : ''))}
360
- onClick={() => { if (!inert) onOpen(file.path); }}
396
+ title={file.path + (oc ? ` — ${oc} open` : inert ? ' (file index only)' : '')}
397
+ onClick={() => {
398
+ if (!inert) onOpen(file.path);
399
+ }}
361
400
  >
362
- <span className="glyph" aria-hidden="true">{isSel ? '▸' : '·'}</span>
401
+ <span className="glyph" aria-hidden="true">
402
+ {isSel ? '▸' : '·'}
403
+ </span>
363
404
  <span className="name">{label}</span>
364
405
  {oc > 0 && <span className="badge">{oc}</span>}
365
406
  </button>
366
407
  );
367
408
  }
368
409
 
369
- function CanvasRow({ primary, sidecars, depth, kind, activePath, onOpen, openCount: oc, showHidden, forceOpen }) {
410
+ function CanvasRow({
411
+ primary,
412
+ sidecars,
413
+ depth,
414
+ kind,
415
+ activePath,
416
+ onOpen,
417
+ openCount: oc,
418
+ showHidden,
419
+ forceOpen,
420
+ }) {
370
421
  const hasSidecars = sidecars.length > 0;
371
422
  const [openState, setOpenState] = useState(false);
372
423
  // Sidecars are only revealed when the user opts in via `showHidden` — the
@@ -420,24 +471,39 @@ function CanvasRow({ primary, sidecars, depth, kind, activePath, onOpen, openCou
420
471
  <span className="name">{displayName(primary.name)}</span>
421
472
  {oc > 0 && <span className="badge">{oc}</span>}
422
473
  </button>
423
- {open && sidecars.map((sc) => (
424
- <FileRow
425
- key={sc.path}
426
- file={sc}
427
- activePath={activePath}
428
- onOpen={onOpen}
429
- openCount={0}
430
- depth={depth + 1}
431
- kind={kind}
432
- sidecar
433
- />
434
- ))}
474
+ {open &&
475
+ sidecars.map((sc) => (
476
+ <FileRow
477
+ key={sc.path}
478
+ file={sc}
479
+ activePath={activePath}
480
+ onOpen={onOpen}
481
+ openCount={0}
482
+ depth={depth + 1}
483
+ kind={kind}
484
+ sidecar
485
+ />
486
+ ))}
435
487
  </Fragment>
436
488
  );
437
489
  }
438
490
 
439
- function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showHidden, search, dsFolders, activeDsName, onOpenSystem }) {
440
- const dirs = Object.keys(node).filter(k => k !== '_files').sort();
491
+ function Tree({
492
+ node,
493
+ activePath,
494
+ onOpen,
495
+ commentsByFile,
496
+ depth = 1,
497
+ kind,
498
+ showHidden,
499
+ search,
500
+ dsFolders,
501
+ activeDsName,
502
+ onOpenSystem,
503
+ }) {
504
+ const dirs = Object.keys(node)
505
+ .filter((k) => k !== '_files')
506
+ .sort();
441
507
  const files = node._files || [];
442
508
  // VS Code-style sidecar grouping. Canvas (`.tsx`/`.html`) becomes the primary
443
509
  // row; same-basename non-canvas files (`.meta.json`, `.css`, …) collapse
@@ -456,10 +522,12 @@ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showH
456
522
  return (
457
523
  <Fragment>
458
524
  {canvases.map((entry) => {
459
- const forceOpen = hasSearch && entry.sidecars.some((sc) => {
460
- const q = search.toLowerCase();
461
- return sc.name.toLowerCase().includes(q) || sc.path.toLowerCase().includes(q);
462
- });
525
+ const forceOpen =
526
+ hasSearch &&
527
+ entry.sidecars.some((sc) => {
528
+ const q = search.toLowerCase();
529
+ return sc.name.toLowerCase().includes(q) || sc.path.toLowerCase().includes(q);
530
+ });
463
531
  return (
464
532
  <CanvasRow
465
533
  key={entry.primary.path}
@@ -475,18 +543,19 @@ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showH
475
543
  />
476
544
  );
477
545
  })}
478
- {showHidden && orphans.map((entry) => (
479
- <FileRow
480
- key={entry.primary.path}
481
- file={entry.primary}
482
- activePath={activePath}
483
- onOpen={onOpen}
484
- openCount={openCount(commentsByFile[entry.primary.path])}
485
- depth={depth}
486
- kind={kind}
487
- />
488
- ))}
489
- {dirs.map(d => {
546
+ {showHidden &&
547
+ orphans.map((entry) => (
548
+ <FileRow
549
+ key={entry.primary.path}
550
+ file={entry.primary}
551
+ activePath={activePath}
552
+ onOpen={onOpen}
553
+ openCount={openCount(commentsByFile[entry.primary.path])}
554
+ depth={depth}
555
+ kind={kind}
556
+ />
557
+ ))}
558
+ {dirs.map((d) => {
490
559
  const dsMatch = dsFolderByName?.get(d);
491
560
  const childTree = (
492
561
  <Tree
@@ -531,12 +600,12 @@ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showH
531
600
  // project / DS identity; the mock keeps these tight (1 line). Labels are
532
601
  // keyed by the server-provided `kind` (PROJECT / DS / UI / RUNTIME).
533
602
  const SECTION_META = {
534
- project: { title: 'PROJECT', pillFromCount: false },
603
+ project: { title: 'PROJECT', pillFromCount: false },
535
604
  // Design-system group: pill shows the number of DSes (one row per DS folder
536
605
  // inside). Computed in Sidebar from `g.dsFolders.length`.
537
- ds: { title: 'DESIGN SYSTEM', pillFromDsCount: true },
538
- canvas: { title: 'UI CANVASES', pillFromCount: true },
539
- runtime: { title: 'RUNTIME · GITIGNORED', pillFromCount: true },
606
+ ds: { title: 'DESIGN SYSTEM', pillFromDsCount: true },
607
+ canvas: { title: 'UI CANVASES', pillFromCount: true },
608
+ runtime: { title: 'RUNTIME · GITIGNORED', pillFromCount: true },
540
609
  };
541
610
 
542
611
  function sectionMetaFor(g) {
@@ -544,14 +613,27 @@ function sectionMetaFor(g) {
544
613
  if (g.kind === 'runtime') return SECTION_META.runtime;
545
614
  // canvas-kind groups: "Design system" → ds, anything else → canvas label
546
615
  if (g.label === 'Design system') return SECTION_META.ds;
547
- if (g.label === 'UI kit') return SECTION_META.canvas;
616
+ if (g.label === 'UI kit') return SECTION_META.canvas;
548
617
  return { title: g.label.toUpperCase(), pillFromCount: true };
549
618
  }
550
619
 
551
- function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsConnected, search, setSearch, commentsByFile, showHidden, sectionsExpanded, onToggleSection }) {
620
+ function Sidebar({
621
+ groups,
622
+ activePath,
623
+ activeDsName,
624
+ onOpen,
625
+ onOpenSystem,
626
+ wsConnected,
627
+ search,
628
+ setSearch,
629
+ commentsByFile,
630
+ showHidden,
631
+ sectionsExpanded,
632
+ onToggleSection,
633
+ }) {
552
634
  const filteredGroups = useMemo(() => {
553
635
  if (!search) return groups;
554
- return groups.map(g => ({ ...g, tree: filterTree(g.tree, search), filtered: !!search }));
636
+ return groups.map((g) => ({ ...g, tree: filterTree(g.tree, search), filtered: !!search }));
555
637
  }, [groups, search]);
556
638
 
557
639
  // Mock uses `42 / 42` — total openable canvases, not every listed file.
@@ -564,7 +646,8 @@ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsCon
564
646
  }, [groups]);
565
647
  const htmlShown = useMemo(() => {
566
648
  let total = 0;
567
- for (const g of filteredGroups) for (const p of g.paths || []) if (CANVAS_EXT_RE.test(p)) total++;
649
+ for (const g of filteredGroups)
650
+ for (const p of g.paths || []) if (CANVAS_EXT_RE.test(p)) total++;
568
651
  return total;
569
652
  }, [filteredGroups]);
570
653
 
@@ -584,18 +667,27 @@ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsCon
584
667
  type="search"
585
668
  placeholder="filter (⌘F)"
586
669
  value={search}
587
- onChange={e => setSearch(e.target.value)}
670
+ onChange={(e) => setSearch(e.target.value)}
588
671
  aria-label="Filter files"
589
672
  />
590
673
  {search ? (
591
- <button className="search-clear" onClick={() => setSearch('')} title="Clear (Esc)" aria-label="Clear search">×</button>
674
+ <button
675
+ className="search-clear"
676
+ onClick={() => setSearch('')}
677
+ title="Clear (Esc)"
678
+ aria-label="Clear search"
679
+ >
680
+ ×
681
+ </button>
592
682
  ) : (
593
- <span className="search-kbd" aria-hidden="true">/</span>
683
+ <span className="search-kbd" aria-hidden="true">
684
+ /
685
+ </span>
594
686
  )}
595
687
  </div>
596
688
 
597
689
  <div className="tree-panel-body" role="tree" aria-label="Project file tree">
598
- {filteredGroups.map(g => {
690
+ {filteredGroups.map((g) => {
599
691
  // Hide gitignored runtime / orphan-only project sections by default.
600
692
  // Active search overrides — if the user typed a query, they want hits
601
693
  // wherever they live.
@@ -604,9 +696,10 @@ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsCon
604
696
  // Counter pill counts canvases only — sidecars + orphans inflate the
605
697
  // raw `paths.length` and the FILES header already filters this way.
606
698
  const canvasCount = (g.paths || []).filter((p) => CANVAS_EXT_RE.test(p)).length;
607
- const pill = meta.pill
608
- || (meta.pillFromDsCount ? String(g.dsFolders?.length || 0) : null)
609
- || (meta.pillFromCount ? String(canvasCount || g.paths?.length || 0) : null);
699
+ const pill =
700
+ meta.pill ||
701
+ (meta.pillFromDsCount ? String(g.dsFolders?.length || 0) : null) ||
702
+ (meta.pillFromCount ? String(canvasCount || g.paths?.length || 0) : null);
610
703
  const hasItems = g.tree && Object.keys(g.tree).length > 0;
611
704
  const isDs = g.label === 'Design system';
612
705
  const isProject = g.kind === 'project';
@@ -628,29 +721,30 @@ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsCon
628
721
  aria-expanded={sectionOpen}
629
722
  title={sectionOpen ? 'Collapse section' : 'Expand section'}
630
723
  >
631
- <span className="chev" aria-hidden="true">{chev}</span>
724
+ <span className="chev" aria-hidden="true">
725
+ {chev}
726
+ </span>
632
727
  <span className="section-label">{meta.title}</span>
633
728
  {pill && <span className="pill">{pill}</span>}
634
729
  </button>
635
- {sectionOpen && (hasItems ? (
636
- <Tree
637
- node={g.tree}
638
- activePath={activePath}
639
- onOpen={onOpen}
640
- commentsByFile={commentsByFile}
641
- depth={1}
642
- kind={g.kind}
643
- showHidden={showHidden}
644
- search={search}
645
- dsFolders={g.dsFolders}
646
- activeDsName={activeDsName}
647
- onOpenSystem={isDs ? onOpenSystem : undefined}
648
- />
649
- ) : (
650
- <div className="tp-empty">
651
- {search ? 'No matches.' : 'Empty.'}
652
- </div>
653
- ))}
730
+ {sectionOpen &&
731
+ (hasItems ? (
732
+ <Tree
733
+ node={g.tree}
734
+ activePath={activePath}
735
+ onOpen={onOpen}
736
+ commentsByFile={commentsByFile}
737
+ depth={1}
738
+ kind={g.kind}
739
+ showHidden={showHidden}
740
+ search={search}
741
+ dsFolders={g.dsFolders}
742
+ activeDsName={activeDsName}
743
+ onOpenSystem={isDs ? onOpenSystem : undefined}
744
+ />
745
+ ) : (
746
+ <div className="tp-empty">{search ? 'No matches.' : 'Empty.'}</div>
747
+ ))}
654
748
  </Fragment>
655
749
  );
656
750
  })}
@@ -664,7 +758,9 @@ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsCon
664
758
  function HelpModal({ open, onClose }) {
665
759
  useEffect(() => {
666
760
  if (!open) return;
667
- function onKey(e) { if (e.key === 'Escape') onClose(); }
761
+ function onKey(e) {
762
+ if (e.key === 'Escape') onClose();
763
+ }
668
764
  window.addEventListener('keydown', onKey);
669
765
  return () => window.removeEventListener('keydown', onKey);
670
766
  }, [open, onClose]);
@@ -673,112 +769,286 @@ function HelpModal({ open, onClose }) {
673
769
  <div
674
770
  className="help-modal-backdrop"
675
771
  role="presentation"
676
- onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}
772
+ onMouseDown={(e) => {
773
+ if (e.target === e.currentTarget) onClose();
774
+ }}
677
775
  >
678
- <div className="help-modal" role="dialog" aria-modal="true" aria-labelledby="help-modal-title">
776
+ <div
777
+ className="help-modal"
778
+ role="dialog"
779
+ aria-modal="true"
780
+ aria-labelledby="help-modal-title"
781
+ >
679
782
  <header className="help-modal-hd">
680
- <span className="title" id="help-modal-title">Help · shortcuts &amp; commands</span>
783
+ <span className="title" id="help-modal-title">
784
+ Help · shortcuts &amp; commands
785
+ </span>
681
786
  <span className="sku">MDCC-DEV-SRV / v{MDCC_VERSION}</span>
682
- <button type="button" className="help-modal-close" aria-label="Close (Esc)" onClick={onClose}>×</button>
787
+ <button
788
+ type="button"
789
+ className="help-modal-close"
790
+ aria-label="Close (Esc)"
791
+ onClick={onClose}
792
+ >
793
+ ×
794
+ </button>
683
795
  </header>
684
796
  <div className="help-modal-body">
685
797
  <details open>
686
798
  <summary>Canvas selection &amp; tools</summary>
687
- <ul>
688
- <li><kbd>V</kbd> <span>move tool — Cmd+click to select, Cmd+Shift to multi</span></li>
689
- <li><kbd>H</kbd> <span>hand tool — bare drag pans (no Space needed)</span></li>
690
- <li><kbd>C</kbd> <span>comment tool — hover paints, click drops a pin</span></li>
691
- <li><kbd>⌘</kbd> + hover <span>preview deepest element under cursor</span></li>
692
- <li><kbd>⌘</kbd> + click <span>select that element (replace)</span></li>
693
- <li><kbd>⌘⇧</kbd> + click <span>add deepest to selection (multi)</span></li>
694
- <li>right-click <span>context menu (Copy CSS / Fit / Reset...)</span></li>
695
- <li><kbd>Esc</kbd> in canvas <span>clear selection + close menu</span></li>
696
- </ul>
697
- </details>
698
- <details>
699
- <summary>Annotation tools</summary>
700
- <ul>
701
- <li><kbd>B</kbd> <span>pen freehand stroke</span></li>
702
- <li><kbd>R</kbd> <span>rectangle — drag to define corners</span></li>
703
- <li><kbd>O</kbd> <span>ellipse — drag from center outward</span></li>
704
- <li><kbd>A</kbd> <span>arrow drag tail → tip</span></li>
705
- <li><kbd>E</kbd> <span>eraser — click or drag over strokes to remove</span></li>
706
- <li><kbd>V</kbd> + click stroke <span>select annotation (Shift+click to multi)</span></li>
707
- <li><kbd>V</kbd> + drag empty <span>marquee-select strokes that overlap</span></li>
708
- <li>double-click rect/ellipse <span>add text inside the shape</span></li>
709
- <li>arrow keys <span>nudge selected annotation 1 unit (Shift = 10)</span></li>
710
- <li><kbd>Backspace</kbd> <span>delete selected annotations</span></li>
711
- <li><kbd>⇧P</kbd> <span>presentation — hide annotations for clean screenshot</span></li>
712
- </ul>
713
- </details>
714
- <details>
715
- <summary>Tabs &amp; canvas</summary>
716
- <ul>
717
- <li>click in tree <span>open tab</span></li>
718
- <li><kbd>×</kbd> on tab <span>close tab</span></li>
719
- <li><kbd>⌘R</kbd> <span>reload iframe</span></li>
720
- <li><kbd>/</kbd> <span>focus search</span></li>
721
- <li><kbd>⌘⇧M</kbd> <span>toggle comments panel</span></li>
722
- </ul>
723
- </details>
724
- <details>
725
- <summary>Slash commands</summary>
726
- <ul className="cmds">
727
- <li><code>/design:edit "<i>feedback</i>"</code><span>edit + 4-iter multi-axis loop</span></li>
728
- <li><code>/design:edit "<i>…</i>" --perfect</code><span>8-iter polish (4.5/5 aspiration)</span></li>
729
- <li><code>/design:edit "<i>…</i>" --no-critic</code><span>raw edit, skip loop</span></li>
730
- <li><code>/design:edit "<i>…</i>" --opt-out=<i>scope</i></code><span>override DS scope (palette/aesthetic/full)</span></li>
731
- <li><code>/design:new "<i>Name</i>" "<i>brief</i>"</code><span>scaffold canvas</span></li>
732
- <li><code>/design:new "<i>…</i>" --opt-out=aesthetic</code><span>scaffold off-system canvas (gradients/radii/type free)</span></li>
733
- <li><code>/design:critic</code><span>review panel (routed)</span></li>
734
- <li><code>/design:critic --all</code><span>10-critic sweep</span></li>
735
- <li><code>/design:critic --agent signature-moment-critic</code><span>aspiration axis only</span></li>
736
- <li><code>/design:rollback</code><span>undo last edit</span></li>
737
- <li><code>/design:screenshot</code><span>capture canvas</span></li>
738
- <li><code>/design:setup-docs</code><span>refresh README + INDEX</span></li>
739
- <li><code>/design:handoff</code><span>migrate to apps/</span></li>
740
- </ul>
741
- </details>
742
- <details>
743
- <summary>Opt-out scope</summary>
744
- <ul>
745
- <li><strong>palette</strong> <span>default tokens + rootClass kept; local namespace overrides colors only. DS aesthetic still enforced.</span></li>
746
- <li><strong>aesthetic</strong> <span>palette + gradients/off-ladder radii/alt type/decorative SVG flags allowed.</span></li>
747
- <li><strong>full</strong> <span>DS treated as advisory. Type/radii/aesthetic up to canvas.</span></li>
748
- <li><em>A11y enforced at every scope</em> <span>contrast, focus, semantics, motion, touch targets — never relaxed.</span></li>
749
- <li>Persisted on canvas's <code>.meta.json</code> <code>opt_out_scope</code> field — subsequent <code>/design:edit</code> iterations inherit.</li>
750
- <li>Inferred from brief ("modern", "vibrant", "off-system") with one-shot AskUserQuestion before iter-1 critics fire.</li>
751
- </ul>
752
- </details>
753
- <details>
754
- <summary>Auto-critic loop</summary>
755
- <ul>
756
- <li><strong>Default</strong> <span>4 iter · aspiration ≥ 4.0 · stable-but-bland exit</span></li>
757
- <li><strong>--perfect</strong> <span>8 iter · aspiration ≥ 4.5 · broader divergence tolerance</span></li>
758
- <li><strong>--perfect --all</strong> <span>every critic incl. aspiration · portfolio-grade</span></li>
759
- <li>Exit: <code>solid</code> · <code>stable-but-bland</code> · <code>max-reached</code> · <code>divergent</code></li>
760
- <li><em>stable-but-bland</em> = correctness clean, aspiration plateau — surface for review with lowest 2 axes named</li>
761
- <li>When <code>opt_out_scope ∈ &#123;aesthetic, full&#125;</code>: iter-1 checkpoint fires — pick (a) run loop, (b) skip auto-loop and review iter 1, (c) a11y-only check.</li>
762
- </ul>
763
- </details>
764
- <details>
765
- <summary>Pin-to-element flow</summary>
766
- <ol>
767
- <li>Open canvas tab</li>
768
- <li><kbd>⌘</kbd>+click element</li>
769
- <li>Status bar shows ● selector</li>
770
- <li>Run <code>/design:edit "<i>change just this</i>"</code></li>
771
- <li>Reload iframe (<kbd>⌘R</kbd>)</li>
772
- </ol>
773
- </details>
799
+ <ul>
800
+ <li>
801
+ <kbd>V</kbd> <span>move tool — Cmd+click to select, Cmd+Shift to multi</span>
802
+ </li>
803
+ <li>
804
+ <kbd>H</kbd> <span>hand tool bare drag pans (no Space needed)</span>
805
+ </li>
806
+ <li>
807
+ <kbd>C</kbd> <span>comment tool hover paints, click drops a pin</span>
808
+ </li>
809
+ <li>
810
+ <kbd>⌘</kbd> + hover <span>preview deepest element under cursor</span>
811
+ </li>
812
+ <li>
813
+ <kbd>⌘</kbd> + click <span>select that element (replace)</span>
814
+ </li>
815
+ <li>
816
+ <kbd>⌘⇧</kbd> + click <span>add deepest to selection (multi)</span>
817
+ </li>
818
+ <li>
819
+ right-click <span>context menu (Copy CSS / Fit / Reset...)</span>
820
+ </li>
821
+ <li>
822
+ <kbd>Esc</kbd> in canvas <span>clear selection + close menu</span>
823
+ </li>
824
+ </ul>
825
+ </details>
826
+ <details>
827
+ <summary>Annotation tools</summary>
828
+ <ul>
829
+ <li>
830
+ <kbd>B</kbd> <span>pen — freehand stroke</span>
831
+ </li>
832
+ <li>
833
+ <kbd>R</kbd> <span>rectangle drag to define corners</span>
834
+ </li>
835
+ <li>
836
+ <kbd>O</kbd> <span>ellipse — drag from center outward</span>
837
+ </li>
838
+ <li>
839
+ <kbd>A</kbd> <span>arrow drag tail → tip</span>
840
+ </li>
841
+ <li>
842
+ <kbd>E</kbd> <span>eraser click or drag over strokes to remove</span>
843
+ </li>
844
+ <li>
845
+ <kbd>V</kbd> + click stroke <span>select annotation (Shift+click to multi)</span>
846
+ </li>
847
+ <li>
848
+ <kbd>V</kbd> + drag empty <span>marquee-select strokes that overlap</span>
849
+ </li>
850
+ <li>
851
+ double-click rect/ellipse <span>add text inside the shape</span>
852
+ </li>
853
+ <li>
854
+ arrow keys <span>nudge selected annotation 1 unit (Shift = 10)</span>
855
+ </li>
856
+ <li>
857
+ <kbd>Backspace</kbd> <span>delete selected annotations</span>
858
+ </li>
859
+ <li>
860
+ <kbd>⇧P</kbd> <span>presentation hide annotations for clean screenshot</span>
861
+ </li>
862
+ </ul>
863
+ </details>
864
+ <details>
865
+ <summary>Tabs &amp; canvas</summary>
866
+ <ul>
867
+ <li>
868
+ click in tree <span>open tab</span>
869
+ </li>
870
+ <li>
871
+ <kbd>×</kbd> on tab <span>close tab</span>
872
+ </li>
873
+ <li>
874
+ <kbd>⌘R</kbd> <span>reload iframe</span>
875
+ </li>
876
+ <li>
877
+ <kbd>/</kbd> <span>focus search</span>
878
+ </li>
879
+ <li>
880
+ <kbd>⌘⇧M</kbd> <span>toggle comments panel</span>
881
+ </li>
882
+ </ul>
883
+ </details>
884
+ <details>
885
+ <summary>Slash commands</summary>
886
+ <ul className="cmds">
887
+ <li>
888
+ <code>
889
+ /design:edit "<i>feedback</i>"
890
+ </code>
891
+ <span>edit + 4-iter multi-axis loop</span>
892
+ </li>
893
+ <li>
894
+ <code>
895
+ /design:edit "<i>…</i>" --perfect
896
+ </code>
897
+ <span>8-iter polish (4.5/5 aspiration)</span>
898
+ </li>
899
+ <li>
900
+ <code>
901
+ /design:edit "<i>…</i>" --no-critic
902
+ </code>
903
+ <span>raw edit, skip loop</span>
904
+ </li>
905
+ <li>
906
+ <code>
907
+ /design:edit "<i>…</i>" --opt-out=<i>scope</i>
908
+ </code>
909
+ <span>override DS scope (palette/aesthetic/full)</span>
910
+ </li>
911
+ <li>
912
+ <code>
913
+ /design:new "<i>Name</i>" "<i>brief</i>"
914
+ </code>
915
+ <span>scaffold canvas</span>
916
+ </li>
917
+ <li>
918
+ <code>
919
+ /design:new "<i>…</i>" --opt-out=aesthetic
920
+ </code>
921
+ <span>scaffold off-system canvas (gradients/radii/type free)</span>
922
+ </li>
923
+ <li>
924
+ <code>/design:critic</code>
925
+ <span>review panel (routed)</span>
926
+ </li>
927
+ <li>
928
+ <code>/design:critic --all</code>
929
+ <span>10-critic sweep</span>
930
+ </li>
931
+ <li>
932
+ <code>/design:critic --agent signature-moment-critic</code>
933
+ <span>aspiration axis only</span>
934
+ </li>
935
+ <li>
936
+ <code>/design:rollback</code>
937
+ <span>undo last edit</span>
938
+ </li>
939
+ <li>
940
+ <code>/design:screenshot</code>
941
+ <span>capture canvas</span>
942
+ </li>
943
+ <li>
944
+ <code>/design:setup-docs</code>
945
+ <span>refresh README + INDEX</span>
946
+ </li>
947
+ <li>
948
+ <code>/design:handoff</code>
949
+ <span>migrate to apps/</span>
950
+ </li>
951
+ </ul>
952
+ </details>
953
+ <details>
954
+ <summary>Opt-out scope</summary>
955
+ <ul>
956
+ <li>
957
+ <strong>palette</strong>{' '}
958
+ <span>
959
+ default — tokens + rootClass kept; local namespace overrides colors only. DS
960
+ aesthetic still enforced.
961
+ </span>
962
+ </li>
963
+ <li>
964
+ <strong>aesthetic</strong>{' '}
965
+ <span>
966
+ palette + gradients/off-ladder radii/alt type/decorative SVG flags allowed.
967
+ </span>
968
+ </li>
969
+ <li>
970
+ <strong>full</strong>{' '}
971
+ <span>DS treated as advisory. Type/radii/aesthetic up to canvas.</span>
972
+ </li>
973
+ <li>
974
+ <em>A11y enforced at every scope</em>{' '}
975
+ <span>contrast, focus, semantics, motion, touch targets — never relaxed.</span>
976
+ </li>
977
+ <li>
978
+ Persisted on canvas's <code>.meta.json</code> <code>opt_out_scope</code> field —
979
+ subsequent <code>/design:edit</code> iterations inherit.
980
+ </li>
981
+ <li>
982
+ Inferred from brief ("modern", "vibrant", "off-system") with one-shot
983
+ AskUserQuestion before iter-1 critics fire.
984
+ </li>
985
+ </ul>
986
+ </details>
987
+ <details>
988
+ <summary>Auto-critic loop</summary>
989
+ <ul>
990
+ <li>
991
+ <strong>Default</strong>{' '}
992
+ <span>4 iter · aspiration ≥ 4.0 · stable-but-bland exit</span>
993
+ </li>
994
+ <li>
995
+ <strong>--perfect</strong>{' '}
996
+ <span>8 iter · aspiration ≥ 4.5 · broader divergence tolerance</span>
997
+ </li>
998
+ <li>
999
+ <strong>--perfect --all</strong>{' '}
1000
+ <span>every critic incl. aspiration · portfolio-grade</span>
1001
+ </li>
1002
+ <li>
1003
+ Exit: <code>solid</code> · <code>stable-but-bland</code> · <code>max-reached</code>{' '}
1004
+ · <code>divergent</code>
1005
+ </li>
1006
+ <li>
1007
+ <em>stable-but-bland</em> = correctness clean, aspiration plateau — surface for
1008
+ review with lowest 2 axes named
1009
+ </li>
1010
+ <li>
1011
+ When <code>opt_out_scope ∈ &#123;aesthetic, full&#125;</code>: iter-1 checkpoint
1012
+ fires — pick (a) run loop, (b) skip auto-loop and review iter 1, (c) a11y-only
1013
+ check.
1014
+ </li>
1015
+ </ul>
1016
+ </details>
1017
+ <details>
1018
+ <summary>Pin-to-element flow</summary>
1019
+ <ol>
1020
+ <li>Open canvas tab</li>
1021
+ <li>
1022
+ <kbd>⌘</kbd>+click element
1023
+ </li>
1024
+ <li>Status bar shows ● selector</li>
1025
+ <li>
1026
+ Run{' '}
1027
+ <code>
1028
+ /design:edit "<i>change just this</i>"
1029
+ </code>
1030
+ </li>
1031
+ <li>
1032
+ Reload iframe (<kbd>⌘R</kbd>)
1033
+ </li>
1034
+ </ol>
1035
+ </details>
774
1036
  <details>
775
1037
  <summary>Comments</summary>
776
1038
  <ol>
777
- <li><kbd>⌘</kbd>+click element, then <kbd>⌘C</kbd> <span>or ⌘⇧+click</span></li>
1039
+ <li>
1040
+ <kbd>⌘</kbd>+click element, then <kbd>⌘C</kbd> <span>or ⌘⇧+click</span>
1041
+ </li>
778
1042
  <li>Numbered pin appears on canvas</li>
779
- <li><kbd>⌘⇧M</kbd> <span>opens panel — All / Open / Resolved</span></li>
780
- <li>Click row in panel <span>jumps to that file + pin</span></li>
781
- <li>Claude reads <code>_comments/&lt;slug&gt;.json</code> on next <code>/design</code></li>
1043
+ <li>
1044
+ <kbd>⌘⇧M</kbd> <span>opens panel All / Open / Resolved</span>
1045
+ </li>
1046
+ <li>
1047
+ Click row in panel <span>jumps to that file + pin</span>
1048
+ </li>
1049
+ <li>
1050
+ Claude reads <code>_comments/&lt;slug&gt;.json</code> on next <code>/design</code>
1051
+ </li>
782
1052
  </ol>
783
1053
  </details>
784
1054
  </div>
@@ -799,7 +1069,9 @@ const MENU_NAMES = ['File', 'Edit', 'View', 'Selection', 'Tools', 'Help'];
799
1069
 
800
1070
  function ViewDropdown({ panels, onToggle, onClose }) {
801
1071
  useEffect(() => {
802
- function onKey(e) { if (e.key === 'Escape') onClose(); }
1072
+ function onKey(e) {
1073
+ if (e.key === 'Escape') onClose();
1074
+ }
803
1075
  function onDocClick(e) {
804
1076
  if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
805
1077
  }
@@ -814,32 +1086,39 @@ function ViewDropdown({ panels, onToggle, onClose }) {
814
1086
  return (
815
1087
  <div className="mb-dropdown" role="menu" aria-label="View" style={{ left: '146px' }}>
816
1088
  <div className="mb-dd-hd">Panels</div>
817
- {panels.map(p => (
1089
+ {panels.map((p) => (
818
1090
  <button
819
1091
  key={p.id}
820
1092
  type="button"
821
1093
  role="menuitem"
822
1094
  className={'mb-dd-item' + (p.checked ? ' active' : '')}
823
1095
  aria-disabled={p.disabled ? 'true' : undefined}
824
- onClick={() => { if (!p.disabled) { onToggle(p.id); onClose(); } }}
1096
+ onClick={() => {
1097
+ if (!p.disabled) {
1098
+ onToggle(p.id);
1099
+ onClose();
1100
+ }
1101
+ }}
825
1102
  >
826
1103
  <span className="lbl">
827
1104
  <span className="check">{p.checked ? '✓' : ''}</span>
828
1105
  <span>{p.label}</span>
829
1106
  </span>
830
- {p.phase
831
- ? <span className="phase-tag">{p.phase}</span>
832
- : <span className="shortcut">{p.shortcut || ''}</span>}
1107
+ {p.phase ? (
1108
+ <span className="phase-tag">{p.phase}</span>
1109
+ ) : (
1110
+ <span className="shortcut">{p.shortcut || ''}</span>
1111
+ )}
833
1112
  </button>
834
1113
  ))}
835
1114
  <div className="mb-dd-sep" />
836
1115
  <div className="mb-dd-hd">Zoom</div>
837
1116
  {[
838
- { label: 'Zoom In', shortcut: '⌘ +' },
839
- { label: 'Zoom Out', shortcut: '⌘ −' },
1117
+ { label: 'Zoom In', shortcut: '⌘ +' },
1118
+ { label: 'Zoom Out', shortcut: '⌘ −' },
840
1119
  { label: 'Fit to Screen', shortcut: '⌘ 0' },
841
1120
  { label: 'Actual Size · 100 %', shortcut: '⌥ ⌘ 0' },
842
- ].map(z => (
1121
+ ].map((z) => (
843
1122
  <button
844
1123
  key={z.label}
845
1124
  type="button"
@@ -847,7 +1126,10 @@ function ViewDropdown({ panels, onToggle, onClose }) {
847
1126
  className="mb-dd-item"
848
1127
  aria-disabled="true"
849
1128
  >
850
- <span className="lbl"><span className="check" /><span>{z.label}</span></span>
1129
+ <span className="lbl">
1130
+ <span className="check" />
1131
+ <span>{z.label}</span>
1132
+ </span>
851
1133
  <span className="phase-tag">Phase 4</span>
852
1134
  </button>
853
1135
  ))}
@@ -860,7 +1142,9 @@ function ViewDropdown({ panels, onToggle, onClose }) {
860
1142
 
861
1143
  function SelectionDropdown({ onAction, onClose }) {
862
1144
  useEffect(() => {
863
- function onKey(e) { if (e.key === 'Escape') onClose(); }
1145
+ function onKey(e) {
1146
+ if (e.key === 'Escape') onClose();
1147
+ }
864
1148
  function onDocClick(e) {
865
1149
  if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
866
1150
  }
@@ -872,20 +1156,26 @@ function SelectionDropdown({ onAction, onClose }) {
872
1156
  };
873
1157
  }, [onClose]);
874
1158
  const items = [
875
- { id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
1159
+ { id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
876
1160
  { id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⌘ ⇧ A' },
877
1161
  ];
878
1162
  return (
879
1163
  <div className="mb-dropdown" role="menu" aria-label="Selection" style={{ left: '195px' }}>
880
- {items.map(it => (
1164
+ {items.map((it) => (
881
1165
  <button
882
1166
  key={it.id}
883
1167
  type="button"
884
1168
  role="menuitem"
885
1169
  className="mb-dd-item"
886
- onClick={() => { onAction(it.id); onClose(); }}
1170
+ onClick={() => {
1171
+ onAction(it.id);
1172
+ onClose();
1173
+ }}
887
1174
  >
888
- <span className="lbl"><span className="check" /><span>{it.label}</span></span>
1175
+ <span className="lbl">
1176
+ <span className="check" />
1177
+ <span>{it.label}</span>
1178
+ </span>
889
1179
  <span className="shortcut">{it.shortcut}</span>
890
1180
  </button>
891
1181
  ))}
@@ -895,7 +1185,9 @@ function SelectionDropdown({ onAction, onClose }) {
895
1185
 
896
1186
  function ToolsDropdown({ onAction, onClose }) {
897
1187
  useEffect(() => {
898
- function onKey(e) { if (e.key === 'Escape') onClose(); }
1188
+ function onKey(e) {
1189
+ if (e.key === 'Escape') onClose();
1190
+ }
899
1191
  function onDocClick(e) {
900
1192
  if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
901
1193
  }
@@ -910,28 +1202,34 @@ function ToolsDropdown({ onAction, onClose }) {
910
1202
  // kept in sync by hand because the menubar lives in the dev-server shell
911
1203
  // (no shared bundle with the canvas iframes).
912
1204
  const tools = [
913
- { id: 'move', label: 'Move', shortcut: 'V' },
914
- { id: 'hand', label: 'Hand', shortcut: 'H' },
1205
+ { id: 'move', label: 'Move', shortcut: 'V' },
1206
+ { id: 'hand', label: 'Hand', shortcut: 'H' },
915
1207
  { id: 'comment', label: 'Comment', shortcut: 'C' },
916
- { id: 'pen', label: 'Pen', shortcut: 'B' },
917
- { id: 'rect', label: 'Rect', shortcut: 'R' },
1208
+ { id: 'pen', label: 'Pen', shortcut: 'B' },
1209
+ { id: 'rect', label: 'Rect', shortcut: 'R' },
918
1210
  { id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
919
- { id: 'sticky', label: 'Sticky', shortcut: 'N' },
920
- { id: 'arrow', label: 'Arrow', shortcut: 'A' },
921
- { id: 'text', label: 'Text', shortcut: 'T' },
922
- { id: 'eraser', label: 'Eraser', shortcut: 'E' },
1211
+ { id: 'sticky', label: 'Sticky', shortcut: 'N' },
1212
+ { id: 'arrow', label: 'Arrow', shortcut: 'A' },
1213
+ { id: 'text', label: 'Text', shortcut: 'T' },
1214
+ { id: 'eraser', label: 'Eraser', shortcut: 'E' },
923
1215
  ];
924
1216
  return (
925
1217
  <div className="mb-dropdown" role="menu" aria-label="Tools" style={{ left: '253px' }}>
926
- {tools.map(t => (
1218
+ {tools.map((t) => (
927
1219
  <button
928
1220
  key={t.id}
929
1221
  type="button"
930
1222
  role="menuitem"
931
1223
  className="mb-dd-item"
932
- onClick={() => { onAction(t.id); onClose(); }}
1224
+ onClick={() => {
1225
+ onAction(t.id);
1226
+ onClose();
1227
+ }}
933
1228
  >
934
- <span className="lbl"><span className="check" /><span>{t.label}</span></span>
1229
+ <span className="lbl">
1230
+ <span className="check" />
1231
+ <span>{t.label}</span>
1232
+ </span>
935
1233
  <span className="shortcut">{t.shortcut}</span>
936
1234
  </button>
937
1235
  ))}
@@ -939,21 +1237,62 @@ function ToolsDropdown({ onAction, onClose }) {
939
1237
  );
940
1238
  }
941
1239
 
942
- function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commentsPanelOpen, onToggleComments, onOpenSystem, sidebarOpen, onToggleSidebar, showHidden, onToggleShowHidden, onOpenHelp, annotationsVisible, onToggleAnnotations, postToActiveCanvas }) {
1240
+ function Menubar({
1241
+ activePath,
1242
+ project,
1243
+ tabsCount,
1244
+ openMenu,
1245
+ setOpenMenu,
1246
+ commentsPanelOpen,
1247
+ onToggleComments,
1248
+ onOpenSystem,
1249
+ sidebarOpen,
1250
+ onToggleSidebar,
1251
+ showHidden,
1252
+ onToggleShowHidden,
1253
+ onOpenHelp,
1254
+ annotationsVisible,
1255
+ onToggleAnnotations,
1256
+ postToActiveCanvas,
1257
+ }) {
943
1258
  const isSystem = activePath === SYSTEM_TAB;
944
- const stamp = isSystem ? 'SYSTEM' : (activePath ? 'CANVAS' : 'IDLE');
945
- const fileLabel = isSystem
946
- ? <b>design system</b>
947
- : (activePath ? <>{activePath.split('/').slice(0, -1).join('/')}/<b>{displayName(basename(activePath))}</b></> : <span style={{ color: 'var(--u-fg-3)' }}>no canvas open</span>);
1259
+ const stamp = isSystem ? 'SYSTEM' : activePath ? 'CANVAS' : 'IDLE';
1260
+ const fileLabel = isSystem ? (
1261
+ <b>design system</b>
1262
+ ) : activePath ? (
1263
+ <>
1264
+ {activePath.split('/').slice(0, -1).join('/')}/<b>{displayName(basename(activePath))}</b>
1265
+ </>
1266
+ ) : (
1267
+ <span style={{ color: 'var(--u-fg-3)' }}>no canvas open</span>
1268
+ );
948
1269
 
949
1270
  const panels = [
950
- { id: 'tree', label: 'Project Tree', shortcut: 'T', checked: sidebarOpen, disabled: false },
951
- { id: 'comments', label: 'Comments Sidebar', shortcut: '⌘ ⇧ M', checked: commentsPanelOpen, disabled: false },
952
- { id: 'hidden', label: 'Show hidden files', shortcut: 'H', checked: showHidden, disabled: false },
953
- { id: 'layers', label: 'Layers Panel', phase: 'Phase 12', disabled: true },
954
- { id: 'inspector', label: 'Inspector', phase: 'Phase 12', disabled: true },
955
- { id: 'annotate', label: 'Annotations', shortcut: '⇧ P', checked: annotationsVisible, disabled: false },
956
- { id: 'present', label: 'Presentation Mode', phase: 'Phase 6', disabled: true },
1271
+ { id: 'tree', label: 'Project Tree', shortcut: 'T', checked: sidebarOpen, disabled: false },
1272
+ {
1273
+ id: 'comments',
1274
+ label: 'Comments Sidebar',
1275
+ shortcut: ' M',
1276
+ checked: commentsPanelOpen,
1277
+ disabled: false,
1278
+ },
1279
+ {
1280
+ id: 'hidden',
1281
+ label: 'Show hidden files',
1282
+ shortcut: 'H',
1283
+ checked: showHidden,
1284
+ disabled: false,
1285
+ },
1286
+ { id: 'layers', label: 'Layers Panel', phase: 'Phase 12', disabled: true },
1287
+ { id: 'inspector', label: 'Inspector', phase: 'Phase 12', disabled: true },
1288
+ {
1289
+ id: 'annotate',
1290
+ label: 'Annotations',
1291
+ shortcut: '⇧ P',
1292
+ checked: annotationsVisible,
1293
+ disabled: false,
1294
+ },
1295
+ { id: 'present', label: 'Presentation Mode', phase: 'Phase 6', disabled: true },
957
1296
  ];
958
1297
 
959
1298
  function onMenuClick(key) {
@@ -972,7 +1311,7 @@ function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commen
972
1311
  <span>maude</span>
973
1312
  </span>
974
1313
  <nav className="mb-menus" aria-label="Application menus">
975
- {MENU_NAMES.map(name => {
1314
+ {MENU_NAMES.map((name) => {
976
1315
  const key = name.toLowerCase();
977
1316
  const hasDropdown = key === 'view' || key === 'selection' || key === 'tools';
978
1317
  const interactive = hasDropdown || key === 'help';
@@ -997,7 +1336,7 @@ function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commen
997
1336
  {openMenu === 'view' && (
998
1337
  <ViewDropdown
999
1338
  panels={panels}
1000
- onToggle={id => {
1339
+ onToggle={(id) => {
1001
1340
  if (id === 'tree') onToggleSidebar();
1002
1341
  else if (id === 'comments') onToggleComments();
1003
1342
  else if (id === 'hidden') onToggleShowHidden();
@@ -1010,7 +1349,8 @@ function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commen
1010
1349
  <SelectionDropdown
1011
1350
  onAction={(id) => {
1012
1351
  if (id === 'deselect-all') postToActiveCanvas({ dgn: 'selection-clear' });
1013
- else if (id === 'select-all-annotations') postToActiveCanvas({ dgn: 'annotation-select-all' });
1352
+ else if (id === 'select-all-annotations')
1353
+ postToActiveCanvas({ dgn: 'annotation-select-all' });
1014
1354
  }}
1015
1355
  onClose={() => setOpenMenu(null)}
1016
1356
  />
@@ -1024,13 +1364,21 @@ function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commen
1024
1364
  <div className="mb-spacer" />
1025
1365
  <div className="mb-status">
1026
1366
  <span className="cv-stamp">{stamp}</span>
1027
- <span className="file" title={activePath || ''}>{fileLabel}</span>
1367
+ <span className="file" title={activePath || ''}>
1368
+ {fileLabel}
1369
+ </span>
1028
1370
  <span className="sep" />
1029
- <span><span className="accent-dot">●</span> <b>{tabsCount}</b> ARTBOARDS</span>
1371
+ <span>
1372
+ <span className="accent-dot">●</span> <b>{tabsCount}</b> ARTBOARDS
1373
+ </span>
1030
1374
  <span className="sep" />
1031
- <span title="Pan/zoom in Phase 4">ZOOM <b>100%</b></span>
1375
+ <span title="Pan/zoom in Phase 4">
1376
+ ZOOM <b>100%</b>
1377
+ </span>
1032
1378
  <span className="sep" />
1033
- <span className="ok"><b>{project || 'MDCC'}</b></span>
1379
+ <span className="ok">
1380
+ <b>{project || 'MDCC'}</b>
1381
+ </span>
1034
1382
  </div>
1035
1383
  </header>
1036
1384
  );
@@ -1039,7 +1387,8 @@ function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commen
1039
1387
  function ThemeToggle({ theme, onToggle }) {
1040
1388
  // Show the icon of the theme you'll switch TO — clearer affordance than current state.
1041
1389
  // Sun + Moon paths are condensed Lucide-style (single-path so the existing <Icon> works).
1042
- const sun = 'M12 7a5 5 0 100 10 5 5 0 000-10z M12 3v1 M12 20v1 M21 12h-1 M4 12H3 M16.95 7.05l-.71.71 M7.05 16.95l-.71.71 M16.95 16.95l-.71-.71 M7.05 7.05l-.71-.71';
1390
+ const sun =
1391
+ 'M12 7a5 5 0 100 10 5 5 0 000-10z M12 3v1 M12 20v1 M21 12h-1 M4 12H3 M16.95 7.05l-.71.71 M7.05 16.95l-.71.71 M16.95 16.95l-.71-.71 M7.05 7.05l-.71-.71';
1043
1392
  const moon = 'M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z';
1044
1393
  const next = theme === 'dark' ? 'light' : 'dark';
1045
1394
  return (
@@ -1071,38 +1420,64 @@ function Wordmark({ project, port, version }) {
1071
1420
  );
1072
1421
  }
1073
1422
 
1074
- function Viewport({ tabs, activePath, registerIframe, systemData, onOpenFromSystem, onSelectDs, project, cfg }) {
1423
+ function Viewport({
1424
+ tabs,
1425
+ activePath,
1426
+ registerIframe,
1427
+ systemData,
1428
+ onOpenFromSystem,
1429
+ onSelectDs,
1430
+ project,
1431
+ cfg,
1432
+ }) {
1075
1433
  return (
1076
1434
  <div className="viewport">
1077
1435
  {tabs.length === 0 && (
1078
1436
  <>
1079
- <Wordmark project={project} port={typeof window !== 'undefined' ? window.location.port : ''} version={MDCC_VERSION} />
1437
+ <Wordmark
1438
+ project={project}
1439
+ port={typeof window !== 'undefined' ? window.location.port : ''}
1440
+ version={MDCC_VERSION}
1441
+ />
1080
1442
  <div className="empty-state">
1081
1443
  <div className="big">No mock open</div>
1082
1444
  <div className="small">
1083
- ← Click a <code>.tsx</code> (or legacy <code>.html</code>) file in the tree, or open the <strong>Design system</strong> view above it.
1084
- <br /><br />
1085
- Tabs work like in an editor — close with the × on each tab. <kbd>⌘R</kbd> reloads the active iframe.
1086
- <br /><br />
1087
- <strong>Element selection:</strong> hold <kbd>⌘</kbd> inside the canvas and hover for a preview, click to select. <kbd>⌘⇧</kbd>+click adds to a multi-selection. <kbd>V</kbd>/<kbd>H</kbd>/<kbd>C</kbd> swap tool; right-click opens the context menu.
1088
- <br /><br />
1089
- Active file, selection, and comments are tracked in <code>_active.json</code> + <code>_comments/</code> — Claude reads them when you run <code>/design</code>.
1445
+ ← Click a <code>.tsx</code> (or legacy <code>.html</code>) file in the tree, or open
1446
+ the <strong>Design system</strong> view above it.
1447
+ <br />
1448
+ <br />
1449
+ Tabs work like in an editor close with the × on each tab. <kbd>⌘R</kbd> reloads the
1450
+ active iframe.
1451
+ <br />
1452
+ <br />
1453
+ <strong>Element selection:</strong> hold <kbd>⌘</kbd> inside the canvas and hover for
1454
+ a preview, click to select. <kbd>⌘⇧</kbd>+click adds to a multi-selection.{' '}
1455
+ <kbd>V</kbd>/<kbd>H</kbd>/<kbd>C</kbd> swap tool; right-click opens the context menu.
1456
+ <br />
1457
+ <br />
1458
+ Active file, selection, and comments are tracked in <code>_active.json</code> +{' '}
1459
+ <code>_comments/</code> — Claude reads them when you run <code>/design</code>.
1090
1460
  </div>
1091
1461
  </div>
1092
1462
  </>
1093
1463
  )}
1094
- {tabs.map(t => {
1464
+ {tabs.map((t) => {
1095
1465
  if (t.path === SYSTEM_TAB) {
1096
1466
  return (
1097
1467
  <div key={t.path} className={'system-view' + (t.path === activePath ? ' active' : '')}>
1098
- <SystemView data={systemData} onOpen={onOpenFromSystem} cfg={cfg} onSelectDs={onSelectDs} />
1468
+ <SystemView
1469
+ data={systemData}
1470
+ onOpen={onOpenFromSystem}
1471
+ cfg={cfg}
1472
+ onSelectDs={onSelectDs}
1473
+ />
1099
1474
  </div>
1100
1475
  );
1101
1476
  }
1102
1477
  return (
1103
1478
  <iframe
1104
1479
  key={t.path}
1105
- ref={el => registerIframe(t.path, el)}
1480
+ ref={(el) => registerIframe(t.path, el)}
1106
1481
  src={canvasUrl(t.path, cfg)}
1107
1482
  className={t.path === activePath ? 'active' : ''}
1108
1483
  data-path={t.path}
@@ -1133,7 +1508,15 @@ function Viewport({ tabs, activePath, registerIframe, systemData, onOpenFromSyst
1133
1508
  // Order kinds match the typical reading flow of a tokens file. Unknown kinds
1134
1509
  // fall through to `other` so a custom token group still renders, just last.
1135
1510
  const TOKEN_GROUP_ORDER = [
1136
- 'color', 'space', 'radius', 'shadow', 'leading', 'weight', 'motion', 'font', 'other',
1511
+ 'color',
1512
+ 'space',
1513
+ 'radius',
1514
+ 'shadow',
1515
+ 'leading',
1516
+ 'weight',
1517
+ 'motion',
1518
+ 'font',
1519
+ 'other',
1137
1520
  ];
1138
1521
  const TOKEN_GROUP_LABELS = {
1139
1522
  color: 'colors',
@@ -1147,17 +1530,22 @@ const TOKEN_GROUP_LABELS = {
1147
1530
  other: 'other',
1148
1531
  };
1149
1532
 
1150
- function isSwatchKind(kind) { return kind === 'color'; }
1533
+ function isSwatchKind(kind) {
1534
+ return kind === 'color';
1535
+ }
1151
1536
 
1152
1537
  function TokenLadder({ tokens, tokenGroups, tokensPath }) {
1153
1538
  if (!tokens || tokens.length === 0) {
1154
1539
  return (
1155
1540
  <section className="sv-section sv-section-tokens">
1156
- <h2>tokens<span className="sv-h-num">0</span></h2>
1541
+ <h2>
1542
+ tokens<span className="sv-h-num">0</span>
1543
+ </h2>
1157
1544
  <div className="sv-empty">
1158
1545
  <p>
1159
- No tokens parsed from {tokensPath ? <code>{tokensPath}</code> : 'the configured tokens file'}.
1160
- Does the file exist and contain CSS custom properties (<code>--name: value;</code>)?
1546
+ No tokens parsed from{' '}
1547
+ {tokensPath ? <code>{tokensPath}</code> : 'the configured tokens file'}. Does the file
1548
+ exist and contain CSS custom properties (<code>--name: value;</code>)?
1161
1549
  </p>
1162
1550
  </div>
1163
1551
  </section>
@@ -1165,9 +1553,9 @@ function TokenLadder({ tokens, tokenGroups, tokensPath }) {
1165
1553
  }
1166
1554
 
1167
1555
  const groups = tokenGroups || {};
1168
- const kinds = Array.from(
1169
- new Set([...TOKEN_GROUP_ORDER, ...Object.keys(groups)])
1170
- ).filter((k) => groups[k]?.length);
1556
+ const kinds = Array.from(new Set([...TOKEN_GROUP_ORDER, ...Object.keys(groups)])).filter(
1557
+ (k) => groups[k]?.length
1558
+ );
1171
1559
 
1172
1560
  return (
1173
1561
  <>
@@ -1206,9 +1594,11 @@ function findLeadingFor(typeToken, leadingTokens) {
1206
1594
  const m = typeToken.name.match(/^--(?:type|fs|text)-(.+)$/);
1207
1595
  if (!m) return null;
1208
1596
  const suffix = m[1];
1209
- return (leadingTokens || []).find((t) =>
1210
- /^--(?:lh|leading|line-height)-/.test(t.name) && t.name.endsWith('-' + suffix)
1211
- )?.value ?? null;
1597
+ return (
1598
+ (leadingTokens || []).find(
1599
+ (t) => /^--(?:lh|leading|line-height)-/.test(t.name) && t.name.endsWith('-' + suffix)
1600
+ )?.value ?? null
1601
+ );
1212
1602
  }
1213
1603
 
1214
1604
  // Best-effort sample font: prefer body / sans / display tokens, fall back to
@@ -1257,7 +1647,11 @@ function TypeLadder({ tokenGroups }) {
1257
1647
 
1258
1648
  function SystemView({ data, onOpen, cfg, onSelectDs }) {
1259
1649
  if (!data) {
1260
- return <div className="sv-empty"><p>Loading design system…</p></div>;
1650
+ return (
1651
+ <div className="sv-empty">
1652
+ <p>Loading design system…</p>
1653
+ </div>
1654
+ );
1261
1655
  }
1262
1656
  const {
1263
1657
  previewGallery,
@@ -1269,7 +1663,8 @@ function SystemView({ data, onOpen, cfg, onSelectDs }) {
1269
1663
  ds,
1270
1664
  availableDesignSystems,
1271
1665
  } = data;
1272
- const empty = (!previewGallery || !previewGallery.length) && (!uiKitsGallery || !uiKitsGallery.length);
1666
+ const empty =
1667
+ (!previewGallery || !previewGallery.length) && (!uiKitsGallery || !uiKitsGallery.length);
1273
1668
  const hasPicker = Array.isArray(availableDesignSystems) && availableDesignSystems.length > 1;
1274
1669
  const selectedName = ds?.name ?? availableDesignSystems?.[0]?.name ?? '';
1275
1670
 
@@ -1281,34 +1676,42 @@ function SystemView({ data, onOpen, cfg, onSelectDs }) {
1281
1676
  {hasPicker ? (
1282
1677
  <label className="sv-ds-picker">
1283
1678
  <span className="sv-ds-picker-label">DS</span>
1284
- <select
1285
- value={selectedName}
1286
- onChange={(e) => onSelectDs && onSelectDs(e.target.value)}
1287
- >
1679
+ <select value={selectedName} onChange={(e) => onSelectDs && onSelectDs(e.target.value)}>
1288
1680
  {availableDesignSystems.map((d) => (
1289
- <option key={d.name} value={d.name}>{d.name}</option>
1681
+ <option key={d.name} value={d.name}>
1682
+ {d.name}
1683
+ </option>
1290
1684
  ))}
1291
1685
  </select>
1292
1686
  </label>
1293
1687
  ) : null}
1294
- <span className="sv-loc"><code>{systemDir}</code></span>
1688
+ <span className="sv-loc">
1689
+ <code>{systemDir}</code>
1690
+ </span>
1295
1691
  </header>
1296
1692
 
1297
- {ds?.description ? (
1298
- <p className="sv-ds-description">{ds.description}</p>
1299
- ) : null}
1693
+ {ds?.description ? <p className="sv-ds-description">{ds.description}</p> : null}
1300
1694
 
1301
1695
  <TokenLadder tokens={tokens} tokenGroups={tokenGroups} tokensPath={tokensPath} />
1302
1696
  <TypeLadder tokenGroups={tokenGroups} />
1303
1697
 
1304
1698
  {empty ? (
1305
1699
  <div className="sv-empty">
1306
- <p>No <code>preview/</code> or <code>ui_kits/</code> folders found under <code>{systemDir}</code>.</p>
1700
+ <p>
1701
+ No <code>preview/</code> or <code>ui_kits/</code> folders found under{' '}
1702
+ <code>{systemDir}</code>.
1703
+ </p>
1307
1704
  </div>
1308
1705
  ) : (
1309
1706
  <>
1310
- <Gallery title="preview" items={previewGallery} onOpen={onOpen} kind="preview" cfg={cfg} />
1311
- <Gallery title="ui kits" items={uiKitsGallery} onOpen={onOpen} kind="ui_kits" cfg={cfg} />
1707
+ <Gallery
1708
+ title="preview"
1709
+ items={previewGallery}
1710
+ onOpen={onOpen}
1711
+ kind="preview"
1712
+ cfg={cfg}
1713
+ />
1714
+ <Gallery title="ui kits" items={uiKitsGallery} onOpen={onOpen} kind="ui_kits" cfg={cfg} />
1312
1715
  </>
1313
1716
  )}
1314
1717
  </div>
@@ -1319,12 +1722,19 @@ function Gallery({ title, items, onOpen, kind, cfg }) {
1319
1722
  if (!items || items.length === 0) return null;
1320
1723
  return (
1321
1724
  <section className="sv-section">
1322
- <h2>{title} <span className="sv-count">{items.length}</span></h2>
1725
+ <h2>
1726
+ {title} <span className="sv-count">{items.length}</span>
1727
+ </h2>
1323
1728
  <div className={'sv-previews sv-previews-' + kind}>
1324
- {items.map(p => (
1729
+ {items.map((p) => (
1325
1730
  <article key={p.path} className="sv-preview-card" onClick={() => onOpen(p.path)}>
1326
1731
  <div className="sv-preview-frame">
1327
- <iframe src={canvasUrl(p.path, cfg, { thumbnail: true })} title={p.label} scrolling="no" {...(cfg?.canvasOrigin ? { sandbox: 'allow-scripts allow-same-origin' } : {})} />
1732
+ <iframe
1733
+ src={canvasUrl(p.path, cfg, { thumbnail: true })}
1734
+ title={p.label}
1735
+ scrolling="no"
1736
+ {...(cfg?.canvasOrigin ? { sandbox: 'allow-scripts allow-same-origin' } : {})}
1737
+ />
1328
1738
  </div>
1329
1739
  <div className="sv-preview-foot">
1330
1740
  <strong>{p.label}</strong>
@@ -1345,7 +1755,7 @@ function CommentBar({ activePath, comments }) {
1345
1755
  // bubbles + thread popover. BottomBar shrinks to a live open-count summary
1346
1756
  // so the shell still surfaces total review activity at a glance.
1347
1757
  if (!activePath) return null;
1348
- const openComments = (comments || []).filter(c => c.status !== 'resolved');
1758
+ const openComments = (comments || []).filter((c) => c.status !== 'resolved');
1349
1759
  if (openComments.length === 0) return null;
1350
1760
  return (
1351
1761
  <div className="comment-bar">
@@ -1366,26 +1776,53 @@ function StatusBarSlot({ label, children, className = '' }) {
1366
1776
  );
1367
1777
  }
1368
1778
 
1369
- function StatusBar({ activePath, selected, wsConnected, openCount, theme, onToggleTheme, onClearSelected, syncStatus }) {
1779
+ function StatusBar({
1780
+ activePath,
1781
+ selected,
1782
+ wsConnected,
1783
+ openCount,
1784
+ theme,
1785
+ onToggleTheme,
1786
+ onClearSelected,
1787
+ syncStatus,
1788
+ }) {
1370
1789
  const isSystem = activePath === SYSTEM_TAB;
1371
- const text = selected && selected.selector
1372
- ? selected.selector + (selected.text ? ` — "${selected.text.slice(0, 60)}"` : '')
1373
- : '';
1374
- const title = selected && selected.dom_path ? selected.dom_path.join(' > ') : (selected ? selected.selector : '');
1790
+ const text =
1791
+ selected && selected.selector
1792
+ ? selected.selector + (selected.text ? ` — "${selected.text.slice(0, 60)}"` : '')
1793
+ : '';
1794
+ const title =
1795
+ selected && selected.dom_path
1796
+ ? selected.dom_path.join(' > ')
1797
+ : selected
1798
+ ? selected.selector
1799
+ : '';
1375
1800
  return (
1376
1801
  <div className="statusbar" role="contentinfo">
1377
1802
  <StatusBarSlot label="Active file" className="sb-active">
1378
1803
  <span className="sb-key">active</span>
1379
1804
  <span className="sb-file" title={activePath || ''}>
1380
- {isSystem ? '▦ design system' : (activePath || '—')}
1805
+ {isSystem ? '▦ design system' : activePath || '—'}
1381
1806
  </span>
1382
1807
  </StatusBarSlot>
1383
1808
 
1384
1809
  {selected && selected.selector && !isSystem && (
1385
1810
  <StatusBarSlot label="Selected element" className="sb-selected">
1386
- <span className="sb-dot" aria-hidden="true">●</span>
1387
- <span className="sb-sel-text" title={title}>{text}</span>
1388
- <button type="button" className="sb-clear-sel" onClick={onClearSelected} title="Clear (Esc inside iframe)" aria-label="Clear selection">×</button>
1811
+ <span className="sb-dot" aria-hidden="true">
1812
+
1813
+ </span>
1814
+ <span className="sb-sel-text" title={title}>
1815
+ {text}
1816
+ </span>
1817
+ <button
1818
+ type="button"
1819
+ className="sb-clear-sel"
1820
+ onClick={onClearSelected}
1821
+ title="Clear (Esc inside iframe)"
1822
+ aria-label="Clear selection"
1823
+ >
1824
+ ×
1825
+ </button>
1389
1826
  </StatusBarSlot>
1390
1827
  )}
1391
1828
 
@@ -1405,7 +1842,10 @@ function StatusBar({ activePath, selected, wsConnected, openCount, theme, onTogg
1405
1842
  {syncStatus?.notSyncable && (
1406
1843
  <StatusBarSlot label="Hub sync" className="sb-sync">
1407
1844
  <span className="sb-sync-dot" aria-hidden="true" />
1408
- <span className="sb-key" title={syncStatus.reason || 'Linked to a hub, but no canvases are syncable.'}>
1845
+ <span
1846
+ className="sb-key"
1847
+ title={syncStatus.reason || 'Linked to a hub, but no canvases are syncable.'}
1848
+ >
1409
1849
  0 syncable{syncStatus.tsxCount > 0 ? ` · ${syncStatus.tsxCount} tsx` : ''}
1410
1850
  </span>
1411
1851
  </StatusBarSlot>
@@ -1422,26 +1862,36 @@ function StatusBar({ activePath, selected, wsConnected, openCount, theme, onTogg
1422
1862
 
1423
1863
  // ---------- Right sidebar — Comments panel ----------
1424
1864
 
1425
- function CommentsPanel({ commentsByFile, filter, setFilter, activePath, focusedId, onJump, onResolve, onReopen, onDelete }) {
1865
+ function CommentsPanel({
1866
+ commentsByFile,
1867
+ filter,
1868
+ setFilter,
1869
+ activePath,
1870
+ focusedId,
1871
+ onJump,
1872
+ onResolve,
1873
+ onReopen,
1874
+ onDelete,
1875
+ }) {
1426
1876
  const counts = totalCounts(commentsByFile);
1427
1877
  // Build groups: [{ file, comments: filtered }]
1428
1878
  const files = Object.keys(commentsByFile || {}).sort();
1429
1879
  const groups = [];
1430
1880
  for (const f of files) {
1431
1881
  const all = commentsByFile[f] || [];
1432
- const filtered = all.filter(c => {
1882
+ const filtered = all.filter((c) => {
1433
1883
  if (filter === 'open') return c.status !== 'resolved';
1434
1884
  if (filter === 'resolved') return c.status === 'resolved';
1435
1885
  return true;
1436
1886
  });
1437
1887
  if (filtered.length === 0) continue;
1438
1888
  // Number is fixed by all-list order so it matches pin numbers (which are based on position in the array of selector-having comments)
1439
- const numberedAll = all.filter(c => c.selector);
1889
+ const numberedAll = all.filter((c) => c.selector);
1440
1890
  groups.push({
1441
1891
  file: f,
1442
- comments: filtered.map(c => ({
1892
+ comments: filtered.map((c) => ({
1443
1893
  ...c,
1444
- n: numberedAll.findIndex(x => x.id === c.id) + 1,
1894
+ n: numberedAll.findIndex((x) => x.id === c.id) + 1,
1445
1895
  })),
1446
1896
  });
1447
1897
  }
@@ -1456,61 +1906,101 @@ function CommentsPanel({ commentsByFile, filter, setFilter, activePath, focusedI
1456
1906
  <div className="rsidebar-filters" role="tablist">
1457
1907
  <button
1458
1908
  className={'rsidebar-filter' + (filter === 'all' ? ' active' : '')}
1459
- role="tab" aria-selected={filter === 'all'}
1909
+ role="tab"
1910
+ aria-selected={filter === 'all'}
1460
1911
  onClick={() => setFilter('all')}
1461
- >All <span className="fc">{counts.all}</span></button>
1912
+ >
1913
+ All <span className="fc">{counts.all}</span>
1914
+ </button>
1462
1915
  <button
1463
1916
  className={'rsidebar-filter' + (filter === 'open' ? ' active' : '')}
1464
- role="tab" aria-selected={filter === 'open'}
1917
+ role="tab"
1918
+ aria-selected={filter === 'open'}
1465
1919
  onClick={() => setFilter('open')}
1466
- >Open <span className="fc">{counts.open}</span></button>
1920
+ >
1921
+ Open <span className="fc">{counts.open}</span>
1922
+ </button>
1467
1923
  <button
1468
1924
  className={'rsidebar-filter' + (filter === 'resolved' ? ' active' : '')}
1469
- role="tab" aria-selected={filter === 'resolved'}
1925
+ role="tab"
1926
+ aria-selected={filter === 'resolved'}
1470
1927
  onClick={() => setFilter('resolved')}
1471
- >Resolved <span className="fc">{counts.resolved}</span></button>
1928
+ >
1929
+ Resolved <span className="fc">{counts.resolved}</span>
1930
+ </button>
1472
1931
  </div>
1473
1932
  </div>
1474
1933
  <div className="rsidebar-body">
1475
1934
  {groups.length === 0 ? (
1476
1935
  <div className="rsidebar-empty">
1477
1936
  <p>No comments {filter !== 'all' ? `with status “${filter}”` : 'yet'}.</p>
1478
- <p style={{ marginTop: 12 }}>Open a canvas, hold <kbd>⌘</kbd> and click an element, then press <kbd>C</kbd> — or hold <kbd>⌘⇧</kbd> and click directly.</p>
1937
+ <p style={{ marginTop: 12 }}>
1938
+ Open a canvas, hold <kbd>⌘</kbd> and click an element, then press <kbd>C</kbd> — or
1939
+ hold <kbd>⌘⇧</kbd> and click directly.
1940
+ </p>
1479
1941
  </div>
1480
- ) : groups.map(g => (
1481
- <div key={g.file} className="rs-group">
1482
- <button
1483
- className="rs-group-h"
1484
- onClick={() => onJump(g.file, null)}
1485
- title={g.file}
1486
- >
1487
- <span className="rs-group-name">{displayName(basename(g.file))}</span>
1488
- <span className="rs-group-count">{g.comments.length}</span>
1489
- </button>
1490
- {g.comments.map(c => (
1491
- <div
1492
- key={c.id}
1493
- className={'rs-comment' + (c.status === 'resolved' ? ' resolved' : '') + (c.id === focusedId ? ' active-pin' : '')}
1494
- onClick={() => onJump(g.file, c.id)}
1495
- >
1496
- <div className="rs-comment-head">
1497
- <span className="rs-num">{c.n || '·'}</span>
1498
- <span className="rs-time">{timeAgo(c.created)}</span>
1499
- </div>
1500
- <div className="rs-comment-text">{c.text}</div>
1501
- <div className="rs-comment-foot">
1502
- <code title={(c.dom_path || []).join(' > ')}>{c.selector || '—'}</code>
1503
- <div className="rs-comment-actions">
1504
- {c.status === 'resolved'
1505
- ? <button className="rs-act" onClick={e => { e.stopPropagation(); onReopen(c.id); }}>↺</button>
1506
- : <button className="rs-act" onClick={e => { e.stopPropagation(); onResolve(c.id); }}>✓</button>}
1507
- <button className="rs-act danger" onClick={e => { e.stopPropagation(); onDelete(c.id); }}>×</button>
1942
+ ) : (
1943
+ groups.map((g) => (
1944
+ <div key={g.file} className="rs-group">
1945
+ <button className="rs-group-h" onClick={() => onJump(g.file, null)} title={g.file}>
1946
+ <span className="rs-group-name">{displayName(basename(g.file))}</span>
1947
+ <span className="rs-group-count">{g.comments.length}</span>
1948
+ </button>
1949
+ {g.comments.map((c) => (
1950
+ <div
1951
+ key={c.id}
1952
+ className={
1953
+ 'rs-comment' +
1954
+ (c.status === 'resolved' ? ' resolved' : '') +
1955
+ (c.id === focusedId ? ' active-pin' : '')
1956
+ }
1957
+ onClick={() => onJump(g.file, c.id)}
1958
+ >
1959
+ <div className="rs-comment-head">
1960
+ <span className="rs-num">{c.n || '·'}</span>
1961
+ <span className="rs-time">{timeAgo(c.created)}</span>
1962
+ </div>
1963
+ <div className="rs-comment-text">{c.text}</div>
1964
+ <div className="rs-comment-foot">
1965
+ <code title={(c.dom_path || []).join(' > ')}>{c.selector || '—'}</code>
1966
+ <div className="rs-comment-actions">
1967
+ {c.status === 'resolved' ? (
1968
+ <button
1969
+ className="rs-act"
1970
+ onClick={(e) => {
1971
+ e.stopPropagation();
1972
+ onReopen(c.id);
1973
+ }}
1974
+ >
1975
+
1976
+ </button>
1977
+ ) : (
1978
+ <button
1979
+ className="rs-act"
1980
+ onClick={(e) => {
1981
+ e.stopPropagation();
1982
+ onResolve(c.id);
1983
+ }}
1984
+ >
1985
+
1986
+ </button>
1987
+ )}
1988
+ <button
1989
+ className="rs-act danger"
1990
+ onClick={(e) => {
1991
+ e.stopPropagation();
1992
+ onDelete(c.id);
1993
+ }}
1994
+ >
1995
+ ×
1996
+ </button>
1997
+ </div>
1508
1998
  </div>
1509
1999
  </div>
1510
- </div>
1511
- ))}
1512
- </div>
1513
- ))}
2000
+ ))}
2001
+ </div>
2002
+ ))
2003
+ )}
1514
2004
  </div>
1515
2005
  </aside>
1516
2006
  );
@@ -1631,7 +2121,9 @@ function App() {
1631
2121
  });
1632
2122
  })
1633
2123
  .catch(() => {});
1634
- return () => { cancelled = true; };
2124
+ return () => {
2125
+ cancelled = true;
2126
+ };
1635
2127
  }, []);
1636
2128
  // Backfill the sync banner on mount from /_sync-status. The 'sync:status' WS
1637
2129
  // broadcast is one-shot for the zero-syncable case (DDR-060 / 9.1-D), so a
@@ -1646,14 +2138,16 @@ function App() {
1646
2138
  setSyncStatus(data);
1647
2139
  })
1648
2140
  .catch(() => {});
1649
- return () => { cancelled = true; };
2141
+ return () => {
2142
+ cancelled = true;
2143
+ };
1650
2144
  }, []);
1651
- const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
2145
+ const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
1652
2146
  // Phase 6 — the in-iframe composer owns drafting; the shell no longer holds
1653
2147
  // a `draft` state. Mutations route through postMessage → WS instead.
1654
2148
  const [focusedCommentId, setFocusedCommentId] = useState(null);
1655
2149
  const [commentsPanelOpen, setCommentsPanelOpen] = useState(false);
1656
- const [commentsFilter, setCommentsFilter] = useState('open'); // 'all' | 'open' | 'resolved'
2150
+ const [commentsFilter, setCommentsFilter] = useState('open'); // 'all' | 'open' | 'resolved'
1657
2151
  const [theme, setTheme] = useState(readInitialTheme);
1658
2152
  const [openMenu, setOpenMenu] = useState(null);
1659
2153
  const [sidebarOpen, setSidebarOpen] = useState(() => readBoolStore(SIDEBAR_STORE, true));
@@ -1668,18 +2162,25 @@ function App() {
1668
2162
  // The canvas-shell listens for these `dgn:*` messages and dispatches into the
1669
2163
  // matching local provider (annotations visibility / both selection stores /
1670
2164
  // tool mode). Mirrors the existing `force-clear` / `select-clear` channel.
1671
- const postToActiveCanvas = useCallback((payload) => {
1672
- const el = activePath ? iframesRef.current.get(activePath) : null;
1673
- if (!el || !el.contentWindow) return;
1674
- try { el.contentWindow.postMessage(payload, '*'); } catch {}
1675
- }, [activePath]);
2165
+ const postToActiveCanvas = useCallback(
2166
+ (payload) => {
2167
+ const el = activePath ? iframesRef.current.get(activePath) : null;
2168
+ if (!el || !el.contentWindow) return;
2169
+ try {
2170
+ el.contentWindow.postMessage(payload, '*');
2171
+ } catch {}
2172
+ },
2173
+ [activePath]
2174
+ );
1676
2175
 
1677
2176
  const toggleAnnotations = useCallback(() => {
1678
2177
  setAnnotationsVisible((v) => {
1679
2178
  const next = !v;
1680
2179
  const el = activePath ? iframesRef.current.get(activePath) : null;
1681
2180
  if (el && el.contentWindow) {
1682
- try { el.contentWindow.postMessage({ dgn: 'view-annotations', visible: next }, '*'); } catch {}
2181
+ try {
2182
+ el.contentWindow.postMessage({ dgn: 'view-annotations', visible: next }, '*');
2183
+ } catch {}
1683
2184
  }
1684
2185
  return next;
1685
2186
  });
@@ -1700,23 +2201,31 @@ function App() {
1700
2201
  // mount run iframesRef is empty (no canvas open yet) — a freshly-loaded
1701
2202
  // iframe instead gets the current theme from the `dgn:'loaded'` handler.
1702
2203
  for (const el of iframesRef.current.values()) {
1703
- try { el.contentWindow.postMessage({ dgn: 'theme', theme }, '*'); } catch {}
2204
+ try {
2205
+ el.contentWindow.postMessage({ dgn: 'theme', theme }, '*');
2206
+ } catch {}
1704
2207
  }
1705
2208
  }, [theme]);
1706
2209
 
1707
2210
  // Persist sidebar / hidden-files / DS-body toggles. Mirror theme pattern.
1708
2211
  useEffect(() => {
1709
- try { localStorage.setItem(SIDEBAR_STORE, sidebarOpen ? '1' : '0'); } catch {}
2212
+ try {
2213
+ localStorage.setItem(SIDEBAR_STORE, sidebarOpen ? '1' : '0');
2214
+ } catch {}
1710
2215
  }, [sidebarOpen]);
1711
2216
  useEffect(() => {
1712
- try { localStorage.setItem(SHOW_HIDDEN_STORE, showHidden ? '1' : '0'); } catch {}
2217
+ try {
2218
+ localStorage.setItem(SHOW_HIDDEN_STORE, showHidden ? '1' : '0');
2219
+ } catch {}
1713
2220
  }, [showHidden]);
1714
2221
  useEffect(() => {
1715
- try { localStorage.setItem(SECTIONS_STORE, JSON.stringify(sectionsExpanded)); } catch {}
2222
+ try {
2223
+ localStorage.setItem(SECTIONS_STORE, JSON.stringify(sectionsExpanded));
2224
+ } catch {}
1716
2225
  }, [sectionsExpanded]);
1717
2226
 
1718
2227
  const toggleSection = useCallback((label, defaultOpen) => {
1719
- setSectionsExpanded(prev => {
2228
+ setSectionsExpanded((prev) => {
1720
2229
  const cur = prev[label];
1721
2230
  const isOpen = cur === undefined ? defaultOpen : cur;
1722
2231
  return { ...prev, [label]: !isOpen };
@@ -1724,7 +2233,7 @@ function App() {
1724
2233
  }, []);
1725
2234
 
1726
2235
  const toggleTheme = useCallback(() => {
1727
- setTheme(t => (t === 'dark' ? 'light' : 'dark'));
2236
+ setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
1728
2237
  }, []);
1729
2238
 
1730
2239
  // ----- Tree -----
@@ -1733,7 +2242,7 @@ function App() {
1733
2242
  const r = await fetch('/_index-data');
1734
2243
  const data = await r.json();
1735
2244
  setProject(data.project || 'Design');
1736
- const built = data.groups.map(g => ({
2245
+ const built = data.groups.map((g) => ({
1737
2246
  ...g,
1738
2247
  tree: buildTree(g.paths, g.stripPrefix),
1739
2248
  }));
@@ -1743,7 +2252,9 @@ function App() {
1743
2252
  }
1744
2253
  }, []);
1745
2254
 
1746
- useEffect(() => { loadTree(); }, [loadTree]);
2255
+ useEffect(() => {
2256
+ loadTree();
2257
+ }, [loadTree]);
1747
2258
 
1748
2259
  // ----- System data (lazy) -----
1749
2260
  // `dsName` scopes to a single design-system entry (DDR-048). The initial
@@ -1786,7 +2297,9 @@ function App() {
1786
2297
  }
1787
2298
  }, []);
1788
2299
 
1789
- useEffect(() => { loadAllComments(); }, [loadAllComments]);
2300
+ useEffect(() => {
2301
+ loadAllComments();
2302
+ }, [loadAllComments]);
1790
2303
 
1791
2304
  // ----- WebSocket -----
1792
2305
  useEffect(() => {
@@ -1800,7 +2313,7 @@ function App() {
1800
2313
  setTimeout(connect, 1000);
1801
2314
  });
1802
2315
  ws.addEventListener('error', () => {});
1803
- ws.addEventListener('message', e => {
2316
+ ws.addEventListener('message', (e) => {
1804
2317
  try {
1805
2318
  const m = JSON.parse(e.data);
1806
2319
  if (m.type === 'snapshot' && m.state) {
@@ -1808,13 +2321,18 @@ function App() {
1808
2321
  } else if (m.type === 'selected') {
1809
2322
  setSelected(m.selected);
1810
2323
  } else if (m.type === 'comments' && typeof m.file === 'string') {
1811
- setCommentsByFile(prev => ({ ...prev, [m.file]: m.comments || [] }));
2324
+ setCommentsByFile((prev) => ({ ...prev, [m.file]: m.comments || [] }));
1812
2325
  } else if (m.type === 'ai-activity' && typeof m.file === 'string') {
1813
2326
  // Phase 8 Task 4 — relay to every open iframe; each canvas's
1814
2327
  // AiBanner filters by its own file path. Lightweight broadcast
1815
2328
  // (one envelope per change, not per iframe count).
1816
2329
  for (const el of iframesRef.current.values()) {
1817
- try { el.contentWindow.postMessage({ dgn: 'ai-activity', file: m.file, entry: m.entry }, '*'); } catch {}
2330
+ try {
2331
+ el.contentWindow.postMessage(
2332
+ { dgn: 'ai-activity', file: m.file, entry: m.entry },
2333
+ '*'
2334
+ );
2335
+ } catch {}
1818
2336
  }
1819
2337
  } else if (m.type === 'sync:status' && m.payload) {
1820
2338
  // Phase 9 Task 8 — hub connection state for the offline banner.
@@ -1827,7 +2345,9 @@ function App() {
1827
2345
  // Also relay to iframes so canvas-level "Reload?" UI (if any)
1828
2346
  // can react. Outer banner is the primary prompt.
1829
2347
  for (const el of iframesRef.current.values()) {
1830
- try { el.contentWindow.postMessage({ dgn: 'git-lifecycle', payload: m.payload }, '*'); } catch {}
2348
+ try {
2349
+ el.contentWindow.postMessage({ dgn: 'git-lifecycle', payload: m.payload }, '*');
2350
+ } catch {}
1831
2351
  }
1832
2352
  }
1833
2353
  } catch {}
@@ -1839,7 +2359,9 @@ function App() {
1839
2359
 
1840
2360
  function wsSend(obj) {
1841
2361
  const ws = wsRef.current;
1842
- try { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); } catch {}
2362
+ try {
2363
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj));
2364
+ } catch {}
1843
2365
  }
1844
2366
 
1845
2367
  // ----- Tab management (single-canvas) -----
@@ -1848,7 +2370,7 @@ function App() {
1848
2370
  // (iframesRef, comments push, WS `tabs` message) doesn't need refactoring.
1849
2371
  // ARTBOARDS slot in the menubar reads `tabs.length` and reports 0 or 1.
1850
2372
  const openTab = useCallback((path) => {
1851
- setTabs(prev => {
2373
+ setTabs((prev) => {
1852
2374
  // Drop the previously-open iframe so we don't leak DOM nodes.
1853
2375
  for (const t of prev) if (t.path !== path) iframesRef.current.delete(t.path);
1854
2376
  return [{ path }];
@@ -1857,18 +2379,21 @@ function App() {
1857
2379
  setFocusedCommentId(null);
1858
2380
  }, []);
1859
2381
 
1860
- const openSystem = useCallback((dsName) => {
1861
- // DsFolderRow passes the clicked DS name → scope the System view to it so
1862
- // each folder shows its own tokens + previews. The no-arg callers (menubar,
1863
- // keyboard reopen) only load default data on first open.
1864
- const ds = typeof dsName === 'string' ? dsName : undefined;
1865
- if (ds) loadSystemData(ds);
1866
- else if (!systemData) loadSystemData();
1867
- openTab(SYSTEM_TAB);
1868
- }, [systemData, loadSystemData, openTab]);
2382
+ const openSystem = useCallback(
2383
+ (dsName) => {
2384
+ // DsFolderRow passes the clicked DS name scope the System view to it so
2385
+ // each folder shows its own tokens + previews. The no-arg callers (menubar,
2386
+ // keyboard reopen) only load default data on first open.
2387
+ const ds = typeof dsName === 'string' ? dsName : undefined;
2388
+ if (ds) loadSystemData(ds);
2389
+ else if (!systemData) loadSystemData();
2390
+ openTab(SYSTEM_TAB);
2391
+ },
2392
+ [systemData, loadSystemData, openTab]
2393
+ );
1869
2394
 
1870
2395
  useEffect(() => {
1871
- wsSend({ type: 'tabs', tabs: tabs.map(t => t.path).filter(p => p !== SYSTEM_TAB) });
2396
+ wsSend({ type: 'tabs', tabs: tabs.map((t) => t.path).filter((p) => p !== SYSTEM_TAB) });
1872
2397
  }, [tabs]);
1873
2398
 
1874
2399
  useEffect(() => {
@@ -1877,19 +2402,22 @@ function App() {
1877
2402
  else wsSend({ type: 'active', file: '' });
1878
2403
  }, [activePath]);
1879
2404
 
1880
- const closeTab = useCallback((path) => {
1881
- setTabs(prev => {
1882
- const idx = prev.findIndex(t => t.path === path);
1883
- if (idx < 0) return prev;
1884
- const next = prev.filter(t => t.path !== path);
1885
- if (path === activePath) {
1886
- if (next.length === 0) setActivePath(null);
1887
- else setActivePath(next[Math.max(0, idx - 1)].path);
1888
- }
1889
- return next;
1890
- });
1891
- iframesRef.current.delete(path);
1892
- }, [activePath]);
2405
+ const closeTab = useCallback(
2406
+ (path) => {
2407
+ setTabs((prev) => {
2408
+ const idx = prev.findIndex((t) => t.path === path);
2409
+ if (idx < 0) return prev;
2410
+ const next = prev.filter((t) => t.path !== path);
2411
+ if (path === activePath) {
2412
+ if (next.length === 0) setActivePath(null);
2413
+ else setActivePath(next[Math.max(0, idx - 1)].path);
2414
+ }
2415
+ return next;
2416
+ });
2417
+ iframesRef.current.delete(path);
2418
+ },
2419
+ [activePath]
2420
+ );
1893
2421
 
1894
2422
  const reloadActive = useCallback(() => {
1895
2423
  if (!activePath || activePath === SYSTEM_TAB) {
@@ -1908,7 +2436,9 @@ function App() {
1908
2436
  if (activePath && activePath !== SYSTEM_TAB) {
1909
2437
  const el = iframesRef.current.get(activePath);
1910
2438
  if (el && el.contentWindow) {
1911
- try { el.contentWindow.postMessage({ dgn: 'force-clear' }, '*'); } catch {}
2439
+ try {
2440
+ el.contentWindow.postMessage({ dgn: 'force-clear' }, '*');
2441
+ } catch {}
1912
2442
  }
1913
2443
  }
1914
2444
  }, [activePath]);
@@ -1919,7 +2449,9 @@ function App() {
1919
2449
  const el = iframesRef.current.get(activePath);
1920
2450
  if (!el || !el.contentWindow) return;
1921
2451
  const list = commentsByFile[activePath] || [];
1922
- try { el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*'); } catch {}
2452
+ try {
2453
+ el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*');
2454
+ } catch {}
1923
2455
  }, [activePath, commentsByFile]);
1924
2456
 
1925
2457
  // ----- Inbound messages from iframes -----
@@ -1935,6 +2467,29 @@ function App() {
1935
2467
  if (e.origin !== expectedOrigin) return;
1936
2468
  const m = e.data;
1937
2469
  if (!m || typeof m !== 'object' || !m.dgn) return;
2470
+ if (m.dgn === 'tool-cursor') {
2471
+ // Phase 24 — show the active canvas tool's cursor across the WHOLE app
2472
+ // shell (sidebar, top bar, everything) so the custom cursor is visible
2473
+ // everywhere in maude, not just inside the canvas iframe. The canvas
2474
+ // sends only a tool TOKEN; we resolve it to a cursor string from our own
2475
+ // trusted map (resolveToolCursor) and apply THAT — never a raw
2476
+ // canvas-supplied value. A malicious synced canvas (DDR-054) can thus
2477
+ // only pick a known, always-visible glyph; it cannot inject an
2478
+ // invisible/displaced SVG cursor as a clickjacking aid over the un-CSP'd
2479
+ // shell (phase-24 ethical-hacker Finding 2; DDR-067).
2480
+ const cursor = resolveToolCursor(m.tool);
2481
+ if (cursor) {
2482
+ document.body.style.cursor = cursor;
2483
+ let el = document.getElementById('dc-app-cursor');
2484
+ if (!el) {
2485
+ el = document.createElement('style');
2486
+ el.id = 'dc-app-cursor';
2487
+ document.head.appendChild(el);
2488
+ }
2489
+ el.textContent = `* { cursor: ${cursor} !important; }`;
2490
+ }
2491
+ return;
2492
+ }
1938
2493
  if (m.dgn === 'select' && m.selection) {
1939
2494
  wsSend({ type: 'select', selection: m.selection });
1940
2495
  setSelected(m.selection);
@@ -1995,7 +2550,7 @@ function App() {
1995
2550
  wsSend({ type: 'comments-patch', id: m.id, patch: m.patch });
1996
2551
  } else if (m.dgn === 'comment-delete' && m.id) {
1997
2552
  wsSend({ type: 'comments-delete', id: m.id });
1998
- setFocusedCommentId(prev => (prev === m.id ? null : prev));
2553
+ setFocusedCommentId((prev) => (prev === m.id ? null : prev));
1999
2554
  } else if (m.dgn === 'comment-click' && m.id) {
2000
2555
  setFocusedCommentId(m.id);
2001
2556
  } else if (m.dgn === 'loaded' && m.file) {
@@ -2003,17 +2558,87 @@ function App() {
2003
2558
  const list = commentsByFile[m.file] || [];
2004
2559
  const el = [...iframesRef.current.entries()].find(([k]) => k === m.file)?.[1];
2005
2560
  if (el && el.contentWindow) {
2006
- try { el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*'); } catch {}
2561
+ try {
2562
+ el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*');
2563
+ } catch {}
2007
2564
  // System-review D9 — seed the just-loaded canvas with the current
2008
2565
  // chrome theme so a canvas opened AFTER a theme toggle starts
2009
2566
  // correct (no flash from the dark default).
2010
- try { el.contentWindow.postMessage({ dgn: 'theme', theme }, '*'); } catch {}
2011
- if (focusedCommentId && list.some(c => c.id === focusedCommentId)) {
2012
- try { el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*'); } catch {}
2567
+ try {
2568
+ el.contentWindow.postMessage({ dgn: 'theme', theme }, '*');
2569
+ } catch {}
2570
+ if (focusedCommentId && list.some((c) => c.id === focusedCommentId)) {
2571
+ try {
2572
+ el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*');
2573
+ } catch {}
2013
2574
  }
2014
2575
  }
2576
+ } else if (m.dgn === 'export-request' && m.id && m.payload) {
2577
+ // The export dialog renders inside the canvas iframe (canvas origin),
2578
+ // but /_api/export is a privileged MAIN-origin endpoint deliberately
2579
+ // kept off the canvas allowlist (DDR-060). A direct in-iframe fetch
2580
+ // therefore 403s ("Forbidden (canvas origin)"). Bridge it: run the
2581
+ // export here on the trusted main origin, stream the download, and
2582
+ // report status back to the iframe. Origin is already validated
2583
+ // (e.origin === expectedOrigin) above, so only the real canvas iframe
2584
+ // can ask — this is NOT a generic fetch proxy.
2585
+ void runBridgedExport(e.source, m.id, m.payload);
2586
+ } else if (m.dgn === 'export-history-request' && m.id) {
2587
+ // Same bridge for the dialog's Recent tab (/_api/export-history is
2588
+ // also main-origin-only).
2589
+ void runBridgedHistory(e.source, m.id);
2590
+ }
2591
+ }
2592
+ // Reply target for the export bridge: the canvas iframe's own origin.
2593
+ const replyOrigin = cfg?.canvasOrigin || window.location.origin;
2594
+ async function runBridgedExport(source, id, payload) {
2595
+ const reply = (msg) => {
2596
+ try {
2597
+ if (source) source.postMessage({ dgn: 'export-result', id, ...msg }, replyOrigin);
2598
+ } catch {}
2599
+ };
2600
+ try {
2601
+ const r = await fetch('/_api/export', {
2602
+ method: 'POST',
2603
+ headers: { 'content-type': 'application/json' },
2604
+ body: JSON.stringify(payload),
2605
+ });
2606
+ if (!r.ok) {
2607
+ reply({ ok: false, error: (await r.text()) || String(r.status) });
2608
+ return;
2609
+ }
2610
+ const disp = r.headers.get('Content-Disposition') || '';
2611
+ const fn = /filename="([^"]+)"/.exec(disp);
2612
+ const filename = (fn && fn[1]) || 'export';
2613
+ const blob = await r.blob();
2614
+ const url = URL.createObjectURL(blob);
2615
+ const a = document.createElement('a');
2616
+ a.href = url;
2617
+ a.download = filename;
2618
+ document.body.appendChild(a);
2619
+ a.click();
2620
+ a.remove();
2621
+ URL.revokeObjectURL(url);
2622
+ reply({ ok: true, filename });
2623
+ } catch (err) {
2624
+ reply({ ok: false, error: err && err.message ? err.message : String(err) });
2015
2625
  }
2016
2626
  }
2627
+ async function runBridgedHistory(source, id) {
2628
+ let history = [];
2629
+ try {
2630
+ const r = await fetch('/_api/export-history');
2631
+ if (r.ok) {
2632
+ const data = await r.json();
2633
+ if (Array.isArray(data.history)) history = data.history;
2634
+ }
2635
+ } catch {
2636
+ /* best-effort — empty list */
2637
+ }
2638
+ try {
2639
+ if (source) source.postMessage({ dgn: 'export-history-result', id, history }, replyOrigin);
2640
+ } catch {}
2641
+ }
2017
2642
  window.addEventListener('message', onMessage);
2018
2643
  return () => window.removeEventListener('message', onMessage);
2019
2644
  }, [commentsByFile, focusedCommentId, cfg, theme]);
@@ -2026,7 +2651,9 @@ function App() {
2026
2651
  if (!activePath || activePath === SYSTEM_TAB) return;
2027
2652
  const el = iframesRef.current.get(activePath);
2028
2653
  if (el && el.contentWindow) {
2029
- try { el.contentWindow.postMessage({ dgn: 'force-clear' }, '*'); } catch {}
2654
+ try {
2655
+ el.contentWindow.postMessage({ dgn: 'force-clear' }, '*');
2656
+ } catch {}
2030
2657
  }
2031
2658
  }, [activePath]);
2032
2659
 
@@ -2038,37 +2665,44 @@ function App() {
2038
2665
  }, []);
2039
2666
  const deleteComment = useCallback((id) => {
2040
2667
  wsSend({ type: 'comments-delete', id });
2041
- setFocusedCommentId(prev => (prev === id ? null : prev));
2668
+ setFocusedCommentId((prev) => (prev === id ? null : prev));
2042
2669
  }, []);
2043
2670
 
2044
2671
  // Jump from right-sidebar list to a comment: open file tab if needed, focus pin.
2045
2672
  // The iframe may be freshly mounted; the loaded handler also re-sends focus if focusedCommentId matches.
2046
- const jumpToComment = useCallback((file, id) => {
2047
- if (file && file !== activePath) {
2048
- setTabs(prev => prev.find(t => t.path === file) ? prev : [...prev, { path: file }]);
2049
- setActivePath(file);
2050
- }
2051
- if (id == null) {
2052
- setFocusedCommentId(null);
2053
- return;
2054
- }
2055
- setFocusedCommentId(id);
2056
- // Try sending focus immediately (existing iframe) and again after a short delay (newly opened tab).
2057
- const send = () => {
2058
- const el = iframesRef.current.get(file);
2059
- if (el && el.contentWindow) {
2060
- try { el.contentWindow.postMessage({ dgn: 'comment-focus', id }, '*'); } catch {}
2673
+ const jumpToComment = useCallback(
2674
+ (file, id) => {
2675
+ if (file && file !== activePath) {
2676
+ setTabs((prev) => (prev.find((t) => t.path === file) ? prev : [...prev, { path: file }]));
2677
+ setActivePath(file);
2061
2678
  }
2062
- };
2063
- send();
2064
- setTimeout(send, 200);
2065
- }, [activePath]);
2679
+ if (id == null) {
2680
+ setFocusedCommentId(null);
2681
+ return;
2682
+ }
2683
+ setFocusedCommentId(id);
2684
+ // Try sending focus immediately (existing iframe) and again after a short delay (newly opened tab).
2685
+ const send = () => {
2686
+ const el = iframesRef.current.get(file);
2687
+ if (el && el.contentWindow) {
2688
+ try {
2689
+ el.contentWindow.postMessage({ dgn: 'comment-focus', id }, '*');
2690
+ } catch {}
2691
+ }
2692
+ };
2693
+ send();
2694
+ setTimeout(send, 200);
2695
+ },
2696
+ [activePath]
2697
+ );
2066
2698
 
2067
2699
  // ----- Keyboard shortcuts (no Cmd+W — let browser close the tab) -----
2068
2700
  useEffect(() => {
2069
2701
  function onKey(e) {
2070
2702
  const meta = e.metaKey || e.ctrlKey;
2071
- const inEditable = ['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName) || document.activeElement?.isContentEditable;
2703
+ const inEditable =
2704
+ ['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName) ||
2705
+ document.activeElement?.isContentEditable;
2072
2706
  // Phase 4.1: shell-side letter shortcuts (H/T/S) must not double-fire
2073
2707
  // inside a focused canvas iframe — the canvas input router owns those
2074
2708
  // letters as tool-mode keys (V/H/C). Cmd-modified shortcuts (⌘R, ⌘⇧M,
@@ -2084,7 +2718,7 @@ function App() {
2084
2718
  // Cmd+Shift+M / Ctrl+Shift+M — toggle right "Comments" panel
2085
2719
  if (meta && e.shiftKey && (e.key === 'm' || e.key === 'M')) {
2086
2720
  e.preventDefault();
2087
- setCommentsPanelOpen(v => !v);
2721
+ setCommentsPanelOpen((v) => !v);
2088
2722
  return;
2089
2723
  }
2090
2724
  // Cmd+C / Ctrl+C — Phase 4.1 removed the shell-side comment-drop chord.
@@ -2092,8 +2726,18 @@ function App() {
2092
2726
  // then click the element) or right-click "Add comment". Cmd+C now
2093
2727
  // reverts to native browser copy.
2094
2728
  if (meta && !e.shiftKey && !e.altKey && (e.key === 'c' || e.key === 'C')) {
2095
- if (selected && selected.selector && activePath && activePath !== SYSTEM_TAB && !inEditable && console && console.warn) {
2096
- console.warn('Cmd+C comment-drop deprecated — press C inside the canvas to enter Comment tool, then click the element.');
2729
+ if (
2730
+ selected &&
2731
+ selected.selector &&
2732
+ activePath &&
2733
+ activePath !== SYSTEM_TAB &&
2734
+ !inEditable &&
2735
+ console &&
2736
+ console.warn
2737
+ ) {
2738
+ console.warn(
2739
+ 'Cmd+C comment-drop deprecated — press C inside the canvas to enter Comment tool, then click the element.'
2740
+ );
2097
2741
  }
2098
2742
  // Fall through to native copy.
2099
2743
  }
@@ -2126,14 +2770,14 @@ function App() {
2126
2770
  if (e.key === 't' || e.key === 'T') {
2127
2771
  if (e.shiftKey || meta) return;
2128
2772
  e.preventDefault();
2129
- setSidebarOpen(v => !v);
2773
+ setSidebarOpen((v) => !v);
2130
2774
  return;
2131
2775
  }
2132
2776
  // H — toggle show-hidden (sidecars + project/runtime orphans)
2133
2777
  if (e.key === 'h' || e.key === 'H') {
2134
2778
  if (e.shiftKey || meta) return;
2135
2779
  e.preventDefault();
2136
- setShowHidden(v => !v);
2780
+ setShowHidden((v) => !v);
2137
2781
  return;
2138
2782
  }
2139
2783
  // S — toggle Design system view
@@ -2155,18 +2799,31 @@ function App() {
2155
2799
  // Esc — clear focused pin. The in-place composer (Phase 6) and thread
2156
2800
  // popover handle their own Esc inside the iframe.
2157
2801
  if (e.key === 'Escape') {
2158
- if (focusedCommentId) { setFocusedCommentId(null); return; }
2802
+ if (focusedCommentId) {
2803
+ setFocusedCommentId(null);
2804
+ return;
2805
+ }
2159
2806
  }
2160
2807
  }
2161
2808
  window.addEventListener('keydown', onKey);
2162
2809
  return () => window.removeEventListener('keydown', onKey);
2163
- }, [reloadActive, selected, activePath, focusedCommentId, sidebarOpen, openSystem, closeTab, clearActiveCanvasSelection]);
2810
+ }, [
2811
+ reloadActive,
2812
+ selected,
2813
+ activePath,
2814
+ focusedCommentId,
2815
+ sidebarOpen,
2816
+ openSystem,
2817
+ closeTab,
2818
+ clearActiveCanvasSelection,
2819
+ ]);
2164
2820
 
2165
2821
  const registerIframe = useCallback((path, el) => {
2166
2822
  if (el) iframesRef.current.set(path, el);
2167
2823
  }, []);
2168
2824
 
2169
- const activeFileComments = (activePath && activePath !== SYSTEM_TAB) ? (commentsByFile[activePath] || []) : [];
2825
+ const activeFileComments =
2826
+ activePath && activePath !== SYSTEM_TAB ? commentsByFile[activePath] || [] : [];
2170
2827
  const totalOpen = totalCounts(commentsByFile).open;
2171
2828
 
2172
2829
  // Suppress the native browser context menu across the shell — the canvas
@@ -2177,7 +2834,7 @@ function App() {
2177
2834
  // copy/paste still works.
2178
2835
  const onShellContextMenu = useCallback((e) => {
2179
2836
  const t = e.target;
2180
- if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || (t.isContentEditable))) {
2837
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
2181
2838
  return;
2182
2839
  }
2183
2840
  e.preventDefault();
@@ -2185,7 +2842,9 @@ function App() {
2185
2842
 
2186
2843
  return (
2187
2844
  <div
2188
- className={'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')}
2845
+ className={
2846
+ 'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')
2847
+ }
2189
2848
  onContextMenu={onShellContextMenu}
2190
2849
  >
2191
2850
  <SyncBanner status={syncStatus} />
@@ -2214,7 +2873,11 @@ function App() {
2214
2873
  <span>Repo state changed — reload to sync?</span>
2215
2874
  <button
2216
2875
  type="button"
2217
- onClick={() => { try { window.location.reload(); } catch {} }}
2876
+ onClick={() => {
2877
+ try {
2878
+ window.location.reload();
2879
+ } catch {}
2880
+ }}
2218
2881
  style={{
2219
2882
  padding: '3px 10px',
2220
2883
  background: '#2563eb',
@@ -2224,7 +2887,9 @@ function App() {
2224
2887
  font: '500 11px/1.2 system-ui',
2225
2888
  cursor: 'pointer',
2226
2889
  }}
2227
- >Reload</button>
2890
+ >
2891
+ Reload
2892
+ </button>
2228
2893
  <button
2229
2894
  type="button"
2230
2895
  onClick={() => setGitLifecycle(null)}
@@ -2237,7 +2902,9 @@ function App() {
2237
2902
  font: '500 11px/1.2 system-ui',
2238
2903
  cursor: 'pointer',
2239
2904
  }}
2240
- >Dismiss</button>
2905
+ >
2906
+ Dismiss
2907
+ </button>
2241
2908
  </div>
2242
2909
  )}
2243
2910
  <Sidebar
@@ -2262,12 +2929,12 @@ function App() {
2262
2929
  openMenu={openMenu}
2263
2930
  setOpenMenu={setOpenMenu}
2264
2931
  commentsPanelOpen={commentsPanelOpen}
2265
- onToggleComments={() => setCommentsPanelOpen(v => !v)}
2932
+ onToggleComments={() => setCommentsPanelOpen((v) => !v)}
2266
2933
  onOpenSystem={openSystem}
2267
2934
  sidebarOpen={sidebarOpen}
2268
- onToggleSidebar={() => setSidebarOpen(v => !v)}
2935
+ onToggleSidebar={() => setSidebarOpen((v) => !v)}
2269
2936
  showHidden={showHidden}
2270
- onToggleShowHidden={() => setShowHidden(v => !v)}
2937
+ onToggleShowHidden={() => setShowHidden((v) => !v)}
2271
2938
  onOpenHelp={() => setHelpOpen(true)}
2272
2939
  annotationsVisible={annotationsVisible}
2273
2940
  onToggleAnnotations={toggleAnnotations}