@1agh/maude 0.15.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 (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +166 -0
  3. package/cli/bin/maude.exe +15 -0
  4. package/cli/bin/maude.mjs +45 -0
  5. package/cli/bin/mdcc.exe +10 -0
  6. package/cli/bin/mdcc.mjs +7 -0
  7. package/cli/cli-wrapper.cjs +67 -0
  8. package/cli/commands/config.mjs +94 -0
  9. package/cli/commands/design.mjs +386 -0
  10. package/cli/commands/help.mjs +57 -0
  11. package/cli/commands/init.mjs +178 -0
  12. package/cli/commands/version.mjs +7 -0
  13. package/cli/install.cjs +113 -0
  14. package/cli/lib/argv.mjs +37 -0
  15. package/cli/lib/argv.test.mjs +46 -0
  16. package/cli/lib/copy-tree.mjs +78 -0
  17. package/package.json +94 -0
  18. package/plugins/design/dev-server/annotations-context-toolbar.tsx +397 -0
  19. package/plugins/design/dev-server/annotations-layer.tsx +1717 -0
  20. package/plugins/design/dev-server/api.ts +674 -0
  21. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +50 -0
  22. package/plugins/design/dev-server/bin/bootstrap-check.sh +83 -0
  23. package/plugins/design/dev-server/bin/canvas-edit.sh +48 -0
  24. package/plugins/design/dev-server/bin/handoff.sh +27 -0
  25. package/plugins/design/dev-server/bin/screenshot.sh +232 -0
  26. package/plugins/design/dev-server/bin/server-up.sh +135 -0
  27. package/plugins/design/dev-server/bin/slug.sh +22 -0
  28. package/plugins/design/dev-server/bin/smoke.sh +272 -0
  29. package/plugins/design/dev-server/build.ts +267 -0
  30. package/plugins/design/dev-server/canvas-build.ts +219 -0
  31. package/plugins/design/dev-server/canvas-edit.ts +388 -0
  32. package/plugins/design/dev-server/canvas-header.ts +165 -0
  33. package/plugins/design/dev-server/canvas-icons.tsx +131 -0
  34. package/plugins/design/dev-server/canvas-lib-inline.ts +260 -0
  35. package/plugins/design/dev-server/canvas-lib-resolver.ts +85 -0
  36. package/plugins/design/dev-server/canvas-lib.tsx +1995 -0
  37. package/plugins/design/dev-server/canvas-meta.schema.json +181 -0
  38. package/plugins/design/dev-server/canvas-pipeline.ts +270 -0
  39. package/plugins/design/dev-server/canvas-shell.tsx +813 -0
  40. package/plugins/design/dev-server/client/app.jsx +2027 -0
  41. package/plugins/design/dev-server/client/hmr.mjs +85 -0
  42. package/plugins/design/dev-server/client/iframe-lazy.mjs +121 -0
  43. package/plugins/design/dev-server/client/index.html +15 -0
  44. package/plugins/design/dev-server/client/styles/0-reset.css +18 -0
  45. package/plugins/design/dev-server/client/styles/1-tokens.css +297 -0
  46. package/plugins/design/dev-server/client/styles/2-layout.css +35 -0
  47. package/plugins/design/dev-server/client/styles/3-shell.css +906 -0
  48. package/plugins/design/dev-server/client/styles/4-components.css +1268 -0
  49. package/plugins/design/dev-server/client/styles/5-utilities.css +4 -0
  50. package/plugins/design/dev-server/client/styles/_index.css +24 -0
  51. package/plugins/design/dev-server/client/styles.css +1419 -0
  52. package/plugins/design/dev-server/config.schema.json +147 -0
  53. package/plugins/design/dev-server/context-menu.tsx +343 -0
  54. package/plugins/design/dev-server/context.ts +173 -0
  55. package/plugins/design/dev-server/dist/client.bundle.js +20323 -0
  56. package/plugins/design/dev-server/dist/styles.css +2875 -0
  57. package/plugins/design/dev-server/examples/README.md +9 -0
  58. package/plugins/design/dev-server/examples/perf-100-artboards.tsx +113 -0
  59. package/plugins/design/dev-server/fs-watch.ts +63 -0
  60. package/plugins/design/dev-server/handoff.ts +721 -0
  61. package/plugins/design/dev-server/history.ts +125 -0
  62. package/plugins/design/dev-server/hmr-broadcast.ts +114 -0
  63. package/plugins/design/dev-server/http.ts +413 -0
  64. package/plugins/design/dev-server/input-router.tsx +485 -0
  65. package/plugins/design/dev-server/inspect.ts +365 -0
  66. package/plugins/design/dev-server/locator.ts +159 -0
  67. package/plugins/design/dev-server/mem.ts +97 -0
  68. package/plugins/design/dev-server/runtime-bundle.ts +235 -0
  69. package/plugins/design/dev-server/server.mjs +1246 -0
  70. package/plugins/design/dev-server/server.ts +131 -0
  71. package/plugins/design/dev-server/test/_helpers.ts +81 -0
  72. package/plugins/design/dev-server/test/active-state.test.ts +145 -0
  73. package/plugins/design/dev-server/test/annotations-api.test.ts +146 -0
  74. package/plugins/design/dev-server/test/annotations-layer.test.ts +419 -0
  75. package/plugins/design/dev-server/test/binary-smoke.test.ts +47 -0
  76. package/plugins/design/dev-server/test/bundle-smoke.test.ts +29 -0
  77. package/plugins/design/dev-server/test/canvas-build.test.ts +78 -0
  78. package/plugins/design/dev-server/test/canvas-edit.test.ts +139 -0
  79. package/plugins/design/dev-server/test/canvas-header.test.ts +127 -0
  80. package/plugins/design/dev-server/test/canvas-lib-inline.test.ts +146 -0
  81. package/plugins/design/dev-server/test/canvas-lib-resolver.test.ts +112 -0
  82. package/plugins/design/dev-server/test/canvas-meta-api.test.ts +236 -0
  83. package/plugins/design/dev-server/test/canvas-pipeline.test.ts +180 -0
  84. package/plugins/design/dev-server/test/canvas-route.test.ts +176 -0
  85. package/plugins/design/dev-server/test/fs-watch.test.ts +41 -0
  86. package/plugins/design/dev-server/test/handoff-static-frames.test.ts +215 -0
  87. package/plugins/design/dev-server/test/handoff.test.ts +281 -0
  88. package/plugins/design/dev-server/test/history-rollback.test.ts +62 -0
  89. package/plugins/design/dev-server/test/hmr-broadcast.test.ts +108 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +316 -0
  91. package/plugins/design/dev-server/test/locator.test.ts +214 -0
  92. package/plugins/design/dev-server/test/perf-harness.ts +193 -0
  93. package/plugins/design/dev-server/test/phase-3.6-smoke.test.ts +77 -0
  94. package/plugins/design/dev-server/test/runtime-bundle.test.ts +69 -0
  95. package/plugins/design/dev-server/test/server-lifecycle.test.ts +28 -0
  96. package/plugins/design/dev-server/test/tool-palette.test.tsx +55 -0
  97. package/plugins/design/dev-server/test/use-annotation-selection.test.tsx +77 -0
  98. package/plugins/design/dev-server/test/use-artboard-drag.test.ts +325 -0
  99. package/plugins/design/dev-server/test/use-selection-set.test.tsx +166 -0
  100. package/plugins/design/dev-server/test/use-snap-guides.test.ts +190 -0
  101. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +93 -0
  102. package/plugins/design/dev-server/test/ws-handshake.test.ts +33 -0
  103. package/plugins/design/dev-server/tool-palette.tsx +278 -0
  104. package/plugins/design/dev-server/tsconfig.json +26 -0
  105. package/plugins/design/dev-server/use-annotation-selection.tsx +92 -0
  106. package/plugins/design/dev-server/use-annotations-visibility.tsx +43 -0
  107. package/plugins/design/dev-server/use-artboard-drag.tsx +445 -0
  108. package/plugins/design/dev-server/use-selection-set.tsx +224 -0
  109. package/plugins/design/dev-server/use-snap-guides.tsx +215 -0
  110. package/plugins/design/dev-server/use-tool-mode.tsx +114 -0
  111. package/plugins/design/dev-server/ws.ts +90 -0
  112. package/plugins/design/templates/_shell.html +177 -0
  113. package/plugins/design/templates/canvas.tsx.template +54 -0
  114. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +277 -0
  115. package/plugins/design/templates/design-system-inspiration/_README.md +71 -0
  116. package/plugins/design/templates/design-system-inspiration/audience-consumer/components-banner.html +68 -0
  117. package/plugins/design/templates/design-system-inspiration/audience-consumer/components-empty-state-generous.html +39 -0
  118. package/plugins/design/templates/design-system-inspiration/audience-consumer/components-feature-grid.html +62 -0
  119. package/plugins/design/templates/design-system-inspiration/audience-consumer/components-marketing-card.html +63 -0
  120. package/plugins/design/templates/design-system-inspiration/audience-consumer/components-testimonial.html +80 -0
  121. package/plugins/design/templates/design-system-inspiration/audience-developer/components-code-block.html +71 -0
  122. package/plugins/design/templates/design-system-inspiration/audience-developer/components-diff-view.html +65 -0
  123. package/plugins/design/templates/design-system-inspiration/audience-developer/components-log-stream.html +62 -0
  124. package/plugins/design/templates/design-system-inspiration/audience-developer/components-monospace-table.html +57 -0
  125. package/plugins/design/templates/design-system-inspiration/audience-developer/components-terminal-pane.html +67 -0
  126. package/plugins/design/templates/design-system-inspiration/audience-developer/type-mono.html +57 -0
  127. package/plugins/design/templates/design-system-inspiration/audience-pro/colors-presence.html +74 -0
  128. package/plugins/design/templates/design-system-inspiration/audience-pro/components-command-palette.html +90 -0
  129. package/plugins/design/templates/design-system-inspiration/audience-pro/components-keyboard.html +51 -0
  130. package/plugins/design/templates/design-system-inspiration/audience-pro/components-list.html +89 -0
  131. package/plugins/design/templates/design-system-inspiration/audience-pro/components-shortcuts-overlay.html +85 -0
  132. package/plugins/design/templates/design-system-inspiration/audience-pro/components-toast-menu.html +80 -0
  133. package/plugins/design/templates/design-system-inspiration/core/INDEX.md.tpl +25 -0
  134. package/plugins/design/templates/design-system-inspiration/core/README.orchestration.md.tpl +71 -0
  135. package/plugins/design/templates/design-system-inspiration/core/README.philosophy.md.tpl +77 -0
  136. package/plugins/design/templates/design-system-inspiration/core/SKILL.md.tpl +50 -0
  137. package/plugins/design/templates/design-system-inspiration/core/colors_and_type.css.tpl +111 -0
  138. package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +28 -0
  139. package/plugins/design/templates/design-system-inspiration/core/preview/_layout.css +113 -0
  140. package/plugins/design/templates/design-system-inspiration/core/preview/colors-accent.html +48 -0
  141. package/plugins/design/templates/design-system-inspiration/core/preview/colors-surfaces.html +49 -0
  142. package/plugins/design/templates/design-system-inspiration/core/preview/colors-text.html +52 -0
  143. package/plugins/design/templates/design-system-inspiration/core/preview/components-buttons.html +83 -0
  144. package/plugins/design/templates/design-system-inspiration/core/preview/components-cards.html +79 -0
  145. package/plugins/design/templates/design-system-inspiration/core/preview/components-inputs.html +82 -0
  146. package/plugins/design/templates/design-system-inspiration/core/preview/motion.html +66 -0
  147. package/plugins/design/templates/design-system-inspiration/core/preview/spacing-scale.html +53 -0
  148. package/plugins/design/templates/design-system-inspiration/core/preview/type-scale.html +62 -0
  149. package/plugins/design/templates/design-system-inspiration/foundations/borders.html +39 -0
  150. package/plugins/design/templates/design-system-inspiration/foundations/elevation.html +46 -0
  151. package/plugins/design/templates/design-system-inspiration/foundations/focus.html +48 -0
  152. package/plugins/design/templates/design-system-inspiration/foundations/grid.html +51 -0
  153. package/plugins/design/templates/design-system-inspiration/foundations/iconography.html +73 -0
  154. package/plugins/design/templates/design-system-inspiration/foundations/opacity.html +45 -0
  155. package/plugins/design/templates/design-system-inspiration/foundations/radii.html +40 -0
  156. package/plugins/design/templates/design-system-inspiration/foundations/selection.html +45 -0
  157. package/plugins/design/templates/design-system-inspiration/meta/accessibility.html +62 -0
  158. package/plugins/design/templates/design-system-inspiration/meta/i18n.html +73 -0
  159. package/plugins/design/templates/design-system-inspiration/meta/presence-multiplayer.html +71 -0
  160. package/plugins/design/templates/design-system-inspiration/meta/tokens-index.html +80 -0
  161. package/plugins/design/templates/design-system-inspiration/patterns/patterns-auth.html +78 -0
  162. package/plugins/design/templates/design-system-inspiration/patterns/patterns-data-density.html +61 -0
  163. package/plugins/design/templates/design-system-inspiration/patterns/patterns-error-pages.html +70 -0
  164. package/plugins/design/templates/design-system-inspiration/patterns/patterns-form-layouts.html +70 -0
  165. package/plugins/design/templates/design-system-inspiration/patterns/patterns-onboarding.html +71 -0
  166. package/plugins/design/templates/design-system-inspiration/patterns/patterns-pricing.html +83 -0
  167. package/plugins/design/templates/design-system-inspiration/platform-desktop/components-resize-panels.html +63 -0
  168. package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-index.html +55 -0
  169. package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-showcase.html +302 -0
  170. package/plugins/design/templates/design-system-inspiration/platform-mobile/components-bottom-sheet.html +63 -0
  171. package/plugins/design/templates/design-system-inspiration/platform-mobile/components-pull-to-refresh.html +74 -0
  172. package/plugins/design/templates/design-system-inspiration/platform-mobile/components-segmented-control.html +51 -0
  173. package/plugins/design/templates/design-system-inspiration/platform-mobile/components-tab-bar.html +57 -0
  174. package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-index.html +58 -0
  175. package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-showcase.html +237 -0
  176. package/plugins/design/templates/design-system-inspiration/status/colors-status.html +49 -0
  177. package/plugins/design/templates/design-system-inspiration/status/components-status.html +63 -0
  178. package/plugins/design/templates/design-system-inspiration/status/skeletons.html +74 -0
  179. package/plugins/design/templates/design-system-inspiration/theme-both/colors-themes-side-by-side.html +59 -0
  180. package/plugins/design/templates/design-system-inspiration/universal/components-callout.html +74 -0
  181. package/plugins/design/templates/design-system-inspiration/universal/components-dialogs.html +81 -0
  182. package/plugins/design/templates/design-system-inspiration/universal/components-tables.html +101 -0
  183. package/plugins/design/templates/design-system-inspiration/universal/components-toggles.html +74 -0
  184. package/plugins/design/templates/design-system-inspiration/universal/components-tooltips.html +74 -0
  185. package/plugins/design/templates/design-system-inspiration/universal/empty-state.html.tpl +34 -0
  186. package/plugins/design/templates/design-system-inspiration/universal/logo.html +42 -0
  187. package/plugins/design/templates/ds-specimen.tsx.template +59 -0
  188. package/plugins/flow/.claude-plugin/config.schema.json +398 -0
  189. package/plugins/flow/README.md +45 -0
  190. package/plugins/flow/templates/ai-skeleton/INDEX.md +50 -0
  191. package/plugins/flow/templates/ai-skeleton/README.md +46 -0
  192. package/plugins/flow/templates/ai-skeleton/browser/har/.gitkeep +0 -0
  193. package/plugins/flow/templates/ai-skeleton/browser/snapshots/.gitkeep +0 -0
  194. package/plugins/flow/templates/ai-skeleton/business/README.md +12 -0
  195. package/plugins/flow/templates/ai-skeleton/context/README.md +12 -0
  196. package/plugins/flow/templates/ai-skeleton/decisions/README.md +20 -0
  197. package/plugins/flow/templates/ai-skeleton/design-import/.gitkeep +0 -0
  198. package/plugins/flow/templates/ai-skeleton/dev-logs/.gitkeep +0 -0
  199. package/plugins/flow/templates/ai-skeleton/device/.gitkeep +0 -0
  200. package/plugins/flow/templates/ai-skeleton/docs/README.md +5 -0
  201. package/plugins/flow/templates/ai-skeleton/logs/.gitkeep +0 -0
  202. package/plugins/flow/templates/ai-skeleton/logs/README.md +13 -0
  203. package/plugins/flow/templates/ai-skeleton/plans/README.md +16 -0
  204. package/plugins/flow/templates/ai-skeleton/plans/archive/.gitkeep +0 -0
  205. package/plugins/flow/templates/ai-skeleton/release-guide.md +35 -0
  206. package/plugins/flow/templates/ai-skeleton/reviews/README.md +15 -0
  207. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +26 -0
  208. package/plugins/flow/templates/ai-skeleton/scenarios/_lib/.gitkeep +0 -0
  209. package/plugins/flow/templates/ai-skeleton/state/STATE.md +25 -0
  210. package/plugins/flow/templates/ai-skeleton/templates/HANDOFF.md +32 -0
  211. package/plugins/flow/templates/ai-skeleton/workflows.config.json +74 -0
@@ -0,0 +1,2027 @@
1
+ // Design plugin local browser — React UI.
2
+ // Bundled via Bun.build (DDR-009/012) — IIFE, tree-shaken, React 19 from npm.
3
+ // Renders: file tree, tabs, viewport (iframes), status bar, design-system view, comments.
4
+ // Universal — no project tokens needed; styling lives in client/styles/.
5
+
6
+ import { useState, useEffect, useRef, useMemo, useCallback, Fragment } from 'react';
7
+ import { createRoot } from 'react-dom/client';
8
+
9
+ const SYSTEM_TAB = '__system__';
10
+ const THEME_STORE = 'mdcc-theme';
11
+ const SHOW_HIDDEN_STORE = 'mdcc-show-hidden';
12
+ const SECTIONS_STORE = 'mdcc-sections-expanded';
13
+ const SIDEBAR_STORE = 'mdcc-sidebar-open';
14
+ const CANVAS_EXT_RE = /\.(tsx|html?)$/i;
15
+ // Bun's `define` substitutes this at build time (see build.ts); falls back when
16
+ // the bundle is consumed in a context that hasn't run the build.
17
+ const MDCC_VERSION = typeof __MDCC_VERSION__ !== 'undefined' ? __MDCC_VERSION__ : 'dev';
18
+
19
+ function readInitialTheme() {
20
+ if (typeof window === 'undefined') return 'dark';
21
+ try {
22
+ const stored = localStorage.getItem(THEME_STORE);
23
+ if (stored === 'light' || stored === 'dark') return stored;
24
+ } catch {}
25
+ // Match the data-theme attribute index.html ships with (dark).
26
+ return 'dark';
27
+ }
28
+
29
+ function readBoolStore(key, fallback) {
30
+ if (typeof window === 'undefined') return fallback;
31
+ try {
32
+ const v = localStorage.getItem(key);
33
+ if (v === '1') return true;
34
+ if (v === '0') return false;
35
+ } catch {}
36
+ return fallback;
37
+ }
38
+
39
+ function readJsonStore(key, fallback) {
40
+ if (typeof window === 'undefined') return fallback;
41
+ try {
42
+ const v = localStorage.getItem(key);
43
+ return v ? JSON.parse(v) : fallback;
44
+ } catch { return fallback; }
45
+ }
46
+
47
+ // Section default-open: working sections (project + non-DS canvas groups)
48
+ // open; meta sections (DS + runtime) collapsed. Users can override per-section
49
+ // via the chevron; overrides persist in localStorage.
50
+ function sectionDefaultOpen(g) {
51
+ if (g.kind === 'runtime') return false;
52
+ if (g.label === 'Design system') return false;
53
+ return true;
54
+ }
55
+
56
+ // ---------- Utility ----------
57
+
58
+ function urlOf(p) {
59
+ return '/' + p.split('/').map(encodeURIComponent).join('/');
60
+ }
61
+
62
+ // Iframe src for a canvas path. TSX canvases go through _canvas-shell.html so
63
+ // the bundled React 19 runtime + importmap can mount the default export. HTML
64
+ // canvases keep the legacy "serve the file with inspector + Babel injected"
65
+ // path. Phase 3.6 contract; the path argument is repo-root-relative
66
+ // (e.g. ".design/ui/Foo.tsx").
67
+ function canvasUrl(p, cfg) {
68
+ if (!p.endsWith('.tsx')) return urlOf(p);
69
+ const designRel = (cfg?.designRel || '.design').replace(/^\/+|\/+$/g, '');
70
+ // Path under designRoot.
71
+ let rel = p;
72
+ if (rel.startsWith(designRel + '/')) rel = rel.slice(designRel.length + 1);
73
+ // Pass `rel` to URLSearchParams RAW — it does encoding once. Pre-encoding
74
+ // with encodeURIComponent then handing to URLSearchParams produced
75
+ // `Docs%2520Site.tsx` (the `%` of `%20` got re-encoded as `%25`) and broke
76
+ // every UI canvas with a space in its filename.
77
+ const params = new URLSearchParams();
78
+ params.set('canvas', rel);
79
+ params.set('designRel', designRel);
80
+ // Resolve tokens path. Prefer the first designSystem's tokensCssRel — that's
81
+ // the project's authoritative tokens file (e.g. `system/project/colors_and_type.css`).
82
+ // The top-level cfg.tokensCssRel is the legacy default (`system/colors_and_type.css`)
83
+ // and points to a file that usually doesn't exist in DS-bootstrapped projects.
84
+ const ds0 = cfg?.designSystems?.[0];
85
+ const tokens = ds0?.tokensCssRel || cfg?.tokensCssRel;
86
+ if (tokens) params.set('tokens', tokens);
87
+ if (cfg?.componentsCssRel) params.set('components', cfg.componentsCssRel);
88
+ // Specimen detection: anything under `system/<ds>/preview/` gets the layout
89
+ // chrome CSS so its `.specimen-hd` / `_layout.css`-baked treatment renders.
90
+ const specMatch = rel.match(/^system\/([^/]+)\/preview\//);
91
+ if (specMatch) {
92
+ const ds = specMatch[1];
93
+ params.set('layout', `system/${ds}/preview/_layout.css`);
94
+ if (!cfg?.componentsCssRel) {
95
+ params.set('components', `system/${ds}/preview/_components.css`);
96
+ }
97
+ } else if (ds0?.path) {
98
+ // UI canvas — load the project DS's `_components.css` so the dc-canvas /
99
+ // dc-section / dc-artboard chrome (and any DS classes the canvas reuses)
100
+ // renders correctly.
101
+ if (!cfg?.componentsCssRel) {
102
+ params.set('components', `${ds0.path}/preview/_components.css`);
103
+ }
104
+ }
105
+ return `/_canvas-shell.html?${params.toString()}`;
106
+ }
107
+
108
+ function basename(p) {
109
+ return p.split('/').pop();
110
+ }
111
+
112
+ // Strip canvas extensions for display. `Canvas Viewport.tsx` → `Canvas Viewport`.
113
+ // Sidecars (`.meta.json`, `.css`, `.registry.json`) keep their extensions so
114
+ // the file type stays unambiguous.
115
+ function displayName(name) {
116
+ return name.replace(CANVAS_EXT_RE, '');
117
+ }
118
+
119
+ // Primary base = name with the canvas extension stripped. `Canvas Viewport.tsx`
120
+ // → `Canvas Viewport`. A sidecar belongs to that primary when its name starts
121
+ // with `<base>.` — so `Canvas Viewport.meta.json` and `Canvas Viewport.css`
122
+ // both nest under `Canvas Viewport.tsx`. Naïve single-extension stripping
123
+ // breaks for multi-dot sidecars like `*.meta.json`.
124
+ function canvasBase(name) {
125
+ return name.replace(CANVAS_EXT_RE, '');
126
+ }
127
+
128
+ // Group flat file list into { primary: canvas, sidecars: [...] }. Sidecars
129
+ // share the primary base + `.` prefix and don't themselves match the canvas
130
+ // extension regex. Orphans (no canvas peer at this dir level) come back as
131
+ // `{ primary: orphan, sidecars: [], orphan: true }` so the caller can gate
132
+ // them on `showHidden`.
133
+ function groupBySidecar(files) {
134
+ // Pass 1 — claim primaries; prefer .tsx over .html on tie.
135
+ const primaryByBase = new Map();
136
+ for (const f of files) {
137
+ if (!CANVAS_EXT_RE.test(f.name)) continue;
138
+ const base = canvasBase(f.name);
139
+ if (!primaryByBase.has(base) || /\.tsx$/i.test(f.name)) primaryByBase.set(base, f);
140
+ }
141
+ // Pass 2 — match non-canvas files to the longest primary base they prefix.
142
+ const sidecarsByBase = new Map();
143
+ const orphans = [];
144
+ for (const f of files) {
145
+ if (CANVAS_EXT_RE.test(f.name)) continue;
146
+ let matched = null;
147
+ for (const base of primaryByBase.keys()) {
148
+ if (f.name === base) continue;
149
+ if (f.name.startsWith(`${base}.`)) {
150
+ if (!matched || base.length > matched.length) matched = base;
151
+ }
152
+ }
153
+ if (matched) {
154
+ const list = sidecarsByBase.get(matched) || [];
155
+ list.push(f);
156
+ sidecarsByBase.set(matched, list);
157
+ } else {
158
+ orphans.push(f);
159
+ }
160
+ }
161
+ const canvases = [];
162
+ for (const [base, primary] of primaryByBase) {
163
+ const sidecars = (sidecarsByBase.get(base) || []).sort((a, b) => a.name.localeCompare(b.name));
164
+ canvases.push({ primary, sidecars, orphan: false });
165
+ }
166
+ canvases.sort((a, b) => a.primary.name.localeCompare(b.primary.name));
167
+ orphans.sort((a, b) => a.name.localeCompare(b.name));
168
+ return {
169
+ canvases,
170
+ orphans: orphans.map((f) => ({ primary: f, sidecars: [], orphan: true })),
171
+ };
172
+ }
173
+
174
+ function buildTree(paths, stripPrefix) {
175
+ const root = {};
176
+ for (const p of paths) {
177
+ const stripped = p.startsWith(stripPrefix) ? p.slice(stripPrefix.length).replace(/^\/+/, '') : p;
178
+ const parts = stripped.split('/');
179
+ let node = root;
180
+ for (let i = 0; i < parts.length; i++) {
181
+ const key = parts[i];
182
+ const isFile = i === parts.length - 1;
183
+ if (isFile) {
184
+ node._files = node._files || [];
185
+ node._files.push({ name: key, path: p });
186
+ } else {
187
+ node[key] = node[key] || {};
188
+ node = node[key];
189
+ }
190
+ }
191
+ }
192
+ return root;
193
+ }
194
+
195
+ function filterTree(node, query) {
196
+ if (!query) return node;
197
+ const q = query.toLowerCase();
198
+ const out = {};
199
+ let any = false;
200
+ const dirs = Object.keys(node).filter(k => k !== '_files');
201
+ for (const d of dirs) {
202
+ const filtered = filterTree(node[d], query);
203
+ if (filtered) { out[d] = filtered; any = true; }
204
+ }
205
+ if (node._files) {
206
+ const files = node._files.filter(f =>
207
+ f.name.toLowerCase().includes(q) || f.path.toLowerCase().includes(q)
208
+ );
209
+ if (files.length) { out._files = files; any = true; }
210
+ }
211
+ return any ? out : null;
212
+ }
213
+
214
+ function openCount(comments) {
215
+ return (comments || []).filter(c => c.status !== 'resolved').length;
216
+ }
217
+
218
+ function timeAgo(iso) {
219
+ if (!iso) return '';
220
+ const t = new Date(iso).getTime();
221
+ if (!t) return '';
222
+ const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
223
+ if (s < 60) return s + 's';
224
+ const m = Math.floor(s / 60);
225
+ if (m < 60) return m + 'm';
226
+ const h = Math.floor(m / 60);
227
+ if (h < 24) return h + 'h';
228
+ const d = Math.floor(h / 24);
229
+ if (d < 7) return d + 'd';
230
+ return new Date(iso).toLocaleDateString();
231
+ }
232
+
233
+ function totalCounts(commentsByFile) {
234
+ let all = 0, open = 0, resolved = 0;
235
+ for (const list of Object.values(commentsByFile || {})) {
236
+ for (const c of list || []) {
237
+ all++;
238
+ if (c.status === 'resolved') resolved++;
239
+ else open++;
240
+ }
241
+ }
242
+ return { all, open, resolved };
243
+ }
244
+
245
+ // ---------- Components ----------
246
+
247
+ function Icon({ d, size = 14, color }) {
248
+ return (
249
+ <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' }}>
250
+ <path d={d} />
251
+ </svg>
252
+ );
253
+ }
254
+
255
+ // ───── Tree (CV-08 spec) ─────
256
+ // File rows use `.tp-row` with optional .dir / .sel / .star / .modified
257
+ // modifiers + a leading `.glyph` (▾ open dir, ▸ closed dir / selected file,
258
+ // · file). Section headers use `.tp-section-hd` with a `.pill` counter.
259
+ // The flat-row model (vs the old nested <details>) mirrors the mock and
260
+ // keeps padding-left under explicit control per depth level.
261
+
262
+ const TREE_INDENT_BASE = 12;
263
+ const TREE_INDENT_STEP = 16;
264
+
265
+ function DirRow({ name, depth, defaultOpen, children }) {
266
+ const [open, setOpen] = useState(defaultOpen);
267
+ return (
268
+ <Fragment>
269
+ <button
270
+ type="button"
271
+ role="treeitem"
272
+ aria-expanded={open}
273
+ tabIndex={-1}
274
+ className="tp-row dir"
275
+ style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
276
+ onClick={() => setOpen(v => !v)}
277
+ >
278
+ <span className="glyph" aria-hidden="true">{open ? '▾' : '▸'}</span>
279
+ <span className="name">{name}</span>
280
+ </button>
281
+ {open && children}
282
+ </Fragment>
283
+ );
284
+ }
285
+
286
+ // DsFolderRow — a per-DS folder inside the DESIGN SYSTEM section.
287
+ // Split target: chevron toggles disclosure of the folder's contents; clicking
288
+ // the folder name opens the SystemView focused on that DS (single SystemView
289
+ // for now; the dsName is plumbed through so a future per-DS view can use it).
290
+ function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, children }) {
291
+ const [open, setOpen] = useState(defaultOpen);
292
+ return (
293
+ <Fragment>
294
+ <div
295
+ className={'tp-row ds-folder' + (active ? ' sel' : '')}
296
+ style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
297
+ role="treeitem"
298
+ aria-expanded={open}
299
+ >
300
+ <button
301
+ type="button"
302
+ className="ds-folder-chev"
303
+ onClick={() => setOpen((v) => !v)}
304
+ aria-label={open ? 'Collapse design system' : 'Expand design system'}
305
+ title={open ? 'Collapse' : 'Expand'}
306
+ >
307
+ <span className="glyph" aria-hidden="true">{open ? '▾' : '▸'}</span>
308
+ </button>
309
+ <button
310
+ type="button"
311
+ className="ds-folder-open"
312
+ onClick={() => onOpenSystem(dsName)}
313
+ aria-label={`Open ${dsName} design system view`}
314
+ title="Open the design system view"
315
+ >
316
+ <span className="name">{name}</span>
317
+ </button>
318
+ </div>
319
+ {open && children}
320
+ </Fragment>
321
+ );
322
+ }
323
+
324
+ function FileRow({ file, activePath, onOpen, openCount: oc, depth, kind, sidecar }) {
325
+ const isSel = file.path === activePath;
326
+ const isCanvas = CANVAS_EXT_RE.test(file.name);
327
+ // Non-canvas rows (PROJECT *.md, RUNTIME _active.json, ...) are display-only —
328
+ // clicking them doesn't open an iframe; we leave the click as no-op + cursor
329
+ // hint via `aria-disabled`.
330
+ const inert = !isCanvas;
331
+ const label = isCanvas ? displayName(file.name) : file.name;
332
+ return (
333
+ <button
334
+ type="button"
335
+ role="treeitem"
336
+ aria-selected={isSel}
337
+ aria-disabled={inert ? 'true' : undefined}
338
+ tabIndex={isSel ? 0 : -1}
339
+ className={'tp-row' + (isSel ? ' sel' : '') + (kind === 'runtime' ? ' muted' : '') + (sidecar ? ' sidecar' : '')}
340
+ style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
341
+ title={file.path + (oc ? ` — ${oc} open` : (inert ? ' (file index only)' : ''))}
342
+ onClick={() => { if (!inert) onOpen(file.path); }}
343
+ >
344
+ <span className="glyph" aria-hidden="true">{isSel ? '▸' : '·'}</span>
345
+ <span className="name">{label}</span>
346
+ {oc > 0 && <span className="badge">{oc}</span>}
347
+ </button>
348
+ );
349
+ }
350
+
351
+ function CanvasRow({ primary, sidecars, depth, kind, activePath, onOpen, openCount: oc, showHidden, forceOpen }) {
352
+ const hasSidecars = sidecars.length > 0;
353
+ const [openState, setOpenState] = useState(false);
354
+ // Sidecars are only revealed when the user opts in via `showHidden` — the
355
+ // chevron itself only appears in that mode. When `forceOpen` is true (search
356
+ // match in a sidecar), override local state so the user sees the hit.
357
+ const open = forceOpen || openState;
358
+ const isSel = primary.path === activePath;
359
+ const showChevron = hasSidecars && showHidden;
360
+ if (!showChevron) {
361
+ return (
362
+ <FileRow
363
+ file={primary}
364
+ activePath={activePath}
365
+ onOpen={onOpen}
366
+ openCount={oc}
367
+ depth={depth}
368
+ kind={kind}
369
+ />
370
+ );
371
+ }
372
+ return (
373
+ <Fragment>
374
+ <button
375
+ type="button"
376
+ role="treeitem"
377
+ aria-selected={isSel}
378
+ aria-expanded={open}
379
+ tabIndex={isSel ? 0 : -1}
380
+ className={'tp-row canvas-row' + (isSel ? ' sel' : '')}
381
+ style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
382
+ title={primary.path}
383
+ onClick={(e) => {
384
+ // Click the chevron region → toggle disclosure. Click anywhere else → open canvas.
385
+ if (e.target.closest('.canvas-chev')) {
386
+ setOpenState((v) => !v);
387
+ return;
388
+ }
389
+ onOpen(primary.path);
390
+ }}
391
+ >
392
+ <span
393
+ className="glyph canvas-chev"
394
+ aria-hidden="true"
395
+ onClick={(e) => {
396
+ e.stopPropagation();
397
+ setOpenState((v) => !v);
398
+ }}
399
+ >
400
+ {open ? '▾' : '▸'}
401
+ </span>
402
+ <span className="name">{displayName(primary.name)}</span>
403
+ {oc > 0 && <span className="badge">{oc}</span>}
404
+ </button>
405
+ {open && sidecars.map((sc) => (
406
+ <FileRow
407
+ key={sc.path}
408
+ file={sc}
409
+ activePath={activePath}
410
+ onOpen={onOpen}
411
+ openCount={0}
412
+ depth={depth + 1}
413
+ kind={kind}
414
+ sidecar
415
+ />
416
+ ))}
417
+ </Fragment>
418
+ );
419
+ }
420
+
421
+ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showHidden, search, dsFolders, onOpenSystem }) {
422
+ const dirs = Object.keys(node).filter(k => k !== '_files').sort();
423
+ const files = node._files || [];
424
+ // VS Code-style sidecar grouping. Canvas (`.tsx`/`.html`) becomes the primary
425
+ // row; same-basename non-canvas files (`.meta.json`, `.css`, …) collapse
426
+ // under it. Orphans surface only when `showHidden` is on.
427
+ const { canvases, orphans } = useMemo(() => groupBySidecar(files), [files]);
428
+ const hasSearch = !!(search && search.trim());
429
+ // DS-folder lookup: only meaningful at the top level of a DS group. The
430
+ // server emits `dsFolders: [{name, folder}, ...]` so the client knows which
431
+ // dir at depth=1 corresponds to a DS root (click → open SystemView).
432
+ const dsFolderByName = useMemo(() => {
433
+ if (!dsFolders || depth !== 1) return null;
434
+ const m = new Map();
435
+ for (const f of dsFolders) m.set(f.folder, f);
436
+ return m;
437
+ }, [dsFolders, depth]);
438
+ return (
439
+ <Fragment>
440
+ {canvases.map((entry) => {
441
+ const forceOpen = hasSearch && entry.sidecars.some((sc) => {
442
+ const q = search.toLowerCase();
443
+ return sc.name.toLowerCase().includes(q) || sc.path.toLowerCase().includes(q);
444
+ });
445
+ return (
446
+ <CanvasRow
447
+ key={entry.primary.path}
448
+ primary={entry.primary}
449
+ sidecars={entry.sidecars}
450
+ activePath={activePath}
451
+ onOpen={onOpen}
452
+ openCount={openCount(commentsByFile[entry.primary.path])}
453
+ depth={depth}
454
+ kind={kind}
455
+ showHidden={showHidden}
456
+ forceOpen={forceOpen}
457
+ />
458
+ );
459
+ })}
460
+ {showHidden && orphans.map((entry) => (
461
+ <FileRow
462
+ key={entry.primary.path}
463
+ file={entry.primary}
464
+ activePath={activePath}
465
+ onOpen={onOpen}
466
+ openCount={openCount(commentsByFile[entry.primary.path])}
467
+ depth={depth}
468
+ kind={kind}
469
+ />
470
+ ))}
471
+ {dirs.map(d => {
472
+ const dsMatch = dsFolderByName?.get(d);
473
+ const childTree = (
474
+ <Tree
475
+ node={node[d]}
476
+ activePath={activePath}
477
+ onOpen={onOpen}
478
+ commentsByFile={commentsByFile}
479
+ depth={depth + 1}
480
+ kind={kind}
481
+ showHidden={showHidden}
482
+ search={search}
483
+ onOpenSystem={onOpenSystem}
484
+ />
485
+ );
486
+ if (dsMatch && onOpenSystem) {
487
+ return (
488
+ <DsFolderRow
489
+ key={d}
490
+ name={d}
491
+ dsName={dsMatch.name}
492
+ depth={depth}
493
+ defaultOpen={true}
494
+ active={activePath === SYSTEM_TAB}
495
+ onOpenSystem={onOpenSystem}
496
+ >
497
+ {childTree}
498
+ </DsFolderRow>
499
+ );
500
+ }
501
+ return (
502
+ <DirRow key={d} name={d} depth={depth} defaultOpen={true}>
503
+ {childTree}
504
+ </DirRow>
505
+ );
506
+ })}
507
+ </Fragment>
508
+ );
509
+ }
510
+
511
+ // CV-08 section labels — title + optional SKU pill. The pill carries
512
+ // project / DS identity; the mock keeps these tight (1 line). Labels are
513
+ // keyed by the server-provided `kind` (PROJECT / DS / UI / RUNTIME).
514
+ const SECTION_META = {
515
+ project: { title: 'PROJECT', pillFromCount: false },
516
+ // Design-system group: pill shows the number of DSes (one row per DS folder
517
+ // inside). Computed in Sidebar from `g.dsFolders.length`.
518
+ ds: { title: 'DESIGN SYSTEM', pillFromDsCount: true },
519
+ canvas: { title: 'UI CANVASES', pillFromCount: true },
520
+ runtime: { title: 'RUNTIME · GITIGNORED', pillFromCount: true },
521
+ };
522
+
523
+ function sectionMetaFor(g) {
524
+ if (g.kind === 'project') return SECTION_META.project;
525
+ if (g.kind === 'runtime') return SECTION_META.runtime;
526
+ // canvas-kind groups: "Design system" → ds, anything else → canvas label
527
+ if (g.label === 'Design system') return SECTION_META.ds;
528
+ if (g.label === 'UI kit') return SECTION_META.canvas;
529
+ return { title: g.label.toUpperCase(), pillFromCount: true };
530
+ }
531
+
532
+ function Sidebar({ groups, activePath, onOpen, onOpenSystem, wsConnected, search, setSearch, commentsByFile, showHidden, sectionsExpanded, onToggleSection }) {
533
+ const filteredGroups = useMemo(() => {
534
+ if (!search) return groups;
535
+ return groups.map(g => ({ ...g, tree: filterTree(g.tree, search), filtered: !!search }));
536
+ }, [groups, search]);
537
+
538
+ // Mock uses `42 / 42` — total openable canvases, not every listed file.
539
+ // We count canvas files (TSX Phase 3.6+ default, HTML legacy) so the counter
540
+ // matches "canvases you can mount".
541
+ const htmlCount = useMemo(() => {
542
+ let total = 0;
543
+ for (const g of groups) for (const p of g.paths || []) if (CANVAS_EXT_RE.test(p)) total++;
544
+ return total;
545
+ }, [groups]);
546
+ const htmlShown = useMemo(() => {
547
+ let total = 0;
548
+ for (const g of filteredGroups) for (const p of g.paths || []) if (CANVAS_EXT_RE.test(p)) total++;
549
+ return total;
550
+ }, [filteredGroups]);
551
+
552
+ return (
553
+ <nav className="sidebar">
554
+ <div className="tree-panel-hd">
555
+ <span>FILES</span>
556
+ <span className="ct" title={wsConnected ? 'live · file index synced' : 'reconnecting…'}>
557
+ <span className={'live-dot' + (wsConnected ? ' connected' : '')} aria-hidden="true" />
558
+ {htmlShown} / {htmlCount}
559
+ </span>
560
+ </div>
561
+
562
+ <div className="tree-panel-search">
563
+ <Icon d="M21 21l-4.35-4.35 M11 19a8 8 0 100-16 8 8 0 000 16z" size={12} />
564
+ <input
565
+ type="search"
566
+ placeholder="filter (⌘F)"
567
+ value={search}
568
+ onChange={e => setSearch(e.target.value)}
569
+ aria-label="Filter files"
570
+ />
571
+ {search ? (
572
+ <button className="search-clear" onClick={() => setSearch('')} title="Clear (Esc)" aria-label="Clear search">×</button>
573
+ ) : (
574
+ <span className="search-kbd" aria-hidden="true">/</span>
575
+ )}
576
+ </div>
577
+
578
+ <div className="tree-panel-body" role="tree" aria-label="Project file tree">
579
+ {filteredGroups.map(g => {
580
+ // Hide gitignored runtime / orphan-only project sections by default.
581
+ // Active search overrides — if the user typed a query, they want hits
582
+ // wherever they live.
583
+ if (!showHidden && !search && g.kind === 'runtime') return null;
584
+ const meta = sectionMetaFor(g);
585
+ // Counter pill counts canvases only — sidecars + orphans inflate the
586
+ // raw `paths.length` and the FILES header already filters this way.
587
+ const canvasCount = (g.paths || []).filter((p) => CANVAS_EXT_RE.test(p)).length;
588
+ const pill = meta.pill
589
+ || (meta.pillFromDsCount ? String(g.dsFolders?.length || 0) : null)
590
+ || (meta.pillFromCount ? String(canvasCount || g.paths?.length || 0) : null);
591
+ const hasItems = g.tree && Object.keys(g.tree).length > 0;
592
+ const isDs = g.label === 'Design system';
593
+ const isProject = g.kind === 'project';
594
+ // Project section: when showHidden is off, every row inside is an
595
+ // orphan (.md / .json / .css) → empty body. Skip the header in that
596
+ // case so the sidebar doesn't show "PROJECT" with nothing under it.
597
+ if (!showHidden && !search && isProject && canvasCount === 0) return null;
598
+ const defaultOpen = sectionDefaultOpen(g);
599
+ const explicit = sectionsExpanded[g.label];
600
+ // Active search forces every section open so hits aren't hidden.
601
+ const sectionOpen = !!search || (explicit === undefined ? defaultOpen : explicit);
602
+ const chev = sectionOpen ? '▾' : '▸';
603
+ return (
604
+ <Fragment key={g.label}>
605
+ <button
606
+ type="button"
607
+ className="tp-section-hd clickable section-toggle"
608
+ onClick={() => onToggleSection(g.label, defaultOpen)}
609
+ aria-expanded={sectionOpen}
610
+ title={sectionOpen ? 'Collapse section' : 'Expand section'}
611
+ >
612
+ <span className="chev" aria-hidden="true">{chev}</span>
613
+ <span className="section-label">{meta.title}</span>
614
+ {pill && <span className="pill">{pill}</span>}
615
+ </button>
616
+ {sectionOpen && (hasItems ? (
617
+ <Tree
618
+ node={g.tree}
619
+ activePath={activePath}
620
+ onOpen={onOpen}
621
+ commentsByFile={commentsByFile}
622
+ depth={1}
623
+ kind={g.kind}
624
+ showHidden={showHidden}
625
+ search={search}
626
+ dsFolders={g.dsFolders}
627
+ onOpenSystem={isDs ? onOpenSystem : undefined}
628
+ />
629
+ ) : (
630
+ <div className="tp-empty">
631
+ {search ? 'No matches.' : 'Empty.'}
632
+ </div>
633
+ ))}
634
+ </Fragment>
635
+ );
636
+ })}
637
+ </div>
638
+ </nav>
639
+ );
640
+ }
641
+
642
+ // Help modal — hosts the cheatsheet that used to live in the left sidebar.
643
+ // Triggered from the menubar's Help item. Esc + backdrop click close it.
644
+ function HelpModal({ open, onClose }) {
645
+ useEffect(() => {
646
+ if (!open) return;
647
+ function onKey(e) { if (e.key === 'Escape') onClose(); }
648
+ window.addEventListener('keydown', onKey);
649
+ return () => window.removeEventListener('keydown', onKey);
650
+ }, [open, onClose]);
651
+ if (!open) return null;
652
+ return (
653
+ <div
654
+ className="help-modal-backdrop"
655
+ role="presentation"
656
+ onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}
657
+ >
658
+ <div className="help-modal" role="dialog" aria-modal="true" aria-labelledby="help-modal-title">
659
+ <header className="help-modal-hd">
660
+ <span className="title" id="help-modal-title">Help · shortcuts &amp; commands</span>
661
+ <span className="sku">MDCC-DEV-SRV / v{MDCC_VERSION}</span>
662
+ <button type="button" className="help-modal-close" aria-label="Close (Esc)" onClick={onClose}>×</button>
663
+ </header>
664
+ <div className="help-modal-body">
665
+ <details open>
666
+ <summary>Canvas selection &amp; tools</summary>
667
+ <ul>
668
+ <li><kbd>V</kbd> <span>move tool — Cmd+click to select, Cmd+Shift to multi</span></li>
669
+ <li><kbd>H</kbd> <span>hand tool — bare drag pans (no Space needed)</span></li>
670
+ <li><kbd>C</kbd> <span>comment tool — hover paints, click drops a pin</span></li>
671
+ <li><kbd>⌘</kbd> + hover <span>preview deepest element under cursor</span></li>
672
+ <li><kbd>⌘</kbd> + click <span>select that element (replace)</span></li>
673
+ <li><kbd>⌘⇧</kbd> + click <span>add deepest to selection (multi)</span></li>
674
+ <li>right-click <span>context menu (Copy CSS / Fit / Reset...)</span></li>
675
+ <li><kbd>Esc</kbd> in canvas <span>clear selection + close menu</span></li>
676
+ </ul>
677
+ </details>
678
+ <details>
679
+ <summary>Annotation tools</summary>
680
+ <ul>
681
+ <li><kbd>B</kbd> <span>pen — freehand stroke</span></li>
682
+ <li><kbd>R</kbd> <span>rectangle — drag to define corners</span></li>
683
+ <li><kbd>O</kbd> <span>ellipse — drag from center outward</span></li>
684
+ <li><kbd>A</kbd> <span>arrow — drag tail → tip</span></li>
685
+ <li><kbd>E</kbd> <span>eraser — click or drag over strokes to remove</span></li>
686
+ <li><kbd>V</kbd> + click stroke <span>select annotation (Shift+click to multi)</span></li>
687
+ <li><kbd>V</kbd> + drag empty <span>marquee-select strokes that overlap</span></li>
688
+ <li>double-click rect/ellipse <span>add text inside the shape</span></li>
689
+ <li>arrow keys <span>nudge selected annotation 1 unit (Shift = 10)</span></li>
690
+ <li><kbd>Backspace</kbd> <span>delete selected annotations</span></li>
691
+ <li><kbd>⇧P</kbd> <span>presentation — hide annotations for clean screenshot</span></li>
692
+ </ul>
693
+ </details>
694
+ <details>
695
+ <summary>Tabs &amp; canvas</summary>
696
+ <ul>
697
+ <li>click in tree <span>open tab</span></li>
698
+ <li><kbd>×</kbd> on tab <span>close tab</span></li>
699
+ <li><kbd>⌘R</kbd> <span>reload iframe</span></li>
700
+ <li><kbd>/</kbd> <span>focus search</span></li>
701
+ <li><kbd>⌘⇧M</kbd> <span>toggle comments panel</span></li>
702
+ </ul>
703
+ </details>
704
+ <details>
705
+ <summary>Slash commands</summary>
706
+ <ul className="cmds">
707
+ <li><code>/design:edit "<i>feedback</i>"</code><span>edit + 4-iter multi-axis loop</span></li>
708
+ <li><code>/design:edit "<i>…</i>" --perfect</code><span>8-iter polish (4.5/5 aspiration)</span></li>
709
+ <li><code>/design:edit "<i>…</i>" --no-critic</code><span>raw edit, skip loop</span></li>
710
+ <li><code>/design:edit "<i>…</i>" --opt-out=<i>scope</i></code><span>override DS scope (palette/aesthetic/full)</span></li>
711
+ <li><code>/design:new "<i>Name</i>" "<i>brief</i>"</code><span>scaffold canvas</span></li>
712
+ <li><code>/design:new "<i>…</i>" --opt-out=aesthetic</code><span>scaffold off-system canvas (gradients/radii/type free)</span></li>
713
+ <li><code>/design:critic</code><span>review panel (routed)</span></li>
714
+ <li><code>/design:critic --all</code><span>10-critic sweep</span></li>
715
+ <li><code>/design:critic --agent signature-moment-critic</code><span>aspiration axis only</span></li>
716
+ <li><code>/design:rollback</code><span>undo last edit</span></li>
717
+ <li><code>/design:screenshot</code><span>capture canvas</span></li>
718
+ <li><code>/design:setup-docs</code><span>refresh README + INDEX</span></li>
719
+ <li><code>/design:handoff</code><span>migrate to apps/</span></li>
720
+ </ul>
721
+ </details>
722
+ <details>
723
+ <summary>Opt-out scope</summary>
724
+ <ul>
725
+ <li><strong>palette</strong> <span>default — tokens + rootClass kept; local namespace overrides colors only. DS aesthetic still enforced.</span></li>
726
+ <li><strong>aesthetic</strong> <span>palette + gradients/off-ladder radii/alt type/decorative SVG flags allowed.</span></li>
727
+ <li><strong>full</strong> <span>DS treated as advisory. Type/radii/aesthetic up to canvas.</span></li>
728
+ <li><em>A11y enforced at every scope</em> <span>contrast, focus, semantics, motion, touch targets — never relaxed.</span></li>
729
+ <li>Persisted on canvas's <code>.meta.json</code> <code>opt_out_scope</code> field — subsequent <code>/design:edit</code> iterations inherit.</li>
730
+ <li>Inferred from brief ("modern", "vibrant", "off-system") with one-shot AskUserQuestion before iter-1 critics fire.</li>
731
+ </ul>
732
+ </details>
733
+ <details>
734
+ <summary>Auto-critic loop</summary>
735
+ <ul>
736
+ <li><strong>Default</strong> <span>4 iter · aspiration ≥ 4.0 · stable-but-bland exit</span></li>
737
+ <li><strong>--perfect</strong> <span>8 iter · aspiration ≥ 4.5 · broader divergence tolerance</span></li>
738
+ <li><strong>--perfect --all</strong> <span>every critic incl. aspiration · portfolio-grade</span></li>
739
+ <li>Exit: <code>solid</code> · <code>stable-but-bland</code> · <code>max-reached</code> · <code>divergent</code></li>
740
+ <li><em>stable-but-bland</em> = correctness clean, aspiration plateau — surface for review with lowest 2 axes named</li>
741
+ <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>
742
+ </ul>
743
+ </details>
744
+ <details>
745
+ <summary>Pin-to-element flow</summary>
746
+ <ol>
747
+ <li>Open canvas tab</li>
748
+ <li><kbd>⌘</kbd>+click element</li>
749
+ <li>Status bar shows ● selector</li>
750
+ <li>Run <code>/design:edit "<i>change just this</i>"</code></li>
751
+ <li>Reload iframe (<kbd>⌘R</kbd>)</li>
752
+ </ol>
753
+ </details>
754
+ <details>
755
+ <summary>Comments</summary>
756
+ <ol>
757
+ <li><kbd>⌘</kbd>+click element, then <kbd>⌘C</kbd> <span>or ⌘⇧+click</span></li>
758
+ <li>Numbered pin appears on canvas</li>
759
+ <li><kbd>⌘⇧M</kbd> <span>opens panel — All / Open / Resolved</span></li>
760
+ <li>Click row in panel <span>jumps to that file + pin</span></li>
761
+ <li>Claude reads <code>_comments/&lt;slug&gt;.json</code> on next <code>/design</code></li>
762
+ </ol>
763
+ </details>
764
+ </div>
765
+ </div>
766
+ </div>
767
+ );
768
+ }
769
+
770
+ // ───────── Menubar (CV-01/CV-08 top chrome) ─────────
771
+ //
772
+ // Replaces the legacy `.header` action-button toolbar. Mirrors the shared
773
+ // Menubar component from .design/ui/Canvas Viewport.html — brand · menus ·
774
+ // status. View dropdown is wired to the only toggleable panel today (the
775
+ // Comments sidebar); the rest is inert with a phase-tag explaining when it
776
+ // lands.
777
+
778
+ const MENU_NAMES = ['File', 'Edit', 'View', 'Selection', 'Tools', 'Help'];
779
+
780
+ function ViewDropdown({ panels, onToggle, onClose }) {
781
+ useEffect(() => {
782
+ function onKey(e) { if (e.key === 'Escape') onClose(); }
783
+ function onDocClick(e) {
784
+ if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
785
+ }
786
+ window.addEventListener('keydown', onKey);
787
+ window.addEventListener('mousedown', onDocClick);
788
+ return () => {
789
+ window.removeEventListener('keydown', onKey);
790
+ window.removeEventListener('mousedown', onDocClick);
791
+ };
792
+ }, [onClose]);
793
+
794
+ return (
795
+ <div className="mb-dropdown" role="menu" aria-label="View" style={{ left: '146px' }}>
796
+ <div className="mb-dd-hd">Panels</div>
797
+ {panels.map(p => (
798
+ <button
799
+ key={p.id}
800
+ type="button"
801
+ role="menuitem"
802
+ className={'mb-dd-item' + (p.checked ? ' active' : '')}
803
+ aria-disabled={p.disabled ? 'true' : undefined}
804
+ onClick={() => { if (!p.disabled) { onToggle(p.id); onClose(); } }}
805
+ >
806
+ <span className="lbl">
807
+ <span className="check">{p.checked ? '✓' : ''}</span>
808
+ <span>{p.label}</span>
809
+ </span>
810
+ {p.phase
811
+ ? <span className="phase-tag">{p.phase}</span>
812
+ : <span className="shortcut">{p.shortcut || ''}</span>}
813
+ </button>
814
+ ))}
815
+ <div className="mb-dd-sep" />
816
+ <div className="mb-dd-hd">Zoom</div>
817
+ {[
818
+ { label: 'Zoom In', shortcut: '⌘ +' },
819
+ { label: 'Zoom Out', shortcut: '⌘ −' },
820
+ { label: 'Fit to Screen', shortcut: '⌘ 0' },
821
+ { label: 'Actual Size · 100 %', shortcut: '⌥ ⌘ 0' },
822
+ ].map(z => (
823
+ <button
824
+ key={z.label}
825
+ type="button"
826
+ role="menuitem"
827
+ className="mb-dd-item"
828
+ aria-disabled="true"
829
+ >
830
+ <span className="lbl"><span className="check" /><span>{z.label}</span></span>
831
+ <span className="phase-tag">Phase 4</span>
832
+ </button>
833
+ ))}
834
+ </div>
835
+ );
836
+ }
837
+
838
+ // ─────────────────────────────────────────────────────────────────────────────
839
+ // Phase 5.1 — Selection + Tools dropdowns (mirror ViewDropdown shape).
840
+
841
+ function SelectionDropdown({ onAction, onClose }) {
842
+ useEffect(() => {
843
+ function onKey(e) { if (e.key === 'Escape') onClose(); }
844
+ function onDocClick(e) {
845
+ if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
846
+ }
847
+ window.addEventListener('keydown', onKey);
848
+ window.addEventListener('mousedown', onDocClick);
849
+ return () => {
850
+ window.removeEventListener('keydown', onKey);
851
+ window.removeEventListener('mousedown', onDocClick);
852
+ };
853
+ }, [onClose]);
854
+ const items = [
855
+ { id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
856
+ { id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⌘ ⇧ A' },
857
+ ];
858
+ return (
859
+ <div className="mb-dropdown" role="menu" aria-label="Selection" style={{ left: '195px' }}>
860
+ {items.map(it => (
861
+ <button
862
+ key={it.id}
863
+ type="button"
864
+ role="menuitem"
865
+ className="mb-dd-item"
866
+ onClick={() => { onAction(it.id); onClose(); }}
867
+ >
868
+ <span className="lbl"><span className="check" /><span>{it.label}</span></span>
869
+ <span className="shortcut">{it.shortcut}</span>
870
+ </button>
871
+ ))}
872
+ </div>
873
+ );
874
+ }
875
+
876
+ function ToolsDropdown({ onAction, onClose }) {
877
+ useEffect(() => {
878
+ function onKey(e) { if (e.key === 'Escape') onClose(); }
879
+ function onDocClick(e) {
880
+ if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
881
+ }
882
+ window.addEventListener('keydown', onKey);
883
+ window.addEventListener('mousedown', onDocClick);
884
+ return () => {
885
+ window.removeEventListener('keydown', onKey);
886
+ window.removeEventListener('mousedown', onDocClick);
887
+ };
888
+ }, [onClose]);
889
+ // Mirrors DEFAULT_TOOLS in plugins/design/dev-server/use-tool-mode.tsx —
890
+ // kept in sync by hand because the menubar lives in the dev-server shell
891
+ // (no shared bundle with the canvas iframes).
892
+ const tools = [
893
+ { id: 'move', label: 'Move', shortcut: 'V' },
894
+ { id: 'hand', label: 'Hand', shortcut: 'H' },
895
+ { id: 'comment', label: 'Comment', shortcut: 'C' },
896
+ { id: 'pen', label: 'Pen', shortcut: 'B' },
897
+ { id: 'rect', label: 'Rect', shortcut: 'R' },
898
+ { id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
899
+ { id: 'arrow', label: 'Arrow', shortcut: 'A' },
900
+ { id: 'eraser', label: 'Eraser', shortcut: 'E' },
901
+ ];
902
+ return (
903
+ <div className="mb-dropdown" role="menu" aria-label="Tools" style={{ left: '253px' }}>
904
+ {tools.map(t => (
905
+ <button
906
+ key={t.id}
907
+ type="button"
908
+ role="menuitem"
909
+ className="mb-dd-item"
910
+ onClick={() => { onAction(t.id); onClose(); }}
911
+ >
912
+ <span className="lbl"><span className="check" /><span>{t.label}</span></span>
913
+ <span className="shortcut">{t.shortcut}</span>
914
+ </button>
915
+ ))}
916
+ </div>
917
+ );
918
+ }
919
+
920
+ function Menubar({ activePath, project, tabsCount, openMenu, setOpenMenu, commentsPanelOpen, onToggleComments, onOpenSystem, sidebarOpen, onToggleSidebar, showHidden, onToggleShowHidden, onOpenHelp, annotationsVisible, onToggleAnnotations, postToActiveCanvas }) {
921
+ const isSystem = activePath === SYSTEM_TAB;
922
+ const stamp = isSystem ? 'SYSTEM' : (activePath ? 'CANVAS' : 'IDLE');
923
+ const fileLabel = isSystem
924
+ ? <b>design system</b>
925
+ : (activePath ? <>{activePath.split('/').slice(0, -1).join('/')}/<b>{displayName(basename(activePath))}</b></> : <span style={{ color: 'var(--u-fg-3)' }}>no canvas open</span>);
926
+
927
+ const panels = [
928
+ { id: 'tree', label: 'Project Tree', shortcut: 'T', checked: sidebarOpen, disabled: false },
929
+ { id: 'comments', label: 'Comments Sidebar', shortcut: '⌘ ⇧ M', checked: commentsPanelOpen, disabled: false },
930
+ { id: 'hidden', label: 'Show hidden files', shortcut: 'H', checked: showHidden, disabled: false },
931
+ { id: 'layers', label: 'Layers Panel', phase: 'Phase 12', disabled: true },
932
+ { id: 'inspector', label: 'Inspector', phase: 'Phase 12', disabled: true },
933
+ { id: 'annotate', label: 'Annotations', shortcut: '⇧ P', checked: annotationsVisible, disabled: false },
934
+ { id: 'present', label: 'Presentation Mode', phase: 'Phase 6', disabled: true },
935
+ ];
936
+
937
+ function onMenuClick(key) {
938
+ if (key === 'view' || key === 'selection' || key === 'tools') {
939
+ setOpenMenu(openMenu === key ? null : key);
940
+ } else if (key === 'help') {
941
+ setOpenMenu(null);
942
+ onOpenHelp();
943
+ }
944
+ }
945
+
946
+ return (
947
+ <header className="mb" role="menubar" aria-label="Application menubar">
948
+ <span className="mb-brand">
949
+ <span className="dot" aria-hidden="true" />
950
+ <span>maude</span>
951
+ </span>
952
+ <nav className="mb-menus" aria-label="Application menus">
953
+ {MENU_NAMES.map(name => {
954
+ const key = name.toLowerCase();
955
+ const hasDropdown = key === 'view' || key === 'selection' || key === 'tools';
956
+ const interactive = hasDropdown || key === 'help';
957
+ const open = openMenu === key;
958
+ return (
959
+ <button
960
+ key={key}
961
+ type="button"
962
+ className="mb-menu"
963
+ role="menuitem"
964
+ aria-haspopup={hasDropdown ? 'menu' : undefined}
965
+ aria-expanded={hasDropdown ? open : undefined}
966
+ aria-disabled={interactive ? undefined : 'true'}
967
+ title={interactive ? '' : 'Coming in a later phase'}
968
+ onClick={() => onMenuClick(key)}
969
+ >
970
+ {name}
971
+ </button>
972
+ );
973
+ })}
974
+ </nav>
975
+ {openMenu === 'view' && (
976
+ <ViewDropdown
977
+ panels={panels}
978
+ onToggle={id => {
979
+ if (id === 'tree') onToggleSidebar();
980
+ else if (id === 'comments') onToggleComments();
981
+ else if (id === 'hidden') onToggleShowHidden();
982
+ else if (id === 'annotate') onToggleAnnotations();
983
+ }}
984
+ onClose={() => setOpenMenu(null)}
985
+ />
986
+ )}
987
+ {openMenu === 'selection' && (
988
+ <SelectionDropdown
989
+ onAction={(id) => {
990
+ if (id === 'deselect-all') postToActiveCanvas({ dgn: 'selection-clear' });
991
+ else if (id === 'select-all-annotations') postToActiveCanvas({ dgn: 'annotation-select-all' });
992
+ }}
993
+ onClose={() => setOpenMenu(null)}
994
+ />
995
+ )}
996
+ {openMenu === 'tools' && (
997
+ <ToolsDropdown
998
+ onAction={(tool) => postToActiveCanvas({ dgn: 'tool-set', tool })}
999
+ onClose={() => setOpenMenu(null)}
1000
+ />
1001
+ )}
1002
+ <div className="mb-spacer" />
1003
+ <div className="mb-status">
1004
+ <span className="cv-stamp">{stamp}</span>
1005
+ <span className="file" title={activePath || ''}>{fileLabel}</span>
1006
+ <span className="sep" />
1007
+ <span><span className="accent-dot">●</span> <b>{tabsCount}</b> ARTBOARDS</span>
1008
+ <span className="sep" />
1009
+ <span title="Pan/zoom in Phase 4">ZOOM <b>100%</b></span>
1010
+ <span className="sep" />
1011
+ <span className="ok"><b>{project || 'MDCC'}</b></span>
1012
+ </div>
1013
+ </header>
1014
+ );
1015
+ }
1016
+
1017
+ function ThemeToggle({ theme, onToggle }) {
1018
+ // Show the icon of the theme you'll switch TO — clearer affordance than current state.
1019
+ // Sun + Moon paths are condensed Lucide-style (single-path so the existing <Icon> works).
1020
+ 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';
1021
+ const moon = 'M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z';
1022
+ const next = theme === 'dark' ? 'light' : 'dark';
1023
+ return (
1024
+ <button
1025
+ type="button"
1026
+ className="theme-toggle"
1027
+ onClick={onToggle}
1028
+ title={`Switch to ${next} theme`}
1029
+ aria-label={`Switch to ${next} theme`}
1030
+ >
1031
+ <Icon d={theme === 'dark' ? sun : moon} size={14} />
1032
+ <span className="theme-toggle-label">{next}</span>
1033
+ </button>
1034
+ );
1035
+ }
1036
+
1037
+ function Wordmark({ project, port, version }) {
1038
+ return (
1039
+ <div className="wm" aria-label="maude design server">
1040
+ <span className="wm-glyph">maude-design-server</span>
1041
+ <span className="wm-sub">
1042
+ <span>CANVAS · {(project || 'MAUDE').toUpperCase()}</span>
1043
+ <span className="wm-sep">/</span>
1044
+ <b>v{version}</b>
1045
+ <span className="wm-sep">/</span>
1046
+ <span>localhost:{port || '4399'}</span>
1047
+ </span>
1048
+ </div>
1049
+ );
1050
+ }
1051
+
1052
+ function Viewport({ tabs, activePath, registerIframe, systemData, onOpenFromSystem, project, cfg }) {
1053
+ return (
1054
+ <div className="viewport">
1055
+ {tabs.length === 0 && (
1056
+ <>
1057
+ <Wordmark project={project} port={typeof window !== 'undefined' ? window.location.port : ''} version={MDCC_VERSION} />
1058
+ <div className="empty-state">
1059
+ <div className="big">No mock open</div>
1060
+ <div className="small">
1061
+ ← Click a <code>.tsx</code> (or legacy <code>.html</code>) file in the tree, or open the <strong>Design system</strong> view above it.
1062
+ <br /><br />
1063
+ Tabs work like in an editor — close with the × on each tab. <kbd>⌘R</kbd> reloads the active iframe.
1064
+ <br /><br />
1065
+ <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.
1066
+ <br /><br />
1067
+ Active file, selection, and comments are tracked in <code>_active.json</code> + <code>_comments/</code> — Claude reads them when you run <code>/design</code>.
1068
+ </div>
1069
+ </div>
1070
+ </>
1071
+ )}
1072
+ {tabs.map(t => {
1073
+ if (t.path === SYSTEM_TAB) {
1074
+ return (
1075
+ <div key={t.path} className={'system-view' + (t.path === activePath ? ' active' : '')}>
1076
+ <SystemView data={systemData} onOpen={onOpenFromSystem} />
1077
+ </div>
1078
+ );
1079
+ }
1080
+ return (
1081
+ <iframe
1082
+ key={t.path}
1083
+ ref={el => registerIframe(t.path, el)}
1084
+ src={canvasUrl(t.path, cfg)}
1085
+ className={t.path === activePath ? 'active' : ''}
1086
+ data-path={t.path}
1087
+ />
1088
+ );
1089
+ })}
1090
+ </div>
1091
+ );
1092
+ }
1093
+
1094
+ // ---------- SystemView ----------
1095
+
1096
+ const TOKEN_NAMES = [
1097
+ '--bg-0', '--bg-1', '--bg-2', '--bg-3', '--bg-4',
1098
+ '--fg-0', '--fg-1', '--fg-2', '--fg-3',
1099
+ '--accent', '--accent-hover', '--accent-active', '--accent-fg', '--accent-tint',
1100
+ '--status-success', '--status-warn', '--status-error', '--status-info',
1101
+ '--border-subtle', '--border-default', '--border-strong',
1102
+ ];
1103
+ const TYPE_STEPS = ['xs', 'sm', 'base', 'md', 'lg', 'xl', '2xl', '3xl'];
1104
+
1105
+ function readTokens(names) {
1106
+ if (typeof window === 'undefined') return names.map(name => ({ name, value: '' }));
1107
+ const cs = getComputedStyle(document.documentElement);
1108
+ return names.map(name => ({ name, value: cs.getPropertyValue(name).trim() }));
1109
+ }
1110
+
1111
+ function TokenLadder() {
1112
+ const [tokens, setTokens] = useState(() => readTokens(TOKEN_NAMES));
1113
+ useEffect(() => {
1114
+ setTokens(readTokens(TOKEN_NAMES));
1115
+ const obs = new MutationObserver(() => setTokens(readTokens(TOKEN_NAMES)));
1116
+ obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
1117
+ return () => obs.disconnect();
1118
+ }, []);
1119
+ return (
1120
+ <section className="sv-section sv-section-tokens">
1121
+ <h2>tokens · surfaces & ink<span className="sv-h-num">{tokens.length}</span></h2>
1122
+ <div className="sv-tokens-ladder">
1123
+ {tokens.map(t => (
1124
+ <div className="sv-tok-cell" key={t.name}>
1125
+ <div className="sv-tok-swatch" style={{ background: `var(${t.name})` }} />
1126
+ <div className="sv-tok-meta">
1127
+ <code className="sv-tok-name">{t.name}</code>
1128
+ <span className="sv-tok-value">{t.value || '—'}</span>
1129
+ </div>
1130
+ </div>
1131
+ ))}
1132
+ </div>
1133
+ </section>
1134
+ );
1135
+ }
1136
+
1137
+ function TypeLadder() {
1138
+ return (
1139
+ <section className="sv-section sv-section-type">
1140
+ <h2>type · 8-step ladder<span className="sv-h-num">{TYPE_STEPS.length}</span></h2>
1141
+ <div className="sv-type-list">
1142
+ {TYPE_STEPS.map(s => (
1143
+ <div className="sv-type-row" key={s}>
1144
+ <code className="sv-type-tok">--type-{s}</code>
1145
+ <span className="sv-type-sample" style={{ fontSize: `var(--type-${s})`, lineHeight: `var(--lh-${s})` }}>
1146
+ The catalog is the system.
1147
+ </span>
1148
+ </div>
1149
+ ))}
1150
+ </div>
1151
+ </section>
1152
+ );
1153
+ }
1154
+
1155
+ function SystemView({ data, onOpen }) {
1156
+ if (!data) {
1157
+ return <div className="sv-empty"><p>Loading design system…</p></div>;
1158
+ }
1159
+ const { previewGallery, uiKitsGallery, systemDir } = data;
1160
+ const empty = (!previewGallery || !previewGallery.length) && (!uiKitsGallery || !uiKitsGallery.length);
1161
+
1162
+ return (
1163
+ <div className="sv">
1164
+ <header className="sv-header">
1165
+ <span className="sv-sku">MDCC-DSN/01</span>
1166
+ <span className="sv-title">design system view</span>
1167
+ <span className="sv-loc"><code>{systemDir}</code></span>
1168
+ </header>
1169
+
1170
+ <TokenLadder />
1171
+ <TypeLadder />
1172
+
1173
+ {empty ? (
1174
+ <div className="sv-empty">
1175
+ <p>No <code>preview/</code> or <code>ui_kits/</code> folders found under <code>{systemDir}</code>.</p>
1176
+ </div>
1177
+ ) : (
1178
+ <>
1179
+ <Gallery title="preview" items={previewGallery} onOpen={onOpen} kind="preview" />
1180
+ <Gallery title="ui kits" items={uiKitsGallery} onOpen={onOpen} kind="ui_kits" />
1181
+ </>
1182
+ )}
1183
+ </div>
1184
+ );
1185
+ }
1186
+
1187
+ function Gallery({ title, items, onOpen, kind }) {
1188
+ if (!items || items.length === 0) return null;
1189
+ return (
1190
+ <section className="sv-section">
1191
+ <h2>{title} <span className="sv-count">{items.length}</span></h2>
1192
+ <div className={'sv-previews sv-previews-' + kind}>
1193
+ {items.map(p => (
1194
+ <article key={p.path} className="sv-preview-card" onClick={() => onOpen(p.path)}>
1195
+ <div className="sv-preview-frame">
1196
+ <iframe src={urlOf(p.path)} title={p.label} scrolling="no" />
1197
+ </div>
1198
+ <div className="sv-preview-foot">
1199
+ <strong>{p.label}</strong>
1200
+ <code>{p.path}</code>
1201
+ </div>
1202
+ </article>
1203
+ ))}
1204
+ </div>
1205
+ </section>
1206
+ );
1207
+ }
1208
+
1209
+ // ---------- Comment composer / viewer ----------
1210
+
1211
+ function CommentBar({ activePath, selected, comments, focusedId, draft, setDraft, onSubmit, onCancel, onResolve, onReopen, onDelete, onFocusPin }) {
1212
+ if (!activePath) return null;
1213
+ const focused = focusedId ? comments.find(c => c.id === focusedId) : null;
1214
+ const openComments = (comments || []).filter(c => c.status !== 'resolved');
1215
+ return (
1216
+ <div className="comment-bar">
1217
+ {draft && draft.file === activePath && (
1218
+ <div className="composer">
1219
+ <div className="composer-head">
1220
+ <span className="cb-label">Comment on</span>
1221
+ <code className="composer-selector" title={(draft.dom_path || []).join(' > ')}>{draft.selector || '(canvas)'}</code>
1222
+ </div>
1223
+ <textarea
1224
+ autoFocus
1225
+ className="composer-textarea"
1226
+ value={draft.text}
1227
+ placeholder="What should change here? (⌘↵ save · Esc cancel)"
1228
+ onChange={e => setDraft({ ...draft, text: e.target.value })}
1229
+ onKeyDown={e => {
1230
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); onSubmit(); }
1231
+ else if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
1232
+ }}
1233
+ rows={4}
1234
+ />
1235
+ <div className="composer-actions">
1236
+ <button className="cb-secondary" onClick={onCancel}>Cancel</button>
1237
+ <button className="cb-primary" disabled={!draft.text.trim()} onClick={onSubmit}>Save · ⌘↵</button>
1238
+ </div>
1239
+ </div>
1240
+ )}
1241
+
1242
+ {focused && (
1243
+ <div className="cb-row focused">
1244
+ <span className="cb-pinno">#{(comments || []).filter(c => c.selector).findIndex(c => c.id === focused.id) + 1}</span>
1245
+ <span className="cb-text">{focused.text}</span>
1246
+ <span className="cb-target" title={focused.dom_path ? focused.dom_path.join(' > ') : ''}>
1247
+ <code>{focused.selector || '—'}</code>
1248
+ </span>
1249
+ {focused.status === 'resolved'
1250
+ ? <button className="cb-secondary" onClick={() => onReopen(focused.id)}>Reopen</button>
1251
+ : <button className="cb-primary" onClick={() => onResolve(focused.id)}>✓ Resolve</button>}
1252
+ <button className="cb-secondary" onClick={() => onDelete(focused.id)}>Delete</button>
1253
+ </div>
1254
+ )}
1255
+
1256
+ {!draft && !focused && openComments.length > 0 && (
1257
+ <div className="cb-row strip">
1258
+ <span className="cb-label">{openComments.length} open comment{openComments.length === 1 ? '' : 's'}</span>
1259
+ <div className="cb-pin-strip">
1260
+ {openComments.slice(0, 12).map((c, i) => (
1261
+ <button key={c.id} className="cb-pin-chip" title={c.text} onClick={() => onFocusPin(c.id)}>
1262
+ {i + 1}
1263
+ </button>
1264
+ ))}
1265
+ {openComments.length > 12 && <span className="cb-more">+{openComments.length - 12}</span>}
1266
+ </div>
1267
+ </div>
1268
+ )}
1269
+ </div>
1270
+ );
1271
+ }
1272
+
1273
+ function StatusBarSlot({ label, children, className = '' }) {
1274
+ return (
1275
+ <span className={'sb-slot ' + className} role="group" aria-label={label}>
1276
+ {children}
1277
+ </span>
1278
+ );
1279
+ }
1280
+
1281
+ function StatusBar({ activePath, selected, wsConnected, openCount, theme, onToggleTheme, onClearSelected, onAddComment, hasDraft }) {
1282
+ const isSystem = activePath === SYSTEM_TAB;
1283
+ const text = selected && selected.selector
1284
+ ? selected.selector + (selected.text ? ` — "${selected.text.slice(0, 60)}"` : '')
1285
+ : '';
1286
+ const title = selected && selected.dom_path ? selected.dom_path.join(' > ') : (selected ? selected.selector : '');
1287
+ return (
1288
+ <div className="statusbar" role="contentinfo">
1289
+ <StatusBarSlot label="Active file" className="sb-active">
1290
+ <span className="sb-key">active</span>
1291
+ <span className="sb-file" title={activePath || ''}>
1292
+ {isSystem ? '▦ design system' : (activePath || '—')}
1293
+ </span>
1294
+ </StatusBarSlot>
1295
+
1296
+ {selected && selected.selector && !isSystem && (
1297
+ <StatusBarSlot label="Selected element" className="sb-selected">
1298
+ <span className="sb-dot" aria-hidden="true">●</span>
1299
+ <span className="sb-sel-text" title={title}>{text}</span>
1300
+ {!hasDraft && (
1301
+ <button type="button" className="sb-add-comment" onClick={onAddComment} title="Add comment on selected element (⌘⇧+click in canvas)">+ comment</button>
1302
+ )}
1303
+ <button type="button" className="sb-clear-sel" onClick={onClearSelected} title="Clear (Esc inside iframe)" aria-label="Clear selection">×</button>
1304
+ </StatusBarSlot>
1305
+ )}
1306
+
1307
+ <StatusBarSlot label="Open comments" className="sb-unread">
1308
+ <span className="sb-key">comments</span>
1309
+ <span className="sb-count">{openCount}</span>
1310
+ </StatusBarSlot>
1311
+
1312
+ <StatusBarSlot label="Connection" className="sb-live">
1313
+ <span className={'sb-live-dot' + (wsConnected ? ' connected' : '')} aria-hidden="true" />
1314
+ <span className="sb-key">{wsConnected ? 'live' : 'reconnecting'}</span>
1315
+ </StatusBarSlot>
1316
+
1317
+ <span className="sb-spacer" />
1318
+
1319
+ <StatusBarSlot label="Theme" className="sb-theme">
1320
+ <ThemeToggle theme={theme} onToggle={onToggleTheme} />
1321
+ </StatusBarSlot>
1322
+ </div>
1323
+ );
1324
+ }
1325
+
1326
+ // ---------- Right sidebar — Comments panel ----------
1327
+
1328
+ function CommentsPanel({ commentsByFile, filter, setFilter, activePath, focusedId, onJump, onResolve, onReopen, onDelete }) {
1329
+ const counts = totalCounts(commentsByFile);
1330
+ // Build groups: [{ file, comments: filtered }]
1331
+ const files = Object.keys(commentsByFile || {}).sort();
1332
+ const groups = [];
1333
+ for (const f of files) {
1334
+ const all = commentsByFile[f] || [];
1335
+ const filtered = all.filter(c => {
1336
+ if (filter === 'open') return c.status !== 'resolved';
1337
+ if (filter === 'resolved') return c.status === 'resolved';
1338
+ return true;
1339
+ });
1340
+ if (filtered.length === 0) continue;
1341
+ // Number is fixed by all-list order so it matches pin numbers (which are based on position in the array of selector-having comments)
1342
+ const numberedAll = all.filter(c => c.selector);
1343
+ groups.push({
1344
+ file: f,
1345
+ comments: filtered.map(c => ({
1346
+ ...c,
1347
+ n: numberedAll.findIndex(x => x.id === c.id) + 1,
1348
+ })),
1349
+ });
1350
+ }
1351
+
1352
+ return (
1353
+ <aside className="rsidebar">
1354
+ <div className="rsidebar-header">
1355
+ <h2>
1356
+ <span>Comments</span>
1357
+ <span className="total">{counts.all}</span>
1358
+ </h2>
1359
+ <div className="rsidebar-filters" role="tablist">
1360
+ <button
1361
+ className={'rsidebar-filter' + (filter === 'all' ? ' active' : '')}
1362
+ role="tab" aria-selected={filter === 'all'}
1363
+ onClick={() => setFilter('all')}
1364
+ >All <span className="fc">{counts.all}</span></button>
1365
+ <button
1366
+ className={'rsidebar-filter' + (filter === 'open' ? ' active' : '')}
1367
+ role="tab" aria-selected={filter === 'open'}
1368
+ onClick={() => setFilter('open')}
1369
+ >Open <span className="fc">{counts.open}</span></button>
1370
+ <button
1371
+ className={'rsidebar-filter' + (filter === 'resolved' ? ' active' : '')}
1372
+ role="tab" aria-selected={filter === 'resolved'}
1373
+ onClick={() => setFilter('resolved')}
1374
+ >Resolved <span className="fc">{counts.resolved}</span></button>
1375
+ </div>
1376
+ </div>
1377
+ <div className="rsidebar-body">
1378
+ {groups.length === 0 ? (
1379
+ <div className="rsidebar-empty">
1380
+ <p>No comments {filter !== 'all' ? `with status “${filter}”` : 'yet'}.</p>
1381
+ <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>
1382
+ </div>
1383
+ ) : groups.map(g => (
1384
+ <div key={g.file} className="rs-group">
1385
+ <button
1386
+ className="rs-group-h"
1387
+ onClick={() => onJump(g.file, null)}
1388
+ title={g.file}
1389
+ >
1390
+ <span className="rs-group-name">{displayName(basename(g.file))}</span>
1391
+ <span className="rs-group-count">{g.comments.length}</span>
1392
+ </button>
1393
+ {g.comments.map(c => (
1394
+ <div
1395
+ key={c.id}
1396
+ className={'rs-comment' + (c.status === 'resolved' ? ' resolved' : '') + (c.id === focusedId ? ' active-pin' : '')}
1397
+ onClick={() => onJump(g.file, c.id)}
1398
+ >
1399
+ <div className="rs-comment-head">
1400
+ <span className="rs-num">{c.n || '·'}</span>
1401
+ <span className="rs-time">{timeAgo(c.created)}</span>
1402
+ </div>
1403
+ <div className="rs-comment-text">{c.text}</div>
1404
+ <div className="rs-comment-foot">
1405
+ <code title={(c.dom_path || []).join(' > ')}>{c.selector || '—'}</code>
1406
+ <div className="rs-comment-actions">
1407
+ {c.status === 'resolved'
1408
+ ? <button className="rs-act" onClick={e => { e.stopPropagation(); onReopen(c.id); }}>↺</button>
1409
+ : <button className="rs-act" onClick={e => { e.stopPropagation(); onResolve(c.id); }}>✓</button>}
1410
+ <button className="rs-act danger" onClick={e => { e.stopPropagation(); onDelete(c.id); }}>×</button>
1411
+ </div>
1412
+ </div>
1413
+ </div>
1414
+ ))}
1415
+ </div>
1416
+ ))}
1417
+ </div>
1418
+ </aside>
1419
+ );
1420
+ }
1421
+
1422
+ // ---------- App ----------
1423
+
1424
+ function App() {
1425
+ const [groups, setGroups] = useState([]);
1426
+ const [project, setProject] = useState('Design');
1427
+ const [tabs, setTabs] = useState([]);
1428
+ const [activePath, setActivePath] = useState(null);
1429
+ const [selected, setSelected] = useState(null);
1430
+ const [wsConnected, setWsConnected] = useState(false);
1431
+ const [search, setSearch] = useState('');
1432
+ const [systemData, setSystemData] = useState(null);
1433
+ // Loaded once at boot from /_config — informs canvasUrl() so TSX iframes
1434
+ // can pass the right ?designRel + ?tokens query to the canvas mount shell.
1435
+ const [cfg, setCfg] = useState({ designRel: '.design' });
1436
+ useEffect(() => {
1437
+ let cancelled = false;
1438
+ fetch('/_config')
1439
+ .then((r) => r.json())
1440
+ .then((data) => {
1441
+ if (cancelled) return;
1442
+ const designRel = (data.designRoot || '.design').replace(/^\/+|\/+$/g, '');
1443
+ setCfg({
1444
+ designRel,
1445
+ tokensCssRel: data.tokensCssRel,
1446
+ // Pass through designSystems so canvasUrl can resolve the right
1447
+ // tokens/components paths per-DS. Top-level tokensCssRel is the
1448
+ // legacy default; designSystems[0].tokensCssRel is the project's
1449
+ // authoritative value (post DS-bootstrap).
1450
+ designSystems: data.designSystems,
1451
+ });
1452
+ })
1453
+ .catch(() => {});
1454
+ return () => { cancelled = true; };
1455
+ }, []);
1456
+ const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
1457
+ const [draft, setDraft] = useState(null); // { file, selector, dom_path, bounds, tag, classes, html, text }
1458
+ const [focusedCommentId, setFocusedCommentId] = useState(null);
1459
+ const [commentsPanelOpen, setCommentsPanelOpen] = useState(false);
1460
+ const [commentsFilter, setCommentsFilter] = useState('open'); // 'all' | 'open' | 'resolved'
1461
+ const [theme, setTheme] = useState(readInitialTheme);
1462
+ const [openMenu, setOpenMenu] = useState(null);
1463
+ const [sidebarOpen, setSidebarOpen] = useState(() => readBoolStore(SIDEBAR_STORE, true));
1464
+ const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
1465
+ const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
1466
+ const [helpOpen, setHelpOpen] = useState(false);
1467
+ const [annotationsVisible, setAnnotationsVisible] = useState(true);
1468
+ const wsRef = useRef(null);
1469
+ const iframesRef = useRef(new Map());
1470
+
1471
+ // Phase 5.1 — postMessage bridge from menubar dropdowns to the canvas iframe.
1472
+ // The canvas-shell listens for these `dgn:*` messages and dispatches into the
1473
+ // matching local provider (annotations visibility / both selection stores /
1474
+ // tool mode). Mirrors the existing `force-clear` / `select-clear` channel.
1475
+ const postToActiveCanvas = useCallback((payload) => {
1476
+ const el = activePath ? iframesRef.current.get(activePath) : null;
1477
+ if (!el || !el.contentWindow) return;
1478
+ try { el.contentWindow.postMessage(payload, '*'); } catch {}
1479
+ }, [activePath]);
1480
+
1481
+ const toggleAnnotations = useCallback(() => {
1482
+ setAnnotationsVisible((v) => {
1483
+ const next = !v;
1484
+ const el = activePath ? iframesRef.current.get(activePath) : null;
1485
+ if (el && el.contentWindow) {
1486
+ try { el.contentWindow.postMessage({ dgn: 'view-annotations', visible: next }, '*'); } catch {}
1487
+ }
1488
+ return next;
1489
+ });
1490
+ }, [activePath]);
1491
+
1492
+ // Sync theme to <html data-theme> + localStorage on every change.
1493
+ useEffect(() => {
1494
+ try {
1495
+ document.documentElement.setAttribute('data-theme', theme);
1496
+ localStorage.setItem(THEME_STORE, theme);
1497
+ } catch {}
1498
+ }, [theme]);
1499
+
1500
+ // Persist sidebar / hidden-files / DS-body toggles. Mirror theme pattern.
1501
+ useEffect(() => {
1502
+ try { localStorage.setItem(SIDEBAR_STORE, sidebarOpen ? '1' : '0'); } catch {}
1503
+ }, [sidebarOpen]);
1504
+ useEffect(() => {
1505
+ try { localStorage.setItem(SHOW_HIDDEN_STORE, showHidden ? '1' : '0'); } catch {}
1506
+ }, [showHidden]);
1507
+ useEffect(() => {
1508
+ try { localStorage.setItem(SECTIONS_STORE, JSON.stringify(sectionsExpanded)); } catch {}
1509
+ }, [sectionsExpanded]);
1510
+
1511
+ const toggleSection = useCallback((label, defaultOpen) => {
1512
+ setSectionsExpanded(prev => {
1513
+ const cur = prev[label];
1514
+ const isOpen = cur === undefined ? defaultOpen : cur;
1515
+ return { ...prev, [label]: !isOpen };
1516
+ });
1517
+ }, []);
1518
+
1519
+ const toggleTheme = useCallback(() => {
1520
+ setTheme(t => (t === 'dark' ? 'light' : 'dark'));
1521
+ }, []);
1522
+
1523
+ // ----- Tree -----
1524
+ const loadTree = useCallback(async () => {
1525
+ try {
1526
+ const r = await fetch('/_index-data');
1527
+ const data = await r.json();
1528
+ setProject(data.project || 'Design');
1529
+ const built = data.groups.map(g => ({
1530
+ ...g,
1531
+ tree: buildTree(g.paths, g.stripPrefix),
1532
+ }));
1533
+ setGroups(built);
1534
+ } catch (e) {
1535
+ console.error('failed to load tree', e);
1536
+ }
1537
+ }, []);
1538
+
1539
+ useEffect(() => { loadTree(); }, [loadTree]);
1540
+
1541
+ // ----- System data (lazy) -----
1542
+ const loadSystemData = useCallback(async () => {
1543
+ try {
1544
+ const r = await fetch('/_system-data');
1545
+ const data = await r.json();
1546
+ setSystemData(data);
1547
+ } catch (e) {
1548
+ console.error('failed to load system-data', e);
1549
+ }
1550
+ }, []);
1551
+
1552
+ // ----- Comments — initial load of all files -----
1553
+ const loadAllComments = useCallback(async () => {
1554
+ try {
1555
+ const r = await fetch('/_comments-all');
1556
+ const data = await r.json();
1557
+ setCommentsByFile(data || {});
1558
+ } catch (e) {
1559
+ console.error('failed to load comments', e);
1560
+ }
1561
+ }, []);
1562
+
1563
+ useEffect(() => { loadAllComments(); }, [loadAllComments]);
1564
+
1565
+ // ----- WebSocket -----
1566
+ useEffect(() => {
1567
+ function connect() {
1568
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
1569
+ const ws = new WebSocket(proto + '//' + location.host + '/_ws');
1570
+ wsRef.current = ws;
1571
+ ws.addEventListener('open', () => setWsConnected(true));
1572
+ ws.addEventListener('close', () => {
1573
+ setWsConnected(false);
1574
+ setTimeout(connect, 1000);
1575
+ });
1576
+ ws.addEventListener('error', () => {});
1577
+ ws.addEventListener('message', e => {
1578
+ try {
1579
+ const m = JSON.parse(e.data);
1580
+ if (m.type === 'snapshot' && m.state) {
1581
+ setSelected(m.state.selected);
1582
+ } else if (m.type === 'selected') {
1583
+ setSelected(m.selected);
1584
+ } else if (m.type === 'comments' && typeof m.file === 'string') {
1585
+ setCommentsByFile(prev => ({ ...prev, [m.file]: m.comments || [] }));
1586
+ }
1587
+ } catch {}
1588
+ });
1589
+ }
1590
+ connect();
1591
+ return () => wsRef.current && wsRef.current.close();
1592
+ }, []);
1593
+
1594
+ function wsSend(obj) {
1595
+ const ws = wsRef.current;
1596
+ try { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); } catch {}
1597
+ }
1598
+
1599
+ // ----- Tab management (single-canvas) -----
1600
+ // Single-canvas model: opening a file REPLACES the active one (no tab strip).
1601
+ // The `tabs` state stays as a 0-or-1 array so the rest of the plumbing
1602
+ // (iframesRef, comments push, WS `tabs` message) doesn't need refactoring.
1603
+ // ARTBOARDS slot in the menubar reads `tabs.length` and reports 0 or 1.
1604
+ const openTab = useCallback((path) => {
1605
+ setTabs(prev => {
1606
+ // Drop the previously-open iframe so we don't leak DOM nodes.
1607
+ for (const t of prev) if (t.path !== path) iframesRef.current.delete(t.path);
1608
+ return [{ path }];
1609
+ });
1610
+ setActivePath(path);
1611
+ setFocusedCommentId(null);
1612
+ setDraft(null);
1613
+ }, []);
1614
+
1615
+ const openSystem = useCallback(() => {
1616
+ if (!systemData) loadSystemData();
1617
+ openTab(SYSTEM_TAB);
1618
+ }, [systemData, loadSystemData, openTab]);
1619
+
1620
+ useEffect(() => {
1621
+ wsSend({ type: 'tabs', tabs: tabs.map(t => t.path).filter(p => p !== SYSTEM_TAB) });
1622
+ }, [tabs]);
1623
+
1624
+ useEffect(() => {
1625
+ if (activePath && activePath !== SYSTEM_TAB) wsSend({ type: 'active', file: activePath });
1626
+ else if (activePath === SYSTEM_TAB) wsSend({ type: 'active', file: '' });
1627
+ else wsSend({ type: 'active', file: '' });
1628
+ }, [activePath]);
1629
+
1630
+ const closeTab = useCallback((path) => {
1631
+ setTabs(prev => {
1632
+ const idx = prev.findIndex(t => t.path === path);
1633
+ if (idx < 0) return prev;
1634
+ const next = prev.filter(t => t.path !== path);
1635
+ if (path === activePath) {
1636
+ if (next.length === 0) setActivePath(null);
1637
+ else setActivePath(next[Math.max(0, idx - 1)].path);
1638
+ }
1639
+ return next;
1640
+ });
1641
+ iframesRef.current.delete(path);
1642
+ }, [activePath]);
1643
+
1644
+ const reloadActive = useCallback(() => {
1645
+ if (!activePath || activePath === SYSTEM_TAB) {
1646
+ if (activePath === SYSTEM_TAB) loadSystemData();
1647
+ return;
1648
+ }
1649
+ const el = iframesRef.current.get(activePath);
1650
+ if (el) el.src = el.src;
1651
+ }, [activePath, loadSystemData]);
1652
+
1653
+ const reloadTree = useCallback(() => loadTree(), [loadTree]);
1654
+
1655
+ const clearSelected = useCallback(() => {
1656
+ wsSend({ type: 'clear-select' });
1657
+ setSelected(null);
1658
+ if (activePath && activePath !== SYSTEM_TAB) {
1659
+ const el = iframesRef.current.get(activePath);
1660
+ if (el && el.contentWindow) {
1661
+ try { el.contentWindow.postMessage({ dgn: 'force-clear' }, '*'); } catch {}
1662
+ }
1663
+ }
1664
+ }, [activePath]);
1665
+
1666
+ // ----- Push comments to iframe whenever they change for active file -----
1667
+ useEffect(() => {
1668
+ if (!activePath || activePath === SYSTEM_TAB) return;
1669
+ const el = iframesRef.current.get(activePath);
1670
+ if (!el || !el.contentWindow) return;
1671
+ const list = commentsByFile[activePath] || [];
1672
+ try { el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*'); } catch {}
1673
+ }, [activePath, commentsByFile]);
1674
+
1675
+ // ----- Comment composer helpers -----
1676
+ // Declared BEFORE the inbound-message useEffect that references them — under
1677
+ // ES build (no var-style hoisting) these are real TDZ violations otherwise.
1678
+ const startDraftFor = useCallback((sel) => {
1679
+ const file = (sel && sel.file) || activePath;
1680
+ if (!file || file === SYSTEM_TAB) return;
1681
+ setDraft({
1682
+ file,
1683
+ selector: sel?.selector || '',
1684
+ dom_path: sel?.dom_path || [],
1685
+ tag: sel?.tag || '',
1686
+ classes: sel?.classes || '',
1687
+ bounds: sel?.bounds || null,
1688
+ html: sel?.html || '',
1689
+ text: '',
1690
+ });
1691
+ setFocusedCommentId(null);
1692
+ }, [activePath]);
1693
+
1694
+ const startDraftFromSelection = useCallback(() => {
1695
+ if (!selected || !selected.selector) return;
1696
+ startDraftFor(selected);
1697
+ }, [selected, startDraftFor]);
1698
+
1699
+ // ----- Inbound messages from iframes -----
1700
+ useEffect(() => {
1701
+ function onMessage(e) {
1702
+ const m = e.data;
1703
+ if (!m || typeof m !== 'object' || !m.dgn) return;
1704
+ if (m.dgn === 'select' && m.selection) {
1705
+ wsSend({ type: 'select', selection: m.selection });
1706
+ setSelected(m.selection);
1707
+ } else if (m.dgn === 'select-set') {
1708
+ // Canvas multi-select. Payload shape:
1709
+ // null → empty selection
1710
+ // Selection → length-1 (back-compat with legacy single-element shape)
1711
+ // Selection[] → N > 1
1712
+ // For shell purposes we track the focused entry (head of array, or
1713
+ // the bare object) — comments + halo only act on one element at a
1714
+ // time today. Multi-target editing is an explicit Phase-4.1 non-goal.
1715
+ const payload = m.selection;
1716
+ if (payload == null) {
1717
+ wsSend({ type: 'clear-select' });
1718
+ setSelected(null);
1719
+ } else if (Array.isArray(payload)) {
1720
+ const head = payload[0] ?? null;
1721
+ if (head) wsSend({ type: 'select', selection: head });
1722
+ setSelected(head);
1723
+ } else {
1724
+ wsSend({ type: 'select', selection: payload });
1725
+ setSelected(payload);
1726
+ }
1727
+ } else if (m.dgn === 'clear-select') {
1728
+ wsSend({ type: 'clear-select' });
1729
+ setSelected(null);
1730
+ } else if (m.dgn === 'comment-compose' && m.selection) {
1731
+ // Canvas C-tool / right-click "Add comment" converge here. The shell
1732
+ // opens a composer for the target.
1733
+ startDraftFor(m.selection);
1734
+ } else if (m.dgn === 'comment-shortcut') {
1735
+ // Carry-over for any legacy `.html` mock or external embed that
1736
+ // still posts this. Canvas-shell uses `comment-compose` directly.
1737
+ startDraftFromSelection();
1738
+ } else if (m.dgn === 'comment-click' && m.id) {
1739
+ setFocusedCommentId(m.id);
1740
+ } else if (m.dgn === 'loaded' && m.file) {
1741
+ // iframe finished loading — push current comments + carry over focused pin if any
1742
+ const list = commentsByFile[m.file] || [];
1743
+ const el = [...iframesRef.current.entries()].find(([k]) => k === m.file)?.[1];
1744
+ if (el && el.contentWindow) {
1745
+ try { el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*'); } catch {}
1746
+ if (focusedCommentId && list.some(c => c.id === focusedCommentId)) {
1747
+ try { el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*'); } catch {}
1748
+ }
1749
+ }
1750
+ }
1751
+ }
1752
+ window.addEventListener('message', onMessage);
1753
+ return () => window.removeEventListener('message', onMessage);
1754
+ }, [commentsByFile, focusedCommentId, startDraftFromSelection, startDraftFor]);
1755
+
1756
+ // Tell the active canvas iframe to drop any persistent selection (canvas
1757
+ // SelectionSet) — used when the comment composer closes via submit /
1758
+ // cancel / Esc. canvas-shell listens for `force-clear` on the window
1759
+ // message channel and calls selSet.clear().
1760
+ const clearActiveCanvasSelection = useCallback(() => {
1761
+ if (!activePath || activePath === SYSTEM_TAB) return;
1762
+ const el = iframesRef.current.get(activePath);
1763
+ if (el && el.contentWindow) {
1764
+ try { el.contentWindow.postMessage({ dgn: 'force-clear' }, '*'); } catch {}
1765
+ }
1766
+ }, [activePath]);
1767
+
1768
+ const submitDraft = useCallback(() => {
1769
+ if (!draft || !draft.text.trim()) return;
1770
+ wsSend({ type: 'comments-add', payload: {
1771
+ file: draft.file,
1772
+ selector: draft.selector,
1773
+ dom_path: draft.dom_path,
1774
+ tag: draft.tag,
1775
+ classes: draft.classes,
1776
+ bounds: draft.bounds,
1777
+ html_excerpt: draft.html,
1778
+ text: draft.text.trim(),
1779
+ }});
1780
+ setDraft(null);
1781
+ clearActiveCanvasSelection();
1782
+ }, [draft, clearActiveCanvasSelection]);
1783
+
1784
+ const cancelDraft = useCallback(() => {
1785
+ setDraft(null);
1786
+ clearActiveCanvasSelection();
1787
+ }, [clearActiveCanvasSelection]);
1788
+
1789
+ const resolveComment = useCallback((id) => {
1790
+ wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
1791
+ }, []);
1792
+ const reopenComment = useCallback((id) => {
1793
+ wsSend({ type: 'comments-patch', id, patch: { status: 'open' } });
1794
+ }, []);
1795
+ const deleteComment = useCallback((id) => {
1796
+ wsSend({ type: 'comments-delete', id });
1797
+ setFocusedCommentId(prev => (prev === id ? null : prev));
1798
+ }, []);
1799
+
1800
+ const focusPinFromBar = useCallback((id) => {
1801
+ setFocusedCommentId(id);
1802
+ if (activePath && activePath !== SYSTEM_TAB) {
1803
+ const el = iframesRef.current.get(activePath);
1804
+ if (el && el.contentWindow) {
1805
+ try { el.contentWindow.postMessage({ dgn: 'comment-focus', id }, '*'); } catch {}
1806
+ }
1807
+ }
1808
+ }, [activePath]);
1809
+
1810
+ // Jump from right-sidebar list to a comment: open file tab if needed, focus pin.
1811
+ // The iframe may be freshly mounted; the loaded handler also re-sends focus if focusedCommentId matches.
1812
+ const jumpToComment = useCallback((file, id) => {
1813
+ if (file && file !== activePath) {
1814
+ setTabs(prev => prev.find(t => t.path === file) ? prev : [...prev, { path: file }]);
1815
+ setActivePath(file);
1816
+ }
1817
+ if (id == null) {
1818
+ setFocusedCommentId(null);
1819
+ return;
1820
+ }
1821
+ setFocusedCommentId(id);
1822
+ // Try sending focus immediately (existing iframe) and again after a short delay (newly opened tab).
1823
+ const send = () => {
1824
+ const el = iframesRef.current.get(file);
1825
+ if (el && el.contentWindow) {
1826
+ try { el.contentWindow.postMessage({ dgn: 'comment-focus', id }, '*'); } catch {}
1827
+ }
1828
+ };
1829
+ send();
1830
+ setTimeout(send, 200);
1831
+ }, [activePath]);
1832
+
1833
+ // ----- Keyboard shortcuts (no Cmd+W — let browser close the tab) -----
1834
+ useEffect(() => {
1835
+ function onKey(e) {
1836
+ const meta = e.metaKey || e.ctrlKey;
1837
+ const inEditable = ['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName) || document.activeElement?.isContentEditable;
1838
+ // Phase 4.1: shell-side letter shortcuts (H/T/S) must not double-fire
1839
+ // inside a focused canvas iframe — the canvas input router owns those
1840
+ // letters as tool-mode keys (V/H/C). Cmd-modified shortcuts (⌘R, ⌘⇧M,
1841
+ // ⌘F) still fire regardless of focus, mirroring browser convention.
1842
+ const inCanvasIframe = document.activeElement?.tagName === 'IFRAME';
1843
+
1844
+ // Cmd+R — reload active iframe (override browser reload)
1845
+ if (meta && (e.key === 'r' || e.key === 'R')) {
1846
+ e.preventDefault();
1847
+ reloadActive();
1848
+ return;
1849
+ }
1850
+ // Cmd+Shift+M / Ctrl+Shift+M — toggle right "Comments" panel
1851
+ if (meta && e.shiftKey && (e.key === 'm' || e.key === 'M')) {
1852
+ e.preventDefault();
1853
+ setCommentsPanelOpen(v => !v);
1854
+ return;
1855
+ }
1856
+ // Cmd+C / Ctrl+C — Phase 4.1 removed the shell-side comment-drop chord.
1857
+ // Canvas comment-drop is the `C` tool letter (press C in the canvas,
1858
+ // then click the element) or right-click "Add comment". Cmd+C now
1859
+ // reverts to native browser copy.
1860
+ if (meta && !e.shiftKey && !e.altKey && (e.key === 'c' || e.key === 'C')) {
1861
+ if (selected && selected.selector && activePath && activePath !== SYSTEM_TAB && !inEditable && console && console.warn) {
1862
+ console.warn('Cmd+C comment-drop deprecated — press C inside the canvas to enter Comment tool, then click the element.');
1863
+ }
1864
+ // Fall through to native copy.
1865
+ }
1866
+ if (inEditable) return;
1867
+ // / — focus search (or ⌘F per CV-08 placeholder hint)
1868
+ if (e.key === '/') {
1869
+ e.preventDefault();
1870
+ const inp = document.querySelector('.tree-panel-search input');
1871
+ if (inp) inp.focus();
1872
+ return;
1873
+ }
1874
+ if (meta && (e.key === 'f' || e.key === 'F')) {
1875
+ e.preventDefault();
1876
+ if (!sidebarOpen) setSidebarOpen(true);
1877
+ setTimeout(() => {
1878
+ const inp = document.querySelector('.tree-panel-search input');
1879
+ if (inp) inp.focus();
1880
+ }, 0);
1881
+ return;
1882
+ }
1883
+ // T / H / S are bare-letter shell shortcuts. When focus is inside a
1884
+ // canvas iframe, the canvas input router claims V/H/C — bail out
1885
+ // here so the canvas owns the key and the sidebar/system view don't
1886
+ // double-fire on focused-canvas keypresses.
1887
+ if (inCanvasIframe) {
1888
+ // Esc still bubbles below (composer / focused-pin clear).
1889
+ if (e.key !== 'Escape') return;
1890
+ }
1891
+ // T — toggle Project Tree (sidebar)
1892
+ if (e.key === 't' || e.key === 'T') {
1893
+ if (e.shiftKey || meta) return;
1894
+ e.preventDefault();
1895
+ setSidebarOpen(v => !v);
1896
+ return;
1897
+ }
1898
+ // H — toggle show-hidden (sidecars + project/runtime orphans)
1899
+ if (e.key === 'h' || e.key === 'H') {
1900
+ if (e.shiftKey || meta) return;
1901
+ e.preventDefault();
1902
+ setShowHidden(v => !v);
1903
+ return;
1904
+ }
1905
+ // S — toggle Design system view
1906
+ if ((e.key === 's' || e.key === 'S') && !meta && !e.shiftKey) {
1907
+ e.preventDefault();
1908
+ if (activePath === SYSTEM_TAB) {
1909
+ closeTab(SYSTEM_TAB);
1910
+ } else {
1911
+ openSystem();
1912
+ }
1913
+ return;
1914
+ }
1915
+ // ? or F1 — open Help modal
1916
+ if (e.key === '?' || e.key === 'F1') {
1917
+ e.preventDefault();
1918
+ setHelpOpen(true);
1919
+ return;
1920
+ }
1921
+ // Esc — close composer (in addition to its own textarea handler) or clear focused pin
1922
+ if (e.key === 'Escape') {
1923
+ if (draft) { setDraft(null); clearActiveCanvasSelection(); return; }
1924
+ if (focusedCommentId) { setFocusedCommentId(null); return; }
1925
+ }
1926
+ }
1927
+ window.addEventListener('keydown', onKey);
1928
+ return () => window.removeEventListener('keydown', onKey);
1929
+ }, [reloadActive, selected, activePath, startDraftFromSelection, draft, focusedCommentId, sidebarOpen, openSystem, closeTab, clearActiveCanvasSelection]);
1930
+
1931
+ const registerIframe = useCallback((path, el) => {
1932
+ if (el) iframesRef.current.set(path, el);
1933
+ }, []);
1934
+
1935
+ const activeFileComments = (activePath && activePath !== SYSTEM_TAB) ? (commentsByFile[activePath] || []) : [];
1936
+ const totalOpen = totalCounts(commentsByFile).open;
1937
+
1938
+ return (
1939
+ <div className={'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')}>
1940
+ <Sidebar
1941
+ groups={groups}
1942
+ activePath={activePath}
1943
+ onOpen={openTab}
1944
+ onOpenSystem={openSystem}
1945
+ wsConnected={wsConnected}
1946
+ search={search}
1947
+ setSearch={setSearch}
1948
+ commentsByFile={commentsByFile}
1949
+ showHidden={showHidden}
1950
+ sectionsExpanded={sectionsExpanded}
1951
+ onToggleSection={toggleSection}
1952
+ />
1953
+ <div className="main">
1954
+ <Menubar
1955
+ activePath={activePath}
1956
+ project={project}
1957
+ tabsCount={tabs.length}
1958
+ openMenu={openMenu}
1959
+ setOpenMenu={setOpenMenu}
1960
+ commentsPanelOpen={commentsPanelOpen}
1961
+ onToggleComments={() => setCommentsPanelOpen(v => !v)}
1962
+ onOpenSystem={openSystem}
1963
+ sidebarOpen={sidebarOpen}
1964
+ onToggleSidebar={() => setSidebarOpen(v => !v)}
1965
+ showHidden={showHidden}
1966
+ onToggleShowHidden={() => setShowHidden(v => !v)}
1967
+ onOpenHelp={() => setHelpOpen(true)}
1968
+ annotationsVisible={annotationsVisible}
1969
+ onToggleAnnotations={toggleAnnotations}
1970
+ postToActiveCanvas={postToActiveCanvas}
1971
+ />
1972
+ <Viewport
1973
+ tabs={tabs}
1974
+ activePath={activePath}
1975
+ registerIframe={registerIframe}
1976
+ systemData={systemData}
1977
+ onOpenFromSystem={openTab}
1978
+ project={project}
1979
+ cfg={cfg}
1980
+ />
1981
+ {activePath && activePath !== SYSTEM_TAB && (
1982
+ <CommentBar
1983
+ activePath={activePath}
1984
+ selected={selected}
1985
+ comments={activeFileComments}
1986
+ focusedId={focusedCommentId}
1987
+ draft={draft && draft.file === activePath ? draft : null}
1988
+ setDraft={setDraft}
1989
+ onSubmit={submitDraft}
1990
+ onCancel={cancelDraft}
1991
+ onResolve={resolveComment}
1992
+ onReopen={reopenComment}
1993
+ onDelete={deleteComment}
1994
+ onFocusPin={focusPinFromBar}
1995
+ />
1996
+ )}
1997
+ <StatusBar
1998
+ activePath={activePath}
1999
+ selected={selected}
2000
+ wsConnected={wsConnected}
2001
+ openCount={totalOpen}
2002
+ theme={theme}
2003
+ onToggleTheme={toggleTheme}
2004
+ onClearSelected={clearSelected}
2005
+ onAddComment={startDraftFromSelection}
2006
+ hasDraft={!!(draft && draft.file === activePath)}
2007
+ />
2008
+ </div>
2009
+ {commentsPanelOpen && (
2010
+ <CommentsPanel
2011
+ commentsByFile={commentsByFile}
2012
+ filter={commentsFilter}
2013
+ setFilter={setCommentsFilter}
2014
+ activePath={activePath}
2015
+ focusedId={focusedCommentId}
2016
+ onJump={jumpToComment}
2017
+ onResolve={resolveComment}
2018
+ onReopen={reopenComment}
2019
+ onDelete={deleteComment}
2020
+ />
2021
+ )}
2022
+ <HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
2023
+ </div>
2024
+ );
2025
+ }
2026
+
2027
+ createRoot(document.getElementById('root')).render(<App />);