@1agh/maude 0.23.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 (127) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +32 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +126 -2
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +74 -10
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dependencies.json +30 -2
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
  27. package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
  28. package/plugins/design/dev-server/api.ts +123 -4
  29. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  30. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  31. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  32. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  33. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  34. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  35. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  36. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  37. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  38. package/plugins/design/dev-server/bin/prep.sh +211 -0
  39. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  40. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  41. package/plugins/design/dev-server/bin/smoke.sh +208 -23
  42. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  43. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  44. package/plugins/design/dev-server/canvas-cursors.ts +173 -0
  45. package/plugins/design/dev-server/canvas-icons.tsx +299 -0
  46. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  47. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  48. package/plugins/design/dev-server/canvas-shell.tsx +404 -66
  49. package/plugins/design/dev-server/client/app.jsx +1231 -416
  50. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  51. package/plugins/design/dev-server/collab/index.ts +87 -9
  52. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  53. package/plugins/design/dev-server/collab/registry.ts +80 -2
  54. package/plugins/design/dev-server/collab/room.ts +21 -8
  55. package/plugins/design/dev-server/context-menu.tsx +167 -23
  56. package/plugins/design/dev-server/context.ts +24 -0
  57. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  58. package/plugins/design/dev-server/dist/client.bundle.js +364 -29
  59. package/plugins/design/dev-server/dist/comment-mount.js +78 -33
  60. package/plugins/design/dev-server/dist/styles.css +16 -0
  61. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  62. package/plugins/design/dev-server/export-dialog.tsx +208 -18
  63. package/plugins/design/dev-server/exporters/html.ts +4 -1
  64. package/plugins/design/dev-server/exporters/index.ts +4 -1
  65. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  66. package/plugins/design/dev-server/exporters/png.ts +21 -2
  67. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  68. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  69. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  70. package/plugins/design/dev-server/fs-watch.ts +1 -0
  71. package/plugins/design/dev-server/http.ts +299 -28
  72. package/plugins/design/dev-server/input-router.tsx +35 -5
  73. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  74. package/plugins/design/dev-server/server.ts +123 -1
  75. package/plugins/design/dev-server/sync/agent.ts +95 -0
  76. package/plugins/design/dev-server/sync/codec.ts +155 -0
  77. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  78. package/plugins/design/dev-server/sync/index.ts +479 -35
  79. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  80. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  81. package/plugins/design/dev-server/sync/origins.ts +57 -0
  82. package/plugins/design/dev-server/sync/projection.ts +368 -0
  83. package/plugins/design/dev-server/sync/status.ts +115 -0
  84. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  85. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  86. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  87. package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
  88. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
  89. package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
  90. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  91. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  92. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  93. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  94. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  95. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  96. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  97. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  98. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  99. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  100. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  101. package/plugins/design/dev-server/test/input-router.test.ts +32 -4
  102. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -0
  103. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  104. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  105. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  106. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  107. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  108. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  109. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  110. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  111. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  112. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  113. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
  114. package/plugins/design/dev-server/tool-palette.tsx +155 -24
  115. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  116. package/plugins/design/dev-server/undo-stack.ts +20 -4
  117. package/plugins/design/dev-server/use-annotation-resize.tsx +219 -61
  118. package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
  119. package/plugins/design/dev-server/ws.ts +40 -1
  120. package/plugins/design/templates/_shell.html +85 -10
  121. package/plugins/design/templates/canvas.tsx.template +13 -0
  122. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  123. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  124. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  125. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  127. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -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,15 +106,15 @@ 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) {
106
- const ds = specMatch[1];
107
- params.set('layout', `system/${ds}/preview/_layout.css`);
114
+ const dsName = specMatch[1];
115
+ params.set('layout', `system/${dsName}/preview/_layout.css`);
108
116
  if (!cfg?.componentsCssRel) {
109
- params.set('components', `system/${ds}/preview/_components.css`);
117
+ params.set('components', `system/${dsName}/preview/_components.css`);
110
118
  }
111
119
  } else if (ds0?.path) {
112
120
  // UI canvas — load the project DS's `_components.css` so the dc-canvas /
@@ -116,7 +124,11 @@ function canvasUrl(p, cfg, opts) {
116
124
  params.set('components', `${ds0.path}/preview/_components.css`);
117
125
  }
118
126
  }
119
- return `/_canvas-shell.html?${params.toString()}`;
127
+ // T2 (9.1-A) — load the iframe from the segregated canvas origin when the
128
+ // server advertises one; fall back to a same-origin relative URL otherwise
129
+ // (older server, tests). The trailing path + query are identical either way.
130
+ const origin = cfg?.canvasOrigin || '';
131
+ return `${origin}/_canvas-shell.html?${params.toString()}`;
120
132
  }
121
133
 
122
134
  function basename(p) {
@@ -188,7 +200,9 @@ function groupBySidecar(files) {
188
200
  function buildTree(paths, stripPrefix) {
189
201
  const root = {};
190
202
  for (const p of paths) {
191
- 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;
192
206
  const parts = stripped.split('/');
193
207
  let node = root;
194
208
  for (let i = 0; i < parts.length; i++) {
@@ -211,22 +225,28 @@ function filterTree(node, query) {
211
225
  const q = query.toLowerCase();
212
226
  const out = {};
213
227
  let any = false;
214
- const dirs = Object.keys(node).filter(k => k !== '_files');
228
+ const dirs = Object.keys(node).filter((k) => k !== '_files');
215
229
  for (const d of dirs) {
216
230
  const filtered = filterTree(node[d], query);
217
- if (filtered) { out[d] = filtered; any = true; }
231
+ if (filtered) {
232
+ out[d] = filtered;
233
+ any = true;
234
+ }
218
235
  }
219
236
  if (node._files) {
220
- const files = node._files.filter(f =>
221
- 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)
222
239
  );
223
- if (files.length) { out._files = files; any = true; }
240
+ if (files.length) {
241
+ out._files = files;
242
+ any = true;
243
+ }
224
244
  }
225
245
  return any ? out : null;
226
246
  }
227
247
 
228
248
  function openCount(comments) {
229
- return (comments || []).filter(c => c.status !== 'resolved').length;
249
+ return (comments || []).filter((c) => c.status !== 'resolved').length;
230
250
  }
231
251
 
232
252
  function timeAgo(iso) {
@@ -245,7 +265,9 @@ function timeAgo(iso) {
245
265
  }
246
266
 
247
267
  function totalCounts(commentsByFile) {
248
- let all = 0, open = 0, resolved = 0;
268
+ let all = 0,
269
+ open = 0,
270
+ resolved = 0;
249
271
  for (const list of Object.values(commentsByFile || {})) {
250
272
  for (const c of list || []) {
251
273
  all++;
@@ -260,7 +282,17 @@ function totalCounts(commentsByFile) {
260
282
 
261
283
  function Icon({ d, size = 14, color }) {
262
284
  return (
263
- <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
+ >
264
296
  <path d={d} />
265
297
  </svg>
266
298
  );
@@ -287,9 +319,11 @@ function DirRow({ name, depth, defaultOpen, children }) {
287
319
  tabIndex={-1}
288
320
  className="tp-row dir"
289
321
  style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
290
- onClick={() => setOpen(v => !v)}
322
+ onClick={() => setOpen((v) => !v)}
291
323
  >
292
- <span className="glyph" aria-hidden="true">{open ? '▾' : '▸'}</span>
324
+ <span className="glyph" aria-hidden="true">
325
+ {open ? '▾' : '▸'}
326
+ </span>
293
327
  <span className="name">{name}</span>
294
328
  </button>
295
329
  {open && children}
@@ -318,7 +352,9 @@ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, c
318
352
  aria-label={open ? 'Collapse design system' : 'Expand design system'}
319
353
  title={open ? 'Collapse' : 'Expand'}
320
354
  >
321
- <span className="glyph" aria-hidden="true">{open ? '▾' : '▸'}</span>
355
+ <span className="glyph" aria-hidden="true">
356
+ {open ? '▾' : '▸'}
357
+ </span>
322
358
  </button>
323
359
  <button
324
360
  type="button"
@@ -350,19 +386,38 @@ function FileRow({ file, activePath, onOpen, openCount: oc, depth, kind, sidecar
350
386
  aria-selected={isSel}
351
387
  aria-disabled={inert ? 'true' : undefined}
352
388
  tabIndex={isSel ? 0 : -1}
353
- 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
+ }
354
395
  style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
355
- title={file.path + (oc ? ` — ${oc} open` : (inert ? ' (file index only)' : ''))}
356
- 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
+ }}
357
400
  >
358
- <span className="glyph" aria-hidden="true">{isSel ? '▸' : '·'}</span>
401
+ <span className="glyph" aria-hidden="true">
402
+ {isSel ? '▸' : '·'}
403
+ </span>
359
404
  <span className="name">{label}</span>
360
405
  {oc > 0 && <span className="badge">{oc}</span>}
361
406
  </button>
362
407
  );
363
408
  }
364
409
 
365
- 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
+ }) {
366
421
  const hasSidecars = sidecars.length > 0;
367
422
  const [openState, setOpenState] = useState(false);
368
423
  // Sidecars are only revealed when the user opts in via `showHidden` — the
@@ -416,24 +471,39 @@ function CanvasRow({ primary, sidecars, depth, kind, activePath, onOpen, openCou
416
471
  <span className="name">{displayName(primary.name)}</span>
417
472
  {oc > 0 && <span className="badge">{oc}</span>}
418
473
  </button>
419
- {open && sidecars.map((sc) => (
420
- <FileRow
421
- key={sc.path}
422
- file={sc}
423
- activePath={activePath}
424
- onOpen={onOpen}
425
- openCount={0}
426
- depth={depth + 1}
427
- kind={kind}
428
- sidecar
429
- />
430
- ))}
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
+ ))}
431
487
  </Fragment>
432
488
  );
433
489
  }
434
490
 
435
- function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showHidden, search, dsFolders, activeDsName, onOpenSystem }) {
436
- 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();
437
507
  const files = node._files || [];
438
508
  // VS Code-style sidecar grouping. Canvas (`.tsx`/`.html`) becomes the primary
439
509
  // row; same-basename non-canvas files (`.meta.json`, `.css`, …) collapse
@@ -452,10 +522,12 @@ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showH
452
522
  return (
453
523
  <Fragment>
454
524
  {canvases.map((entry) => {
455
- const forceOpen = hasSearch && entry.sidecars.some((sc) => {
456
- const q = search.toLowerCase();
457
- return sc.name.toLowerCase().includes(q) || sc.path.toLowerCase().includes(q);
458
- });
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
+ });
459
531
  return (
460
532
  <CanvasRow
461
533
  key={entry.primary.path}
@@ -471,18 +543,19 @@ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showH
471
543
  />
472
544
  );
473
545
  })}
474
- {showHidden && orphans.map((entry) => (
475
- <FileRow
476
- key={entry.primary.path}
477
- file={entry.primary}
478
- activePath={activePath}
479
- onOpen={onOpen}
480
- openCount={openCount(commentsByFile[entry.primary.path])}
481
- depth={depth}
482
- kind={kind}
483
- />
484
- ))}
485
- {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) => {
486
559
  const dsMatch = dsFolderByName?.get(d);
487
560
  const childTree = (
488
561
  <Tree
@@ -527,12 +600,12 @@ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showH
527
600
  // project / DS identity; the mock keeps these tight (1 line). Labels are
528
601
  // keyed by the server-provided `kind` (PROJECT / DS / UI / RUNTIME).
529
602
  const SECTION_META = {
530
- project: { title: 'PROJECT', pillFromCount: false },
603
+ project: { title: 'PROJECT', pillFromCount: false },
531
604
  // Design-system group: pill shows the number of DSes (one row per DS folder
532
605
  // inside). Computed in Sidebar from `g.dsFolders.length`.
533
- ds: { title: 'DESIGN SYSTEM', pillFromDsCount: true },
534
- canvas: { title: 'UI CANVASES', pillFromCount: true },
535
- 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 },
536
609
  };
537
610
 
538
611
  function sectionMetaFor(g) {
@@ -540,14 +613,27 @@ function sectionMetaFor(g) {
540
613
  if (g.kind === 'runtime') return SECTION_META.runtime;
541
614
  // canvas-kind groups: "Design system" → ds, anything else → canvas label
542
615
  if (g.label === 'Design system') return SECTION_META.ds;
543
- if (g.label === 'UI kit') return SECTION_META.canvas;
616
+ if (g.label === 'UI kit') return SECTION_META.canvas;
544
617
  return { title: g.label.toUpperCase(), pillFromCount: true };
545
618
  }
546
619
 
547
- 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
+ }) {
548
634
  const filteredGroups = useMemo(() => {
549
635
  if (!search) return groups;
550
- 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 }));
551
637
  }, [groups, search]);
552
638
 
553
639
  // Mock uses `42 / 42` — total openable canvases, not every listed file.
@@ -560,7 +646,8 @@ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsCon
560
646
  }, [groups]);
561
647
  const htmlShown = useMemo(() => {
562
648
  let total = 0;
563
- 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++;
564
651
  return total;
565
652
  }, [filteredGroups]);
566
653
 
@@ -580,18 +667,27 @@ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsCon
580
667
  type="search"
581
668
  placeholder="filter (⌘F)"
582
669
  value={search}
583
- onChange={e => setSearch(e.target.value)}
670
+ onChange={(e) => setSearch(e.target.value)}
584
671
  aria-label="Filter files"
585
672
  />
586
673
  {search ? (
587
- <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>
588
682
  ) : (
589
- <span className="search-kbd" aria-hidden="true">/</span>
683
+ <span className="search-kbd" aria-hidden="true">
684
+ /
685
+ </span>
590
686
  )}
591
687
  </div>
592
688
 
593
689
  <div className="tree-panel-body" role="tree" aria-label="Project file tree">
594
- {filteredGroups.map(g => {
690
+ {filteredGroups.map((g) => {
595
691
  // Hide gitignored runtime / orphan-only project sections by default.
596
692
  // Active search overrides — if the user typed a query, they want hits
597
693
  // wherever they live.
@@ -600,9 +696,10 @@ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsCon
600
696
  // Counter pill counts canvases only — sidecars + orphans inflate the
601
697
  // raw `paths.length` and the FILES header already filters this way.
602
698
  const canvasCount = (g.paths || []).filter((p) => CANVAS_EXT_RE.test(p)).length;
603
- const pill = meta.pill
604
- || (meta.pillFromDsCount ? String(g.dsFolders?.length || 0) : null)
605
- || (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);
606
703
  const hasItems = g.tree && Object.keys(g.tree).length > 0;
607
704
  const isDs = g.label === 'Design system';
608
705
  const isProject = g.kind === 'project';
@@ -624,29 +721,30 @@ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsCon
624
721
  aria-expanded={sectionOpen}
625
722
  title={sectionOpen ? 'Collapse section' : 'Expand section'}
626
723
  >
627
- <span className="chev" aria-hidden="true">{chev}</span>
724
+ <span className="chev" aria-hidden="true">
725
+ {chev}
726
+ </span>
628
727
  <span className="section-label">{meta.title}</span>
629
728
  {pill && <span className="pill">{pill}</span>}
630
729
  </button>
631
- {sectionOpen && (hasItems ? (
632
- <Tree
633
- node={g.tree}
634
- activePath={activePath}
635
- onOpen={onOpen}
636
- commentsByFile={commentsByFile}
637
- depth={1}
638
- kind={g.kind}
639
- showHidden={showHidden}
640
- search={search}
641
- dsFolders={g.dsFolders}
642
- activeDsName={activeDsName}
643
- onOpenSystem={isDs ? onOpenSystem : undefined}
644
- />
645
- ) : (
646
- <div className="tp-empty">
647
- {search ? 'No matches.' : 'Empty.'}
648
- </div>
649
- ))}
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
+ ))}
650
748
  </Fragment>
651
749
  );
652
750
  })}
@@ -660,7 +758,9 @@ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsCon
660
758
  function HelpModal({ open, onClose }) {
661
759
  useEffect(() => {
662
760
  if (!open) return;
663
- function onKey(e) { if (e.key === 'Escape') onClose(); }
761
+ function onKey(e) {
762
+ if (e.key === 'Escape') onClose();
763
+ }
664
764
  window.addEventListener('keydown', onKey);
665
765
  return () => window.removeEventListener('keydown', onKey);
666
766
  }, [open, onClose]);
@@ -669,112 +769,286 @@ function HelpModal({ open, onClose }) {
669
769
  <div
670
770
  className="help-modal-backdrop"
671
771
  role="presentation"
672
- onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}
772
+ onMouseDown={(e) => {
773
+ if (e.target === e.currentTarget) onClose();
774
+ }}
673
775
  >
674
- <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
+ >
675
782
  <header className="help-modal-hd">
676
- <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>
677
786
  <span className="sku">MDCC-DEV-SRV / v{MDCC_VERSION}</span>
678
- <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>
679
795
  </header>
680
796
  <div className="help-modal-body">
681
797
  <details open>
682
798
  <summary>Canvas selection &amp; tools</summary>
683
- <ul>
684
- <li><kbd>V</kbd> <span>move tool — Cmd+click to select, Cmd+Shift to multi</span></li>
685
- <li><kbd>H</kbd> <span>hand tool — bare drag pans (no Space needed)</span></li>
686
- <li><kbd>C</kbd> <span>comment tool — hover paints, click drops a pin</span></li>
687
- <li><kbd>⌘</kbd> + hover <span>preview deepest element under cursor</span></li>
688
- <li><kbd>⌘</kbd> + click <span>select that element (replace)</span></li>
689
- <li><kbd>⌘⇧</kbd> + click <span>add deepest to selection (multi)</span></li>
690
- <li>right-click <span>context menu (Copy CSS / Fit / Reset...)</span></li>
691
- <li><kbd>Esc</kbd> in canvas <span>clear selection + close menu</span></li>
692
- </ul>
693
- </details>
694
- <details>
695
- <summary>Annotation tools</summary>
696
- <ul>
697
- <li><kbd>B</kbd> <span>pen freehand stroke</span></li>
698
- <li><kbd>R</kbd> <span>rectangle — drag to define corners</span></li>
699
- <li><kbd>O</kbd> <span>ellipse — drag from center outward</span></li>
700
- <li><kbd>A</kbd> <span>arrow drag tail → tip</span></li>
701
- <li><kbd>E</kbd> <span>eraser — click or drag over strokes to remove</span></li>
702
- <li><kbd>V</kbd> + click stroke <span>select annotation (Shift+click to multi)</span></li>
703
- <li><kbd>V</kbd> + drag empty <span>marquee-select strokes that overlap</span></li>
704
- <li>double-click rect/ellipse <span>add text inside the shape</span></li>
705
- <li>arrow keys <span>nudge selected annotation 1 unit (Shift = 10)</span></li>
706
- <li><kbd>Backspace</kbd> <span>delete selected annotations</span></li>
707
- <li><kbd>⇧P</kbd> <span>presentation — hide annotations for clean screenshot</span></li>
708
- </ul>
709
- </details>
710
- <details>
711
- <summary>Tabs &amp; canvas</summary>
712
- <ul>
713
- <li>click in tree <span>open tab</span></li>
714
- <li><kbd>×</kbd> on tab <span>close tab</span></li>
715
- <li><kbd>⌘R</kbd> <span>reload iframe</span></li>
716
- <li><kbd>/</kbd> <span>focus search</span></li>
717
- <li><kbd>⌘⇧M</kbd> <span>toggle comments panel</span></li>
718
- </ul>
719
- </details>
720
- <details>
721
- <summary>Slash commands</summary>
722
- <ul className="cmds">
723
- <li><code>/design:edit "<i>feedback</i>"</code><span>edit + 4-iter multi-axis loop</span></li>
724
- <li><code>/design:edit "<i>…</i>" --perfect</code><span>8-iter polish (4.5/5 aspiration)</span></li>
725
- <li><code>/design:edit "<i>…</i>" --no-critic</code><span>raw edit, skip loop</span></li>
726
- <li><code>/design:edit "<i>…</i>" --opt-out=<i>scope</i></code><span>override DS scope (palette/aesthetic/full)</span></li>
727
- <li><code>/design:new "<i>Name</i>" "<i>brief</i>"</code><span>scaffold canvas</span></li>
728
- <li><code>/design:new "<i>…</i>" --opt-out=aesthetic</code><span>scaffold off-system canvas (gradients/radii/type free)</span></li>
729
- <li><code>/design:critic</code><span>review panel (routed)</span></li>
730
- <li><code>/design:critic --all</code><span>10-critic sweep</span></li>
731
- <li><code>/design:critic --agent signature-moment-critic</code><span>aspiration axis only</span></li>
732
- <li><code>/design:rollback</code><span>undo last edit</span></li>
733
- <li><code>/design:screenshot</code><span>capture canvas</span></li>
734
- <li><code>/design:setup-docs</code><span>refresh README + INDEX</span></li>
735
- <li><code>/design:handoff</code><span>migrate to apps/</span></li>
736
- </ul>
737
- </details>
738
- <details>
739
- <summary>Opt-out scope</summary>
740
- <ul>
741
- <li><strong>palette</strong> <span>default tokens + rootClass kept; local namespace overrides colors only. DS aesthetic still enforced.</span></li>
742
- <li><strong>aesthetic</strong> <span>palette + gradients/off-ladder radii/alt type/decorative SVG flags allowed.</span></li>
743
- <li><strong>full</strong> <span>DS treated as advisory. Type/radii/aesthetic up to canvas.</span></li>
744
- <li><em>A11y enforced at every scope</em> <span>contrast, focus, semantics, motion, touch targets — never relaxed.</span></li>
745
- <li>Persisted on canvas's <code>.meta.json</code> <code>opt_out_scope</code> field — subsequent <code>/design:edit</code> iterations inherit.</li>
746
- <li>Inferred from brief ("modern", "vibrant", "off-system") with one-shot AskUserQuestion before iter-1 critics fire.</li>
747
- </ul>
748
- </details>
749
- <details>
750
- <summary>Auto-critic loop</summary>
751
- <ul>
752
- <li><strong>Default</strong> <span>4 iter · aspiration ≥ 4.0 · stable-but-bland exit</span></li>
753
- <li><strong>--perfect</strong> <span>8 iter · aspiration ≥ 4.5 · broader divergence tolerance</span></li>
754
- <li><strong>--perfect --all</strong> <span>every critic incl. aspiration · portfolio-grade</span></li>
755
- <li>Exit: <code>solid</code> · <code>stable-but-bland</code> · <code>max-reached</code> · <code>divergent</code></li>
756
- <li><em>stable-but-bland</em> = correctness clean, aspiration plateau — surface for review with lowest 2 axes named</li>
757
- <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>
758
- </ul>
759
- </details>
760
- <details>
761
- <summary>Pin-to-element flow</summary>
762
- <ol>
763
- <li>Open canvas tab</li>
764
- <li><kbd>⌘</kbd>+click element</li>
765
- <li>Status bar shows ● selector</li>
766
- <li>Run <code>/design:edit "<i>change just this</i>"</code></li>
767
- <li>Reload iframe (<kbd>⌘R</kbd>)</li>
768
- </ol>
769
- </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>
770
1036
  <details>
771
1037
  <summary>Comments</summary>
772
1038
  <ol>
773
- <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>
774
1042
  <li>Numbered pin appears on canvas</li>
775
- <li><kbd>⌘⇧M</kbd> <span>opens panel — All / Open / Resolved</span></li>
776
- <li>Click row in panel <span>jumps to that file + pin</span></li>
777
- <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>
778
1052
  </ol>
779
1053
  </details>
780
1054
  </div>
@@ -795,7 +1069,9 @@ const MENU_NAMES = ['File', 'Edit', 'View', 'Selection', 'Tools', 'Help'];
795
1069
 
796
1070
  function ViewDropdown({ panels, onToggle, onClose }) {
797
1071
  useEffect(() => {
798
- function onKey(e) { if (e.key === 'Escape') onClose(); }
1072
+ function onKey(e) {
1073
+ if (e.key === 'Escape') onClose();
1074
+ }
799
1075
  function onDocClick(e) {
800
1076
  if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
801
1077
  }
@@ -810,32 +1086,39 @@ function ViewDropdown({ panels, onToggle, onClose }) {
810
1086
  return (
811
1087
  <div className="mb-dropdown" role="menu" aria-label="View" style={{ left: '146px' }}>
812
1088
  <div className="mb-dd-hd">Panels</div>
813
- {panels.map(p => (
1089
+ {panels.map((p) => (
814
1090
  <button
815
1091
  key={p.id}
816
1092
  type="button"
817
1093
  role="menuitem"
818
1094
  className={'mb-dd-item' + (p.checked ? ' active' : '')}
819
1095
  aria-disabled={p.disabled ? 'true' : undefined}
820
- onClick={() => { if (!p.disabled) { onToggle(p.id); onClose(); } }}
1096
+ onClick={() => {
1097
+ if (!p.disabled) {
1098
+ onToggle(p.id);
1099
+ onClose();
1100
+ }
1101
+ }}
821
1102
  >
822
1103
  <span className="lbl">
823
1104
  <span className="check">{p.checked ? '✓' : ''}</span>
824
1105
  <span>{p.label}</span>
825
1106
  </span>
826
- {p.phase
827
- ? <span className="phase-tag">{p.phase}</span>
828
- : <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
+ )}
829
1112
  </button>
830
1113
  ))}
831
1114
  <div className="mb-dd-sep" />
832
1115
  <div className="mb-dd-hd">Zoom</div>
833
1116
  {[
834
- { label: 'Zoom In', shortcut: '⌘ +' },
835
- { label: 'Zoom Out', shortcut: '⌘ −' },
1117
+ { label: 'Zoom In', shortcut: '⌘ +' },
1118
+ { label: 'Zoom Out', shortcut: '⌘ −' },
836
1119
  { label: 'Fit to Screen', shortcut: '⌘ 0' },
837
1120
  { label: 'Actual Size · 100 %', shortcut: '⌥ ⌘ 0' },
838
- ].map(z => (
1121
+ ].map((z) => (
839
1122
  <button
840
1123
  key={z.label}
841
1124
  type="button"
@@ -843,7 +1126,10 @@ function ViewDropdown({ panels, onToggle, onClose }) {
843
1126
  className="mb-dd-item"
844
1127
  aria-disabled="true"
845
1128
  >
846
- <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>
847
1133
  <span className="phase-tag">Phase 4</span>
848
1134
  </button>
849
1135
  ))}
@@ -856,7 +1142,9 @@ function ViewDropdown({ panels, onToggle, onClose }) {
856
1142
 
857
1143
  function SelectionDropdown({ onAction, onClose }) {
858
1144
  useEffect(() => {
859
- function onKey(e) { if (e.key === 'Escape') onClose(); }
1145
+ function onKey(e) {
1146
+ if (e.key === 'Escape') onClose();
1147
+ }
860
1148
  function onDocClick(e) {
861
1149
  if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
862
1150
  }
@@ -868,20 +1156,26 @@ function SelectionDropdown({ onAction, onClose }) {
868
1156
  };
869
1157
  }, [onClose]);
870
1158
  const items = [
871
- { id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
1159
+ { id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
872
1160
  { id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⌘ ⇧ A' },
873
1161
  ];
874
1162
  return (
875
1163
  <div className="mb-dropdown" role="menu" aria-label="Selection" style={{ left: '195px' }}>
876
- {items.map(it => (
1164
+ {items.map((it) => (
877
1165
  <button
878
1166
  key={it.id}
879
1167
  type="button"
880
1168
  role="menuitem"
881
1169
  className="mb-dd-item"
882
- onClick={() => { onAction(it.id); onClose(); }}
1170
+ onClick={() => {
1171
+ onAction(it.id);
1172
+ onClose();
1173
+ }}
883
1174
  >
884
- <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>
885
1179
  <span className="shortcut">{it.shortcut}</span>
886
1180
  </button>
887
1181
  ))}
@@ -891,7 +1185,9 @@ function SelectionDropdown({ onAction, onClose }) {
891
1185
 
892
1186
  function ToolsDropdown({ onAction, onClose }) {
893
1187
  useEffect(() => {
894
- function onKey(e) { if (e.key === 'Escape') onClose(); }
1188
+ function onKey(e) {
1189
+ if (e.key === 'Escape') onClose();
1190
+ }
895
1191
  function onDocClick(e) {
896
1192
  if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
897
1193
  }
@@ -906,26 +1202,34 @@ function ToolsDropdown({ onAction, onClose }) {
906
1202
  // kept in sync by hand because the menubar lives in the dev-server shell
907
1203
  // (no shared bundle with the canvas iframes).
908
1204
  const tools = [
909
- { id: 'move', label: 'Move', shortcut: 'V' },
910
- { id: 'hand', label: 'Hand', shortcut: 'H' },
1205
+ { id: 'move', label: 'Move', shortcut: 'V' },
1206
+ { id: 'hand', label: 'Hand', shortcut: 'H' },
911
1207
  { id: 'comment', label: 'Comment', shortcut: 'C' },
912
- { id: 'pen', label: 'Pen', shortcut: 'B' },
913
- { id: 'rect', label: 'Rect', shortcut: 'R' },
1208
+ { id: 'pen', label: 'Pen', shortcut: 'B' },
1209
+ { id: 'rect', label: 'Rect', shortcut: 'R' },
914
1210
  { id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
915
- { id: 'arrow', label: 'Arrow', shortcut: 'A' },
916
- { 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' },
917
1215
  ];
918
1216
  return (
919
1217
  <div className="mb-dropdown" role="menu" aria-label="Tools" style={{ left: '253px' }}>
920
- {tools.map(t => (
1218
+ {tools.map((t) => (
921
1219
  <button
922
1220
  key={t.id}
923
1221
  type="button"
924
1222
  role="menuitem"
925
1223
  className="mb-dd-item"
926
- onClick={() => { onAction(t.id); onClose(); }}
1224
+ onClick={() => {
1225
+ onAction(t.id);
1226
+ onClose();
1227
+ }}
927
1228
  >
928
- <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>
929
1233
  <span className="shortcut">{t.shortcut}</span>
930
1234
  </button>
931
1235
  ))}
@@ -933,21 +1237,62 @@ function ToolsDropdown({ onAction, onClose }) {
933
1237
  );
934
1238
  }
935
1239
 
936
- 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
+ }) {
937
1258
  const isSystem = activePath === SYSTEM_TAB;
938
- const stamp = isSystem ? 'SYSTEM' : (activePath ? 'CANVAS' : 'IDLE');
939
- const fileLabel = isSystem
940
- ? <b>design system</b>
941
- : (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
+ );
942
1269
 
943
1270
  const panels = [
944
- { id: 'tree', label: 'Project Tree', shortcut: 'T', checked: sidebarOpen, disabled: false },
945
- { id: 'comments', label: 'Comments Sidebar', shortcut: '⌘ ⇧ M', checked: commentsPanelOpen, disabled: false },
946
- { id: 'hidden', label: 'Show hidden files', shortcut: 'H', checked: showHidden, disabled: false },
947
- { id: 'layers', label: 'Layers Panel', phase: 'Phase 12', disabled: true },
948
- { id: 'inspector', label: 'Inspector', phase: 'Phase 12', disabled: true },
949
- { id: 'annotate', label: 'Annotations', shortcut: '⇧ P', checked: annotationsVisible, disabled: false },
950
- { 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 },
951
1296
  ];
952
1297
 
953
1298
  function onMenuClick(key) {
@@ -966,7 +1311,7 @@ function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commen
966
1311
  <span>maude</span>
967
1312
  </span>
968
1313
  <nav className="mb-menus" aria-label="Application menus">
969
- {MENU_NAMES.map(name => {
1314
+ {MENU_NAMES.map((name) => {
970
1315
  const key = name.toLowerCase();
971
1316
  const hasDropdown = key === 'view' || key === 'selection' || key === 'tools';
972
1317
  const interactive = hasDropdown || key === 'help';
@@ -991,7 +1336,7 @@ function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commen
991
1336
  {openMenu === 'view' && (
992
1337
  <ViewDropdown
993
1338
  panels={panels}
994
- onToggle={id => {
1339
+ onToggle={(id) => {
995
1340
  if (id === 'tree') onToggleSidebar();
996
1341
  else if (id === 'comments') onToggleComments();
997
1342
  else if (id === 'hidden') onToggleShowHidden();
@@ -1004,7 +1349,8 @@ function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commen
1004
1349
  <SelectionDropdown
1005
1350
  onAction={(id) => {
1006
1351
  if (id === 'deselect-all') postToActiveCanvas({ dgn: 'selection-clear' });
1007
- else if (id === 'select-all-annotations') postToActiveCanvas({ dgn: 'annotation-select-all' });
1352
+ else if (id === 'select-all-annotations')
1353
+ postToActiveCanvas({ dgn: 'annotation-select-all' });
1008
1354
  }}
1009
1355
  onClose={() => setOpenMenu(null)}
1010
1356
  />
@@ -1018,13 +1364,21 @@ function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commen
1018
1364
  <div className="mb-spacer" />
1019
1365
  <div className="mb-status">
1020
1366
  <span className="cv-stamp">{stamp}</span>
1021
- <span className="file" title={activePath || ''}>{fileLabel}</span>
1367
+ <span className="file" title={activePath || ''}>
1368
+ {fileLabel}
1369
+ </span>
1022
1370
  <span className="sep" />
1023
- <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>
1024
1374
  <span className="sep" />
1025
- <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>
1026
1378
  <span className="sep" />
1027
- <span className="ok"><b>{project || 'MDCC'}</b></span>
1379
+ <span className="ok">
1380
+ <b>{project || 'MDCC'}</b>
1381
+ </span>
1028
1382
  </div>
1029
1383
  </header>
1030
1384
  );
@@ -1033,7 +1387,8 @@ function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commen
1033
1387
  function ThemeToggle({ theme, onToggle }) {
1034
1388
  // Show the icon of the theme you'll switch TO — clearer affordance than current state.
1035
1389
  // Sun + Moon paths are condensed Lucide-style (single-path so the existing <Icon> works).
1036
- 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';
1037
1392
  const moon = 'M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z';
1038
1393
  const next = theme === 'dark' ? 'light' : 'dark';
1039
1394
  return (
@@ -1065,41 +1420,75 @@ function Wordmark({ project, port, version }) {
1065
1420
  );
1066
1421
  }
1067
1422
 
1068
- 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
+ }) {
1069
1433
  return (
1070
1434
  <div className="viewport">
1071
1435
  {tabs.length === 0 && (
1072
1436
  <>
1073
- <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
+ />
1074
1442
  <div className="empty-state">
1075
1443
  <div className="big">No mock open</div>
1076
1444
  <div className="small">
1077
- ← Click a <code>.tsx</code> (or legacy <code>.html</code>) file in the tree, or open the <strong>Design system</strong> view above it.
1078
- <br /><br />
1079
- Tabs work like in an editor — close with the × on each tab. <kbd>⌘R</kbd> reloads the active iframe.
1080
- <br /><br />
1081
- <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.
1082
- <br /><br />
1083
- 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>.
1084
1460
  </div>
1085
1461
  </div>
1086
1462
  </>
1087
1463
  )}
1088
- {tabs.map(t => {
1464
+ {tabs.map((t) => {
1089
1465
  if (t.path === SYSTEM_TAB) {
1090
1466
  return (
1091
1467
  <div key={t.path} className={'system-view' + (t.path === activePath ? ' active' : '')}>
1092
- <SystemView data={systemData} onOpen={onOpenFromSystem} cfg={cfg} onSelectDs={onSelectDs} />
1468
+ <SystemView
1469
+ data={systemData}
1470
+ onOpen={onOpenFromSystem}
1471
+ cfg={cfg}
1472
+ onSelectDs={onSelectDs}
1473
+ />
1093
1474
  </div>
1094
1475
  );
1095
1476
  }
1096
1477
  return (
1097
1478
  <iframe
1098
1479
  key={t.path}
1099
- ref={el => registerIframe(t.path, el)}
1480
+ ref={(el) => registerIframe(t.path, el)}
1100
1481
  src={canvasUrl(t.path, cfg)}
1101
1482
  className={t.path === activePath ? 'active' : ''}
1102
1483
  data-path={t.path}
1484
+ // T2 (9.1-A) — only sandbox + delegate clipboard when the canvas is
1485
+ // served cross-origin (canvasOrigin present = the split is on). In
1486
+ // the default same-origin mode these attrs are omitted so behavior
1487
+ // is identical to pre-9.1. allow-same-origin gives the cross-origin
1488
+ // frame its OWN origin (own WS/fetch/storage), NOT the parent's.
1489
+ {...(cfg?.canvasOrigin
1490
+ ? { sandbox: 'allow-scripts allow-same-origin', allow: 'clipboard-write' }
1491
+ : {})}
1103
1492
  />
1104
1493
  );
1105
1494
  })}
@@ -1119,7 +1508,15 @@ function Viewport({ tabs, activePath, registerIframe, systemData, onOpenFromSyst
1119
1508
  // Order kinds match the typical reading flow of a tokens file. Unknown kinds
1120
1509
  // fall through to `other` so a custom token group still renders, just last.
1121
1510
  const TOKEN_GROUP_ORDER = [
1122
- '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',
1123
1520
  ];
1124
1521
  const TOKEN_GROUP_LABELS = {
1125
1522
  color: 'colors',
@@ -1133,17 +1530,22 @@ const TOKEN_GROUP_LABELS = {
1133
1530
  other: 'other',
1134
1531
  };
1135
1532
 
1136
- function isSwatchKind(kind) { return kind === 'color'; }
1533
+ function isSwatchKind(kind) {
1534
+ return kind === 'color';
1535
+ }
1137
1536
 
1138
1537
  function TokenLadder({ tokens, tokenGroups, tokensPath }) {
1139
1538
  if (!tokens || tokens.length === 0) {
1140
1539
  return (
1141
1540
  <section className="sv-section sv-section-tokens">
1142
- <h2>tokens<span className="sv-h-num">0</span></h2>
1541
+ <h2>
1542
+ tokens<span className="sv-h-num">0</span>
1543
+ </h2>
1143
1544
  <div className="sv-empty">
1144
1545
  <p>
1145
- No tokens parsed from {tokensPath ? <code>{tokensPath}</code> : 'the configured tokens file'}.
1146
- 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>)?
1147
1549
  </p>
1148
1550
  </div>
1149
1551
  </section>
@@ -1151,9 +1553,9 @@ function TokenLadder({ tokens, tokenGroups, tokensPath }) {
1151
1553
  }
1152
1554
 
1153
1555
  const groups = tokenGroups || {};
1154
- const kinds = Array.from(
1155
- new Set([...TOKEN_GROUP_ORDER, ...Object.keys(groups)])
1156
- ).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
+ );
1157
1559
 
1158
1560
  return (
1159
1561
  <>
@@ -1192,9 +1594,11 @@ function findLeadingFor(typeToken, leadingTokens) {
1192
1594
  const m = typeToken.name.match(/^--(?:type|fs|text)-(.+)$/);
1193
1595
  if (!m) return null;
1194
1596
  const suffix = m[1];
1195
- return (leadingTokens || []).find((t) =>
1196
- /^--(?:lh|leading|line-height)-/.test(t.name) && t.name.endsWith('-' + suffix)
1197
- )?.value ?? null;
1597
+ return (
1598
+ (leadingTokens || []).find(
1599
+ (t) => /^--(?:lh|leading|line-height)-/.test(t.name) && t.name.endsWith('-' + suffix)
1600
+ )?.value ?? null
1601
+ );
1198
1602
  }
1199
1603
 
1200
1604
  // Best-effort sample font: prefer body / sans / display tokens, fall back to
@@ -1243,7 +1647,11 @@ function TypeLadder({ tokenGroups }) {
1243
1647
 
1244
1648
  function SystemView({ data, onOpen, cfg, onSelectDs }) {
1245
1649
  if (!data) {
1246
- 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
+ );
1247
1655
  }
1248
1656
  const {
1249
1657
  previewGallery,
@@ -1255,7 +1663,8 @@ function SystemView({ data, onOpen, cfg, onSelectDs }) {
1255
1663
  ds,
1256
1664
  availableDesignSystems,
1257
1665
  } = data;
1258
- const empty = (!previewGallery || !previewGallery.length) && (!uiKitsGallery || !uiKitsGallery.length);
1666
+ const empty =
1667
+ (!previewGallery || !previewGallery.length) && (!uiKitsGallery || !uiKitsGallery.length);
1259
1668
  const hasPicker = Array.isArray(availableDesignSystems) && availableDesignSystems.length > 1;
1260
1669
  const selectedName = ds?.name ?? availableDesignSystems?.[0]?.name ?? '';
1261
1670
 
@@ -1267,34 +1676,42 @@ function SystemView({ data, onOpen, cfg, onSelectDs }) {
1267
1676
  {hasPicker ? (
1268
1677
  <label className="sv-ds-picker">
1269
1678
  <span className="sv-ds-picker-label">DS</span>
1270
- <select
1271
- value={selectedName}
1272
- onChange={(e) => onSelectDs && onSelectDs(e.target.value)}
1273
- >
1679
+ <select value={selectedName} onChange={(e) => onSelectDs && onSelectDs(e.target.value)}>
1274
1680
  {availableDesignSystems.map((d) => (
1275
- <option key={d.name} value={d.name}>{d.name}</option>
1681
+ <option key={d.name} value={d.name}>
1682
+ {d.name}
1683
+ </option>
1276
1684
  ))}
1277
1685
  </select>
1278
1686
  </label>
1279
1687
  ) : null}
1280
- <span className="sv-loc"><code>{systemDir}</code></span>
1688
+ <span className="sv-loc">
1689
+ <code>{systemDir}</code>
1690
+ </span>
1281
1691
  </header>
1282
1692
 
1283
- {ds?.description ? (
1284
- <p className="sv-ds-description">{ds.description}</p>
1285
- ) : null}
1693
+ {ds?.description ? <p className="sv-ds-description">{ds.description}</p> : null}
1286
1694
 
1287
1695
  <TokenLadder tokens={tokens} tokenGroups={tokenGroups} tokensPath={tokensPath} />
1288
1696
  <TypeLadder tokenGroups={tokenGroups} />
1289
1697
 
1290
1698
  {empty ? (
1291
1699
  <div className="sv-empty">
1292
- <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>
1293
1704
  </div>
1294
1705
  ) : (
1295
1706
  <>
1296
- <Gallery title="preview" items={previewGallery} onOpen={onOpen} kind="preview" cfg={cfg} />
1297
- <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} />
1298
1715
  </>
1299
1716
  )}
1300
1717
  </div>
@@ -1305,12 +1722,19 @@ function Gallery({ title, items, onOpen, kind, cfg }) {
1305
1722
  if (!items || items.length === 0) return null;
1306
1723
  return (
1307
1724
  <section className="sv-section">
1308
- <h2>{title} <span className="sv-count">{items.length}</span></h2>
1725
+ <h2>
1726
+ {title} <span className="sv-count">{items.length}</span>
1727
+ </h2>
1309
1728
  <div className={'sv-previews sv-previews-' + kind}>
1310
- {items.map(p => (
1729
+ {items.map((p) => (
1311
1730
  <article key={p.path} className="sv-preview-card" onClick={() => onOpen(p.path)}>
1312
1731
  <div className="sv-preview-frame">
1313
- <iframe src={canvasUrl(p.path, cfg, { thumbnail: true })} title={p.label} scrolling="no" />
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
+ />
1314
1738
  </div>
1315
1739
  <div className="sv-preview-foot">
1316
1740
  <strong>{p.label}</strong>
@@ -1331,7 +1755,7 @@ function CommentBar({ activePath, comments }) {
1331
1755
  // bubbles + thread popover. BottomBar shrinks to a live open-count summary
1332
1756
  // so the shell still surfaces total review activity at a glance.
1333
1757
  if (!activePath) return null;
1334
- const openComments = (comments || []).filter(c => c.status !== 'resolved');
1758
+ const openComments = (comments || []).filter((c) => c.status !== 'resolved');
1335
1759
  if (openComments.length === 0) return null;
1336
1760
  return (
1337
1761
  <div className="comment-bar">
@@ -1352,26 +1776,53 @@ function StatusBarSlot({ label, children, className = '' }) {
1352
1776
  );
1353
1777
  }
1354
1778
 
1355
- function StatusBar({ activePath, selected, wsConnected, openCount, theme, onToggleTheme, onClearSelected }) {
1779
+ function StatusBar({
1780
+ activePath,
1781
+ selected,
1782
+ wsConnected,
1783
+ openCount,
1784
+ theme,
1785
+ onToggleTheme,
1786
+ onClearSelected,
1787
+ syncStatus,
1788
+ }) {
1356
1789
  const isSystem = activePath === SYSTEM_TAB;
1357
- const text = selected && selected.selector
1358
- ? selected.selector + (selected.text ? ` — "${selected.text.slice(0, 60)}"` : '')
1359
- : '';
1360
- 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
+ : '';
1361
1800
  return (
1362
1801
  <div className="statusbar" role="contentinfo">
1363
1802
  <StatusBarSlot label="Active file" className="sb-active">
1364
1803
  <span className="sb-key">active</span>
1365
1804
  <span className="sb-file" title={activePath || ''}>
1366
- {isSystem ? '▦ design system' : (activePath || '—')}
1805
+ {isSystem ? '▦ design system' : activePath || '—'}
1367
1806
  </span>
1368
1807
  </StatusBarSlot>
1369
1808
 
1370
1809
  {selected && selected.selector && !isSystem && (
1371
1810
  <StatusBarSlot label="Selected element" className="sb-selected">
1372
- <span className="sb-dot" aria-hidden="true">●</span>
1373
- <span className="sb-sel-text" title={title}>{text}</span>
1374
- <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>
1375
1826
  </StatusBarSlot>
1376
1827
  )}
1377
1828
 
@@ -1385,6 +1836,21 @@ function StatusBar({ activePath, selected, wsConnected, openCount, theme, onTogg
1385
1836
  <span className="sb-key">{wsConnected ? 'live' : 'reconnecting'}</span>
1386
1837
  </StatusBarSlot>
1387
1838
 
1839
+ {/* DDR-060 / 9.1-D — linked-to-hub-but-nothing-syncs state lives here in the
1840
+ status bar (DS-styled, hover for detail) instead of a floating off-brand
1841
+ pill. Only shown when the project is linked + has 0 syncable canvases. */}
1842
+ {syncStatus?.notSyncable && (
1843
+ <StatusBarSlot label="Hub sync" className="sb-sync">
1844
+ <span className="sb-sync-dot" aria-hidden="true" />
1845
+ <span
1846
+ className="sb-key"
1847
+ title={syncStatus.reason || 'Linked to a hub, but no canvases are syncable.'}
1848
+ >
1849
+ 0 syncable{syncStatus.tsxCount > 0 ? ` · ${syncStatus.tsxCount} tsx` : ''}
1850
+ </span>
1851
+ </StatusBarSlot>
1852
+ )}
1853
+
1388
1854
  <span className="sb-spacer" />
1389
1855
 
1390
1856
  <StatusBarSlot label="Theme" className="sb-theme">
@@ -1396,26 +1862,36 @@ function StatusBar({ activePath, selected, wsConnected, openCount, theme, onTogg
1396
1862
 
1397
1863
  // ---------- Right sidebar — Comments panel ----------
1398
1864
 
1399
- 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
+ }) {
1400
1876
  const counts = totalCounts(commentsByFile);
1401
1877
  // Build groups: [{ file, comments: filtered }]
1402
1878
  const files = Object.keys(commentsByFile || {}).sort();
1403
1879
  const groups = [];
1404
1880
  for (const f of files) {
1405
1881
  const all = commentsByFile[f] || [];
1406
- const filtered = all.filter(c => {
1882
+ const filtered = all.filter((c) => {
1407
1883
  if (filter === 'open') return c.status !== 'resolved';
1408
1884
  if (filter === 'resolved') return c.status === 'resolved';
1409
1885
  return true;
1410
1886
  });
1411
1887
  if (filtered.length === 0) continue;
1412
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)
1413
- const numberedAll = all.filter(c => c.selector);
1889
+ const numberedAll = all.filter((c) => c.selector);
1414
1890
  groups.push({
1415
1891
  file: f,
1416
- comments: filtered.map(c => ({
1892
+ comments: filtered.map((c) => ({
1417
1893
  ...c,
1418
- n: numberedAll.findIndex(x => x.id === c.id) + 1,
1894
+ n: numberedAll.findIndex((x) => x.id === c.id) + 1,
1419
1895
  })),
1420
1896
  });
1421
1897
  }
@@ -1430,66 +1906,177 @@ function CommentsPanel({ commentsByFile, filter, setFilter, activePath, focusedI
1430
1906
  <div className="rsidebar-filters" role="tablist">
1431
1907
  <button
1432
1908
  className={'rsidebar-filter' + (filter === 'all' ? ' active' : '')}
1433
- role="tab" aria-selected={filter === 'all'}
1909
+ role="tab"
1910
+ aria-selected={filter === 'all'}
1434
1911
  onClick={() => setFilter('all')}
1435
- >All <span className="fc">{counts.all}</span></button>
1912
+ >
1913
+ All <span className="fc">{counts.all}</span>
1914
+ </button>
1436
1915
  <button
1437
1916
  className={'rsidebar-filter' + (filter === 'open' ? ' active' : '')}
1438
- role="tab" aria-selected={filter === 'open'}
1917
+ role="tab"
1918
+ aria-selected={filter === 'open'}
1439
1919
  onClick={() => setFilter('open')}
1440
- >Open <span className="fc">{counts.open}</span></button>
1920
+ >
1921
+ Open <span className="fc">{counts.open}</span>
1922
+ </button>
1441
1923
  <button
1442
1924
  className={'rsidebar-filter' + (filter === 'resolved' ? ' active' : '')}
1443
- role="tab" aria-selected={filter === 'resolved'}
1925
+ role="tab"
1926
+ aria-selected={filter === 'resolved'}
1444
1927
  onClick={() => setFilter('resolved')}
1445
- >Resolved <span className="fc">{counts.resolved}</span></button>
1928
+ >
1929
+ Resolved <span className="fc">{counts.resolved}</span>
1930
+ </button>
1446
1931
  </div>
1447
1932
  </div>
1448
1933
  <div className="rsidebar-body">
1449
1934
  {groups.length === 0 ? (
1450
1935
  <div className="rsidebar-empty">
1451
1936
  <p>No comments {filter !== 'all' ? `with status “${filter}”` : 'yet'}.</p>
1452
- <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>
1453
1941
  </div>
1454
- ) : groups.map(g => (
1455
- <div key={g.file} className="rs-group">
1456
- <button
1457
- className="rs-group-h"
1458
- onClick={() => onJump(g.file, null)}
1459
- title={g.file}
1460
- >
1461
- <span className="rs-group-name">{displayName(basename(g.file))}</span>
1462
- <span className="rs-group-count">{g.comments.length}</span>
1463
- </button>
1464
- {g.comments.map(c => (
1465
- <div
1466
- key={c.id}
1467
- className={'rs-comment' + (c.status === 'resolved' ? ' resolved' : '') + (c.id === focusedId ? ' active-pin' : '')}
1468
- onClick={() => onJump(g.file, c.id)}
1469
- >
1470
- <div className="rs-comment-head">
1471
- <span className="rs-num">{c.n || '·'}</span>
1472
- <span className="rs-time">{timeAgo(c.created)}</span>
1473
- </div>
1474
- <div className="rs-comment-text">{c.text}</div>
1475
- <div className="rs-comment-foot">
1476
- <code title={(c.dom_path || []).join(' > ')}>{c.selector || '—'}</code>
1477
- <div className="rs-comment-actions">
1478
- {c.status === 'resolved'
1479
- ? <button className="rs-act" onClick={e => { e.stopPropagation(); onReopen(c.id); }}>↺</button>
1480
- : <button className="rs-act" onClick={e => { e.stopPropagation(); onResolve(c.id); }}>✓</button>}
1481
- <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>
1482
1998
  </div>
1483
1999
  </div>
1484
- </div>
1485
- ))}
1486
- </div>
1487
- ))}
2000
+ ))}
2001
+ </div>
2002
+ ))
2003
+ )}
1488
2004
  </div>
1489
2005
  </aside>
1490
2006
  );
1491
2007
  }
1492
2008
 
2009
+ // ---------- Sync banner (Phase 9 Task 8 — hub-down offline mode) ----------
2010
+
2011
+ // Renders nothing when online with no flash (the common case). Yellow strip
2012
+ // while offline (with queued-edit count), red when offline > 24h, green flash
2013
+ // for 3s right after a reconnect. Driven entirely by the 'sync:status' payload
2014
+ // the dev-server's linked-mode sync runtime broadcasts.
2015
+ function SyncBanner({ status }) {
2016
+ if (!status || status.linked === false) return null;
2017
+ // DDR-060 / 9.1-D — the "linked but 0 syncable" state is surfaced in the
2018
+ // status bar (sb-sync slot), NOT as a floating banner. This component owns
2019
+ // only the transient offline / reconnect-flash banner (Task 8).
2020
+ if (status.notSyncable) return null;
2021
+ const { state, queuedOps, flash, conflicts } = status;
2022
+ const showFlash = flash === 'synced';
2023
+ const offline = state === 'offline' || state === 'offline-long';
2024
+ if (!offline && !showFlash) return null;
2025
+
2026
+ let bg;
2027
+ let fg;
2028
+ let border;
2029
+ let text;
2030
+ if (showFlash) {
2031
+ bg = '#dcfce7';
2032
+ fg = '#166534';
2033
+ border = '#86efac';
2034
+ text = 'Synced with hub';
2035
+ } else if (state === 'offline-long') {
2036
+ bg = '#fee2e2';
2037
+ fg = '#991b1b';
2038
+ border = '#fca5a5';
2039
+ text = `Long offline — ${queuedOps} edit(s) queued. Consider \`git commit && git push\` as backup.`;
2040
+ } else {
2041
+ bg = '#fef9c3';
2042
+ fg = '#854d0e';
2043
+ border = '#fde047';
2044
+ text = `Working offline · ${queuedOps} edit(s) queued · will sync when the hub reconnects.`;
2045
+ }
2046
+ const conflictNote =
2047
+ conflicts && conflicts.length > 0 ? ` (${conflicts.length} conflict notice(s))` : '';
2048
+
2049
+ return (
2050
+ <div
2051
+ role="status"
2052
+ aria-live="polite"
2053
+ style={{
2054
+ position: 'fixed',
2055
+ top: 12,
2056
+ left: '50%',
2057
+ transform: 'translateX(-50%)',
2058
+ zIndex: 10003,
2059
+ display: 'flex',
2060
+ alignItems: 'center',
2061
+ gap: 10,
2062
+ padding: '8px 14px',
2063
+ background: bg,
2064
+ color: fg,
2065
+ border: `1px solid ${border}`,
2066
+ borderRadius: 999,
2067
+ boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
2068
+ font: '500 13px/1.2 system-ui, -apple-system, sans-serif',
2069
+ maxWidth: '80vw',
2070
+ }}
2071
+ >
2072
+ <span>
2073
+ {text}
2074
+ {conflictNote}
2075
+ </span>
2076
+ </div>
2077
+ );
2078
+ }
2079
+
1493
2080
  // ---------- App ----------
1494
2081
 
1495
2082
  function App() {
@@ -1503,6 +2090,9 @@ function App() {
1503
2090
  // every dirty Y.Doc to disk by the time this state populates, so accepting
1504
2091
  // the reload is data-loss-safe (DDR-051 §3).
1505
2092
  const [gitLifecycle, setGitLifecycle] = useState(null);
2093
+ // Phase 9 Task 8 — hub-down offline mode banner. Driven by the 'sync:status'
2094
+ // WS message the linked-mode sync runtime emits. null in solo mode.
2095
+ const [syncStatus, setSyncStatus] = useState(null);
1506
2096
  const [search, setSearch] = useState('');
1507
2097
  const [systemData, setSystemData] = useState(null);
1508
2098
  // Loaded once at boot from /_config — informs canvasUrl() so TSX iframes
@@ -1523,17 +2113,41 @@ function App() {
1523
2113
  // legacy default; designSystems[0].tokensCssRel is the project's
1524
2114
  // authoritative value (post DS-bootstrap).
1525
2115
  designSystems: data.designSystems,
2116
+ // T2 (9.1-A) — segregated canvas-content origin. canvasUrl() prepends
2117
+ // it so iframes load cross-origin (hub-pushed JSX is then walled off
2118
+ // from the main origin's /_api). Absent on older servers → relative
2119
+ // URL fallback keeps same-origin behavior.
2120
+ canvasOrigin: data.canvasOrigin,
1526
2121
  });
1527
2122
  })
1528
2123
  .catch(() => {});
1529
- return () => { cancelled = true; };
2124
+ return () => {
2125
+ cancelled = true;
2126
+ };
1530
2127
  }, []);
1531
- const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
2128
+ // Backfill the sync banner on mount from /_sync-status. The 'sync:status' WS
2129
+ // broadcast is one-shot for the zero-syncable case (DDR-060 / 9.1-D), so a
2130
+ // tab that connects after boot would otherwise miss it. {linked:false} (solo)
2131
+ // leaves the banner null.
2132
+ useEffect(() => {
2133
+ let cancelled = false;
2134
+ fetch('/_sync-status')
2135
+ .then((r) => r.json())
2136
+ .then((data) => {
2137
+ if (cancelled || !data || data.linked === false) return;
2138
+ setSyncStatus(data);
2139
+ })
2140
+ .catch(() => {});
2141
+ return () => {
2142
+ cancelled = true;
2143
+ };
2144
+ }, []);
2145
+ const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
1532
2146
  // Phase 6 — the in-iframe composer owns drafting; the shell no longer holds
1533
2147
  // a `draft` state. Mutations route through postMessage → WS instead.
1534
2148
  const [focusedCommentId, setFocusedCommentId] = useState(null);
1535
2149
  const [commentsPanelOpen, setCommentsPanelOpen] = useState(false);
1536
- const [commentsFilter, setCommentsFilter] = useState('open'); // 'all' | 'open' | 'resolved'
2150
+ const [commentsFilter, setCommentsFilter] = useState('open'); // 'all' | 'open' | 'resolved'
1537
2151
  const [theme, setTheme] = useState(readInitialTheme);
1538
2152
  const [openMenu, setOpenMenu] = useState(null);
1539
2153
  const [sidebarOpen, setSidebarOpen] = useState(() => readBoolStore(SIDEBAR_STORE, true));
@@ -1548,18 +2162,25 @@ function App() {
1548
2162
  // The canvas-shell listens for these `dgn:*` messages and dispatches into the
1549
2163
  // matching local provider (annotations visibility / both selection stores /
1550
2164
  // tool mode). Mirrors the existing `force-clear` / `select-clear` channel.
1551
- const postToActiveCanvas = useCallback((payload) => {
1552
- const el = activePath ? iframesRef.current.get(activePath) : null;
1553
- if (!el || !el.contentWindow) return;
1554
- try { el.contentWindow.postMessage(payload, '*'); } catch {}
1555
- }, [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
+ );
1556
2175
 
1557
2176
  const toggleAnnotations = useCallback(() => {
1558
2177
  setAnnotationsVisible((v) => {
1559
2178
  const next = !v;
1560
2179
  const el = activePath ? iframesRef.current.get(activePath) : null;
1561
2180
  if (el && el.contentWindow) {
1562
- try { el.contentWindow.postMessage({ dgn: 'view-annotations', visible: next }, '*'); } catch {}
2181
+ try {
2182
+ el.contentWindow.postMessage({ dgn: 'view-annotations', visible: next }, '*');
2183
+ } catch {}
1563
2184
  }
1564
2185
  return next;
1565
2186
  });
@@ -1571,21 +2192,40 @@ function App() {
1571
2192
  document.documentElement.setAttribute('data-theme', theme);
1572
2193
  localStorage.setItem(THEME_STORE, theme);
1573
2194
  } catch {}
2195
+ // System-review D9 — the canvas-shell chrome (workspace plane, floating
2196
+ // toolbar, minimap, zoom HUD, halos) follows the Maude theme. Broadcast to
2197
+ // EVERY open canvas iframe (not just activePath — several may be open); the
2198
+ // iframe's canvas-shell sets `data-maude-theme` and re-themes its floating
2199
+ // chrome via the --maude-chrome-* family. Artboards keep their DS theme.
2200
+ // Mirrors the git-lifecycle broadcast-to-all loop below. On the initial
2201
+ // mount run iframesRef is empty (no canvas open yet) — a freshly-loaded
2202
+ // iframe instead gets the current theme from the `dgn:'loaded'` handler.
2203
+ for (const el of iframesRef.current.values()) {
2204
+ try {
2205
+ el.contentWindow.postMessage({ dgn: 'theme', theme }, '*');
2206
+ } catch {}
2207
+ }
1574
2208
  }, [theme]);
1575
2209
 
1576
2210
  // Persist sidebar / hidden-files / DS-body toggles. Mirror theme pattern.
1577
2211
  useEffect(() => {
1578
- try { localStorage.setItem(SIDEBAR_STORE, sidebarOpen ? '1' : '0'); } catch {}
2212
+ try {
2213
+ localStorage.setItem(SIDEBAR_STORE, sidebarOpen ? '1' : '0');
2214
+ } catch {}
1579
2215
  }, [sidebarOpen]);
1580
2216
  useEffect(() => {
1581
- try { localStorage.setItem(SHOW_HIDDEN_STORE, showHidden ? '1' : '0'); } catch {}
2217
+ try {
2218
+ localStorage.setItem(SHOW_HIDDEN_STORE, showHidden ? '1' : '0');
2219
+ } catch {}
1582
2220
  }, [showHidden]);
1583
2221
  useEffect(() => {
1584
- try { localStorage.setItem(SECTIONS_STORE, JSON.stringify(sectionsExpanded)); } catch {}
2222
+ try {
2223
+ localStorage.setItem(SECTIONS_STORE, JSON.stringify(sectionsExpanded));
2224
+ } catch {}
1585
2225
  }, [sectionsExpanded]);
1586
2226
 
1587
2227
  const toggleSection = useCallback((label, defaultOpen) => {
1588
- setSectionsExpanded(prev => {
2228
+ setSectionsExpanded((prev) => {
1589
2229
  const cur = prev[label];
1590
2230
  const isOpen = cur === undefined ? defaultOpen : cur;
1591
2231
  return { ...prev, [label]: !isOpen };
@@ -1593,7 +2233,7 @@ function App() {
1593
2233
  }, []);
1594
2234
 
1595
2235
  const toggleTheme = useCallback(() => {
1596
- setTheme(t => (t === 'dark' ? 'light' : 'dark'));
2236
+ setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
1597
2237
  }, []);
1598
2238
 
1599
2239
  // ----- Tree -----
@@ -1602,7 +2242,7 @@ function App() {
1602
2242
  const r = await fetch('/_index-data');
1603
2243
  const data = await r.json();
1604
2244
  setProject(data.project || 'Design');
1605
- const built = data.groups.map(g => ({
2245
+ const built = data.groups.map((g) => ({
1606
2246
  ...g,
1607
2247
  tree: buildTree(g.paths, g.stripPrefix),
1608
2248
  }));
@@ -1612,7 +2252,9 @@ function App() {
1612
2252
  }
1613
2253
  }, []);
1614
2254
 
1615
- useEffect(() => { loadTree(); }, [loadTree]);
2255
+ useEffect(() => {
2256
+ loadTree();
2257
+ }, [loadTree]);
1616
2258
 
1617
2259
  // ----- System data (lazy) -----
1618
2260
  // `dsName` scopes to a single design-system entry (DDR-048). The initial
@@ -1655,7 +2297,9 @@ function App() {
1655
2297
  }
1656
2298
  }, []);
1657
2299
 
1658
- useEffect(() => { loadAllComments(); }, [loadAllComments]);
2300
+ useEffect(() => {
2301
+ loadAllComments();
2302
+ }, [loadAllComments]);
1659
2303
 
1660
2304
  // ----- WebSocket -----
1661
2305
  useEffect(() => {
@@ -1669,7 +2313,7 @@ function App() {
1669
2313
  setTimeout(connect, 1000);
1670
2314
  });
1671
2315
  ws.addEventListener('error', () => {});
1672
- ws.addEventListener('message', e => {
2316
+ ws.addEventListener('message', (e) => {
1673
2317
  try {
1674
2318
  const m = JSON.parse(e.data);
1675
2319
  if (m.type === 'snapshot' && m.state) {
@@ -1677,14 +2321,22 @@ function App() {
1677
2321
  } else if (m.type === 'selected') {
1678
2322
  setSelected(m.selected);
1679
2323
  } else if (m.type === 'comments' && typeof m.file === 'string') {
1680
- setCommentsByFile(prev => ({ ...prev, [m.file]: m.comments || [] }));
2324
+ setCommentsByFile((prev) => ({ ...prev, [m.file]: m.comments || [] }));
1681
2325
  } else if (m.type === 'ai-activity' && typeof m.file === 'string') {
1682
2326
  // Phase 8 Task 4 — relay to every open iframe; each canvas's
1683
2327
  // AiBanner filters by its own file path. Lightweight broadcast
1684
2328
  // (one envelope per change, not per iframe count).
1685
2329
  for (const el of iframesRef.current.values()) {
1686
- 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 {}
1687
2336
  }
2337
+ } else if (m.type === 'sync:status' && m.payload) {
2338
+ // Phase 9 Task 8 — hub connection state for the offline banner.
2339
+ setSyncStatus(m.payload);
1688
2340
  } else if (m.type === 'git-lifecycle' && m.payload) {
1689
2341
  // Phase 8 Task 7 — branch switch / pull mid-session. Server has
1690
2342
  // already flushed every dirty Y.Doc to JSON; just prompt the user.
@@ -1693,7 +2345,9 @@ function App() {
1693
2345
  // Also relay to iframes so canvas-level "Reload?" UI (if any)
1694
2346
  // can react. Outer banner is the primary prompt.
1695
2347
  for (const el of iframesRef.current.values()) {
1696
- 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 {}
1697
2351
  }
1698
2352
  }
1699
2353
  } catch {}
@@ -1705,7 +2359,9 @@ function App() {
1705
2359
 
1706
2360
  function wsSend(obj) {
1707
2361
  const ws = wsRef.current;
1708
- 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 {}
1709
2365
  }
1710
2366
 
1711
2367
  // ----- Tab management (single-canvas) -----
@@ -1714,7 +2370,7 @@ function App() {
1714
2370
  // (iframesRef, comments push, WS `tabs` message) doesn't need refactoring.
1715
2371
  // ARTBOARDS slot in the menubar reads `tabs.length` and reports 0 or 1.
1716
2372
  const openTab = useCallback((path) => {
1717
- setTabs(prev => {
2373
+ setTabs((prev) => {
1718
2374
  // Drop the previously-open iframe so we don't leak DOM nodes.
1719
2375
  for (const t of prev) if (t.path !== path) iframesRef.current.delete(t.path);
1720
2376
  return [{ path }];
@@ -1723,18 +2379,21 @@ function App() {
1723
2379
  setFocusedCommentId(null);
1724
2380
  }, []);
1725
2381
 
1726
- const openSystem = useCallback((dsName) => {
1727
- // DsFolderRow passes the clicked DS name → scope the System view to it so
1728
- // each folder shows its own tokens + previews. The no-arg callers (menubar,
1729
- // keyboard reopen) only load default data on first open.
1730
- const ds = typeof dsName === 'string' ? dsName : undefined;
1731
- if (ds) loadSystemData(ds);
1732
- else if (!systemData) loadSystemData();
1733
- openTab(SYSTEM_TAB);
1734
- }, [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
+ );
1735
2394
 
1736
2395
  useEffect(() => {
1737
- 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) });
1738
2397
  }, [tabs]);
1739
2398
 
1740
2399
  useEffect(() => {
@@ -1743,19 +2402,22 @@ function App() {
1743
2402
  else wsSend({ type: 'active', file: '' });
1744
2403
  }, [activePath]);
1745
2404
 
1746
- const closeTab = useCallback((path) => {
1747
- setTabs(prev => {
1748
- const idx = prev.findIndex(t => t.path === path);
1749
- if (idx < 0) return prev;
1750
- const next = prev.filter(t => t.path !== path);
1751
- if (path === activePath) {
1752
- if (next.length === 0) setActivePath(null);
1753
- else setActivePath(next[Math.max(0, idx - 1)].path);
1754
- }
1755
- return next;
1756
- });
1757
- iframesRef.current.delete(path);
1758
- }, [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
+ );
1759
2421
 
1760
2422
  const reloadActive = useCallback(() => {
1761
2423
  if (!activePath || activePath === SYSTEM_TAB) {
@@ -1774,7 +2436,9 @@ function App() {
1774
2436
  if (activePath && activePath !== SYSTEM_TAB) {
1775
2437
  const el = iframesRef.current.get(activePath);
1776
2438
  if (el && el.contentWindow) {
1777
- try { el.contentWindow.postMessage({ dgn: 'force-clear' }, '*'); } catch {}
2439
+ try {
2440
+ el.contentWindow.postMessage({ dgn: 'force-clear' }, '*');
2441
+ } catch {}
1778
2442
  }
1779
2443
  }
1780
2444
  }, [activePath]);
@@ -1785,14 +2449,47 @@ function App() {
1785
2449
  const el = iframesRef.current.get(activePath);
1786
2450
  if (!el || !el.contentWindow) return;
1787
2451
  const list = commentsByFile[activePath] || [];
1788
- try { el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*'); } catch {}
2452
+ try {
2453
+ el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*');
2454
+ } catch {}
1789
2455
  }, [activePath, commentsByFile]);
1790
2456
 
1791
2457
  // ----- Inbound messages from iframes -----
1792
2458
  useEffect(() => {
1793
2459
  function onMessage(e) {
2460
+ // Cross-origin hardening (DDR-054): only accept dgn control messages from
2461
+ // the canvas-content origin — the split origin when on, else our own origin
2462
+ // for the same-origin iframe. Drops spoofed messages from any other window.
2463
+ // The handlers below relay to inert stores (comments / selection — the
2464
+ // "safe to sync" set), so the blast radius was small, but unchecked inbound
2465
+ // postMessage is a confused-deputy seam the F1 hardening should close.
2466
+ const expectedOrigin = cfg?.canvasOrigin || window.location.origin;
2467
+ if (e.origin !== expectedOrigin) return;
1794
2468
  const m = e.data;
1795
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
+ }
1796
2493
  if (m.dgn === 'select' && m.selection) {
1797
2494
  wsSend({ type: 'select', selection: m.selection });
1798
2495
  setSelected(m.selection);
@@ -1853,7 +2550,7 @@ function App() {
1853
2550
  wsSend({ type: 'comments-patch', id: m.id, patch: m.patch });
1854
2551
  } else if (m.dgn === 'comment-delete' && m.id) {
1855
2552
  wsSend({ type: 'comments-delete', id: m.id });
1856
- setFocusedCommentId(prev => (prev === m.id ? null : prev));
2553
+ setFocusedCommentId((prev) => (prev === m.id ? null : prev));
1857
2554
  } else if (m.dgn === 'comment-click' && m.id) {
1858
2555
  setFocusedCommentId(m.id);
1859
2556
  } else if (m.dgn === 'loaded' && m.file) {
@@ -1861,16 +2558,90 @@ function App() {
1861
2558
  const list = commentsByFile[m.file] || [];
1862
2559
  const el = [...iframesRef.current.entries()].find(([k]) => k === m.file)?.[1];
1863
2560
  if (el && el.contentWindow) {
1864
- try { el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*'); } catch {}
1865
- if (focusedCommentId && list.some(c => c.id === focusedCommentId)) {
1866
- try { el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*'); } catch {}
2561
+ try {
2562
+ el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*');
2563
+ } catch {}
2564
+ // System-review D9 — seed the just-loaded canvas with the current
2565
+ // chrome theme so a canvas opened AFTER a theme toggle starts
2566
+ // correct (no flash from the dark default).
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 {}
1867
2574
  }
1868
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);
1869
2590
  }
1870
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) });
2625
+ }
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
+ }
1871
2642
  window.addEventListener('message', onMessage);
1872
2643
  return () => window.removeEventListener('message', onMessage);
1873
- }, [commentsByFile, focusedCommentId]);
2644
+ }, [commentsByFile, focusedCommentId, cfg, theme]);
1874
2645
 
1875
2646
  // Tell the active canvas iframe to drop any persistent selection (canvas
1876
2647
  // SelectionSet) — used when the comment composer closes via submit /
@@ -1880,7 +2651,9 @@ function App() {
1880
2651
  if (!activePath || activePath === SYSTEM_TAB) return;
1881
2652
  const el = iframesRef.current.get(activePath);
1882
2653
  if (el && el.contentWindow) {
1883
- try { el.contentWindow.postMessage({ dgn: 'force-clear' }, '*'); } catch {}
2654
+ try {
2655
+ el.contentWindow.postMessage({ dgn: 'force-clear' }, '*');
2656
+ } catch {}
1884
2657
  }
1885
2658
  }, [activePath]);
1886
2659
 
@@ -1892,37 +2665,44 @@ function App() {
1892
2665
  }, []);
1893
2666
  const deleteComment = useCallback((id) => {
1894
2667
  wsSend({ type: 'comments-delete', id });
1895
- setFocusedCommentId(prev => (prev === id ? null : prev));
2668
+ setFocusedCommentId((prev) => (prev === id ? null : prev));
1896
2669
  }, []);
1897
2670
 
1898
2671
  // Jump from right-sidebar list to a comment: open file tab if needed, focus pin.
1899
2672
  // The iframe may be freshly mounted; the loaded handler also re-sends focus if focusedCommentId matches.
1900
- const jumpToComment = useCallback((file, id) => {
1901
- if (file && file !== activePath) {
1902
- setTabs(prev => prev.find(t => t.path === file) ? prev : [...prev, { path: file }]);
1903
- setActivePath(file);
1904
- }
1905
- if (id == null) {
1906
- setFocusedCommentId(null);
1907
- return;
1908
- }
1909
- setFocusedCommentId(id);
1910
- // Try sending focus immediately (existing iframe) and again after a short delay (newly opened tab).
1911
- const send = () => {
1912
- const el = iframesRef.current.get(file);
1913
- if (el && el.contentWindow) {
1914
- 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);
1915
2678
  }
1916
- };
1917
- send();
1918
- setTimeout(send, 200);
1919
- }, [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
+ );
1920
2698
 
1921
2699
  // ----- Keyboard shortcuts (no Cmd+W — let browser close the tab) -----
1922
2700
  useEffect(() => {
1923
2701
  function onKey(e) {
1924
2702
  const meta = e.metaKey || e.ctrlKey;
1925
- 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;
1926
2706
  // Phase 4.1: shell-side letter shortcuts (H/T/S) must not double-fire
1927
2707
  // inside a focused canvas iframe — the canvas input router owns those
1928
2708
  // letters as tool-mode keys (V/H/C). Cmd-modified shortcuts (⌘R, ⌘⇧M,
@@ -1938,7 +2718,7 @@ function App() {
1938
2718
  // Cmd+Shift+M / Ctrl+Shift+M — toggle right "Comments" panel
1939
2719
  if (meta && e.shiftKey && (e.key === 'm' || e.key === 'M')) {
1940
2720
  e.preventDefault();
1941
- setCommentsPanelOpen(v => !v);
2721
+ setCommentsPanelOpen((v) => !v);
1942
2722
  return;
1943
2723
  }
1944
2724
  // Cmd+C / Ctrl+C — Phase 4.1 removed the shell-side comment-drop chord.
@@ -1946,8 +2726,18 @@ function App() {
1946
2726
  // then click the element) or right-click "Add comment". Cmd+C now
1947
2727
  // reverts to native browser copy.
1948
2728
  if (meta && !e.shiftKey && !e.altKey && (e.key === 'c' || e.key === 'C')) {
1949
- if (selected && selected.selector && activePath && activePath !== SYSTEM_TAB && !inEditable && console && console.warn) {
1950
- 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
+ );
1951
2741
  }
1952
2742
  // Fall through to native copy.
1953
2743
  }
@@ -1980,14 +2770,14 @@ function App() {
1980
2770
  if (e.key === 't' || e.key === 'T') {
1981
2771
  if (e.shiftKey || meta) return;
1982
2772
  e.preventDefault();
1983
- setSidebarOpen(v => !v);
2773
+ setSidebarOpen((v) => !v);
1984
2774
  return;
1985
2775
  }
1986
2776
  // H — toggle show-hidden (sidecars + project/runtime orphans)
1987
2777
  if (e.key === 'h' || e.key === 'H') {
1988
2778
  if (e.shiftKey || meta) return;
1989
2779
  e.preventDefault();
1990
- setShowHidden(v => !v);
2780
+ setShowHidden((v) => !v);
1991
2781
  return;
1992
2782
  }
1993
2783
  // S — toggle Design system view
@@ -2009,18 +2799,31 @@ function App() {
2009
2799
  // Esc — clear focused pin. The in-place composer (Phase 6) and thread
2010
2800
  // popover handle their own Esc inside the iframe.
2011
2801
  if (e.key === 'Escape') {
2012
- if (focusedCommentId) { setFocusedCommentId(null); return; }
2802
+ if (focusedCommentId) {
2803
+ setFocusedCommentId(null);
2804
+ return;
2805
+ }
2013
2806
  }
2014
2807
  }
2015
2808
  window.addEventListener('keydown', onKey);
2016
2809
  return () => window.removeEventListener('keydown', onKey);
2017
- }, [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
+ ]);
2018
2820
 
2019
2821
  const registerIframe = useCallback((path, el) => {
2020
2822
  if (el) iframesRef.current.set(path, el);
2021
2823
  }, []);
2022
2824
 
2023
- const activeFileComments = (activePath && activePath !== SYSTEM_TAB) ? (commentsByFile[activePath] || []) : [];
2825
+ const activeFileComments =
2826
+ activePath && activePath !== SYSTEM_TAB ? commentsByFile[activePath] || [] : [];
2024
2827
  const totalOpen = totalCounts(commentsByFile).open;
2025
2828
 
2026
2829
  // Suppress the native browser context menu across the shell — the canvas
@@ -2031,7 +2834,7 @@ function App() {
2031
2834
  // copy/paste still works.
2032
2835
  const onShellContextMenu = useCallback((e) => {
2033
2836
  const t = e.target;
2034
- if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || (t.isContentEditable))) {
2837
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
2035
2838
  return;
2036
2839
  }
2037
2840
  e.preventDefault();
@@ -2039,9 +2842,12 @@ function App() {
2039
2842
 
2040
2843
  return (
2041
2844
  <div
2042
- className={'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')}
2845
+ className={
2846
+ 'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')
2847
+ }
2043
2848
  onContextMenu={onShellContextMenu}
2044
2849
  >
2850
+ <SyncBanner status={syncStatus} />
2045
2851
  {gitLifecycle && (
2046
2852
  <div
2047
2853
  role="status"
@@ -2067,7 +2873,11 @@ function App() {
2067
2873
  <span>Repo state changed — reload to sync?</span>
2068
2874
  <button
2069
2875
  type="button"
2070
- onClick={() => { try { window.location.reload(); } catch {} }}
2876
+ onClick={() => {
2877
+ try {
2878
+ window.location.reload();
2879
+ } catch {}
2880
+ }}
2071
2881
  style={{
2072
2882
  padding: '3px 10px',
2073
2883
  background: '#2563eb',
@@ -2077,7 +2887,9 @@ function App() {
2077
2887
  font: '500 11px/1.2 system-ui',
2078
2888
  cursor: 'pointer',
2079
2889
  }}
2080
- >Reload</button>
2890
+ >
2891
+ Reload
2892
+ </button>
2081
2893
  <button
2082
2894
  type="button"
2083
2895
  onClick={() => setGitLifecycle(null)}
@@ -2090,7 +2902,9 @@ function App() {
2090
2902
  font: '500 11px/1.2 system-ui',
2091
2903
  cursor: 'pointer',
2092
2904
  }}
2093
- >Dismiss</button>
2905
+ >
2906
+ Dismiss
2907
+ </button>
2094
2908
  </div>
2095
2909
  )}
2096
2910
  <Sidebar
@@ -2115,12 +2929,12 @@ function App() {
2115
2929
  openMenu={openMenu}
2116
2930
  setOpenMenu={setOpenMenu}
2117
2931
  commentsPanelOpen={commentsPanelOpen}
2118
- onToggleComments={() => setCommentsPanelOpen(v => !v)}
2932
+ onToggleComments={() => setCommentsPanelOpen((v) => !v)}
2119
2933
  onOpenSystem={openSystem}
2120
2934
  sidebarOpen={sidebarOpen}
2121
- onToggleSidebar={() => setSidebarOpen(v => !v)}
2935
+ onToggleSidebar={() => setSidebarOpen((v) => !v)}
2122
2936
  showHidden={showHidden}
2123
- onToggleShowHidden={() => setShowHidden(v => !v)}
2937
+ onToggleShowHidden={() => setShowHidden((v) => !v)}
2124
2938
  onOpenHelp={() => setHelpOpen(true)}
2125
2939
  annotationsVisible={annotationsVisible}
2126
2940
  onToggleAnnotations={toggleAnnotations}
@@ -2147,6 +2961,7 @@ function App() {
2147
2961
  theme={theme}
2148
2962
  onToggleTheme={toggleTheme}
2149
2963
  onClearSelected={clearSelected}
2964
+ syncStatus={syncStatus}
2150
2965
  />
2151
2966
  </div>
2152
2967
  {commentsPanelOpen && (