@1agh/maude 0.28.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (372) hide show
  1. package/README.md +3 -3
  2. package/{plugins/design/dev-server → apps/studio}/ai-banner.tsx +1 -2
  3. package/apps/studio/annotations-align.ts +149 -0
  4. package/apps/studio/annotations-bindings.ts +197 -0
  5. package/{plugins/design/dev-server → apps/studio}/annotations-context-toolbar.tsx +681 -151
  6. package/apps/studio/annotations-groups.ts +270 -0
  7. package/apps/studio/annotations-layer.tsx +4537 -0
  8. package/apps/studio/annotations-model.ts +2077 -0
  9. package/apps/studio/annotations-snap.ts +125 -0
  10. package/{plugins/design/dev-server → apps/studio}/api.ts +616 -70
  11. package/{plugins/design/dev-server → apps/studio}/artboard-activity-overlay.tsx +1 -1
  12. package/{plugins/design/dev-server → apps/studio}/artboard-marquee.tsx +1 -1
  13. package/apps/studio/bin/_motion-sample-playwright.mjs +129 -0
  14. package/{plugins/design/dev-server → apps/studio}/bin/_png-playwright.mjs +1 -1
  15. package/apps/studio/bin/annotate.mjs +732 -0
  16. package/apps/studio/bin/annotate.sh +17 -0
  17. package/{plugins/design/dev-server → apps/studio}/bin/canvas-edit.sh +1 -1
  18. package/{plugins/design/dev-server → apps/studio}/bin/draw-proof.sh +37 -2
  19. package/{plugins/design/dev-server → apps/studio}/bin/preflight.sh +1 -1
  20. package/apps/studio/bin/read-annotations.mjs +573 -0
  21. package/apps/studio/bin/read-annotations.sh +11 -0
  22. package/{plugins/design/dev-server → apps/studio}/bin/runtime-health.sh +1 -1
  23. package/{plugins/design/dev-server → apps/studio}/bin/screenshot.sh +62 -2
  24. package/{plugins/design/dev-server → apps/studio}/bin/server-up.sh +9 -3
  25. package/apps/studio/bin/to-lottie-verify.html +68 -0
  26. package/apps/studio/bin/to-lottie.sh +128 -0
  27. package/{plugins/design/dev-server → apps/studio}/build.ts +6 -4
  28. package/{plugins/design/dev-server → apps/studio}/canvas-arrowheads.ts +79 -10
  29. package/{plugins/design/dev-server → apps/studio}/canvas-comment-mount.tsx +1 -1
  30. package/apps/studio/canvas-create.ts +104 -0
  31. package/{plugins/design/dev-server → apps/studio}/canvas-cursors.ts +48 -12
  32. package/{plugins/design/dev-server → apps/studio}/canvas-edit.ts +257 -7
  33. package/{plugins/design/dev-server → apps/studio}/canvas-icons.tsx +165 -1
  34. package/{plugins/design/dev-server → apps/studio}/canvas-lib-inline.ts +1 -1
  35. package/{plugins/design/dev-server → apps/studio}/canvas-lib-resolver.ts +1 -1
  36. package/{plugins/design/dev-server → apps/studio}/canvas-lib.tsx +31 -12
  37. package/{plugins/design/dev-server → apps/studio}/canvas-shell.tsx +360 -48
  38. package/apps/studio/client/app.jsx +6742 -0
  39. package/apps/studio/client/canvas-url.js +81 -0
  40. package/{plugins/design/dev-server → apps/studio}/client/comments-overlay.css +41 -27
  41. package/apps/studio/client/index.html +16 -0
  42. package/apps/studio/client/styles/1-tokens-maude.css +296 -0
  43. package/apps/studio/client/styles/3-shell-maude.css +1161 -0
  44. package/{plugins/design/dev-server → apps/studio}/client/styles/3-shell.css +129 -0
  45. package/apps/studio/client/styles/4-components-maude.css +74 -0
  46. package/{plugins/design/dev-server → apps/studio}/client/styles/4-components.css +396 -34
  47. package/apps/studio/client/styles/5-maude-overrides.css +137 -0
  48. package/{plugins/design/dev-server → apps/studio}/client/styles/_index.css +9 -0
  49. package/{plugins/design/dev-server → apps/studio}/client/styles.css +1 -1
  50. package/apps/studio/client/tour/overlay.jsx +279 -0
  51. package/apps/studio/client/tour/usage-tour.js +48 -0
  52. package/apps/studio/client/whats-new-seen.js +52 -0
  53. package/apps/studio/client/whats-new.jsx +250 -0
  54. package/{plugins/design/dev-server → apps/studio}/commands/annotation-strokes-command.ts +2 -2
  55. package/{plugins/design/dev-server → apps/studio}/commands/equal-spacing-command.ts +1 -1
  56. package/{plugins/design/dev-server → apps/studio}/commands/move-artboards-command.ts +2 -2
  57. package/{plugins/design/dev-server → apps/studio}/comments-overlay.tsx +32 -11
  58. package/{plugins/design/dev-server → apps/studio}/context-menu.tsx +5 -4
  59. package/{plugins/design/dev-server → apps/studio}/contextual-toolbar.tsx +18 -7
  60. package/{plugins/design/dev-server → apps/studio}/cursors-overlay.tsx +21 -21
  61. package/apps/studio/dist/client.bundle.js +25545 -0
  62. package/apps/studio/dist/comment-mount.js +2046 -0
  63. package/apps/studio/dist/styles.css +8018 -0
  64. package/apps/studio/dom-selection.ts +342 -0
  65. package/apps/studio/draw/animate.ts +333 -0
  66. package/{plugins/design/dev-server → apps/studio}/draw/brush.ts +1 -1
  67. package/{plugins/design/dev-server → apps/studio}/draw/composition.ts +1 -1
  68. package/{plugins/design/dev-server → apps/studio}/draw/geometry.ts +1 -1
  69. package/{plugins/design/dev-server → apps/studio}/draw/index.ts +4 -1
  70. package/{plugins/design/dev-server → apps/studio}/draw/layout.ts +1 -1
  71. package/apps/studio/draw/morph.ts +204 -0
  72. package/{plugins/design/dev-server → apps/studio}/draw/optimize.ts +1 -1
  73. package/{plugins/design/dev-server → apps/studio}/draw/palette.ts +1 -1
  74. package/{plugins/design/dev-server → apps/studio}/draw/primitives.ts +1 -1
  75. package/apps/studio/draw/serialize-animate.ts +324 -0
  76. package/{plugins/design/dev-server → apps/studio}/draw/serialize.ts +9 -3
  77. package/apps/studio/draw/test/animate.test.ts +172 -0
  78. package/apps/studio/draw/test/morph.test.ts +107 -0
  79. package/apps/studio/draw/test/serialize-animate.test.ts +142 -0
  80. package/{plugins/design/dev-server → apps/studio}/equal-spacing-detector.ts +1 -1
  81. package/{plugins/design/dev-server → apps/studio}/equal-spacing-handles.tsx +7 -29
  82. package/{plugins/design/dev-server → apps/studio}/export-dialog.tsx +16 -4
  83. package/{plugins/design/dev-server → apps/studio}/history.ts +20 -3
  84. package/{plugins/design/dev-server → apps/studio}/http.ts +226 -6
  85. package/{plugins/design/dev-server → apps/studio}/input-router.tsx +15 -1
  86. package/{plugins/design/dev-server → apps/studio}/inspect.ts +50 -4
  87. package/{plugins/design/dev-server → apps/studio}/marquee-overlay.tsx +5 -2
  88. package/{plugins/design/dev-server → apps/studio}/participants-chrome.tsx +2 -8
  89. package/{plugins/design/dev-server → apps/studio}/paths.ts +15 -11
  90. package/{plugins/design/dev-server → apps/studio}/server.ts +23 -2
  91. package/{plugins/design/dev-server → apps/studio}/sync/agent.ts +231 -57
  92. package/{plugins/design/dev-server → apps/studio}/sync/codec.ts +45 -0
  93. package/apps/studio/sync/cold-start.ts +158 -0
  94. package/{plugins/design/dev-server → apps/studio}/sync/connection-state.ts +58 -2
  95. package/{plugins/design/dev-server → apps/studio}/sync/index.ts +563 -221
  96. package/apps/studio/sync/journal.ts +190 -0
  97. package/apps/studio/sync/migrate-seed.ts +321 -0
  98. package/{plugins/design/dev-server → apps/studio}/sync/projection.ts +22 -3
  99. package/{plugins/design/dev-server → apps/studio}/sync/status.ts +15 -4
  100. package/{plugins/design/dev-server → apps/studio}/test/activity.test.ts +1 -7
  101. package/apps/studio/test/annotate-write.test.ts +184 -0
  102. package/apps/studio/test/annotations-align.test.ts +88 -0
  103. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  104. package/apps/studio/test/annotations-groups.test.ts +231 -0
  105. package/{plugins/design/dev-server → apps/studio}/test/annotations-layer.test.ts +63 -0
  106. package/{plugins/design/dev-server → apps/studio}/test/annotations-roundtrip.test.ts +97 -0
  107. package/apps/studio/test/annotations-snap.test.ts +79 -0
  108. package/apps/studio/test/annotations-text-format.test.ts +396 -0
  109. package/apps/studio/test/asset-api.test.ts +149 -0
  110. package/apps/studio/test/canvas-create-api.test.ts +361 -0
  111. package/{plugins/design/dev-server → apps/studio}/test/canvas-cursors.test.ts +2 -0
  112. package/apps/studio/test/canvas-edit.test.ts +319 -0
  113. package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-resolver.test.ts +1 -1
  114. package/apps/studio/test/canvas-media-drop.test.ts +158 -0
  115. package/{plugins/design/dev-server → apps/studio}/test/canvas-origin-gate.test.ts +25 -1
  116. package/apps/studio/test/canvas-url.test.ts +99 -0
  117. package/{plugins/design/dev-server → apps/studio}/test/collab-bridge.test.ts +0 -2
  118. package/{plugins/design/dev-server → apps/studio}/test/collab-room.test.ts +2 -7
  119. package/{plugins/design/dev-server → apps/studio}/test/csp-canvas-shell.test.ts +5 -0
  120. package/apps/studio/test/csrf-write-guard.test.ts +52 -0
  121. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  122. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  123. package/apps/studio/test/index-data-canvas-ds.test.ts +97 -0
  124. package/apps/studio/test/motion-sample.test.ts +35 -0
  125. package/{plugins/design/dev-server → apps/studio}/test/perf-harness.ts +1 -1
  126. package/{plugins/design/dev-server → apps/studio}/test/phase-3.6-smoke.test.ts +3 -1
  127. package/apps/studio/test/read-annotations.test.ts +409 -0
  128. package/{plugins/design/dev-server → apps/studio}/test/sanitize-annotation-svg.test.ts +108 -2
  129. package/{plugins/design/dev-server → apps/studio}/test/shared-doc-convergence.test.ts +1 -1
  130. package/apps/studio/test/shared-doc-migrate.test.ts +369 -0
  131. package/apps/studio/test/sync-agent.test.ts +575 -0
  132. package/{plugins/design/dev-server → apps/studio}/test/sync-codec.test.ts +65 -0
  133. package/apps/studio/test/sync-cold-start.test.ts +244 -0
  134. package/{plugins/design/dev-server → apps/studio}/test/sync-connection-state.test.ts +68 -0
  135. package/{plugins/design/dev-server → apps/studio}/test/sync-hardening.test.ts +0 -8
  136. package/{plugins/design/dev-server → apps/studio}/test/sync-hubs-config.test.ts +0 -1
  137. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  138. package/apps/studio/test/sync-journal.test.ts +176 -0
  139. package/{plugins/design/dev-server → apps/studio}/test/sync-runtime.test.ts +360 -5
  140. package/{plugins/design/dev-server → apps/studio}/test/sync-status.test.ts +66 -0
  141. package/apps/studio/test/tour-overlay.test.tsx +56 -0
  142. package/apps/studio/test/use-annotation-resize.test.ts +402 -0
  143. package/{plugins/design/dev-server → apps/studio}/test/use-artboard-drag.test.ts +0 -1
  144. package/{plugins/design/dev-server → apps/studio}/test/use-collab.test.ts +0 -0
  145. package/{plugins/design/dev-server → apps/studio}/test/use-tool-mode.test.tsx +8 -2
  146. package/apps/studio/test/whats-new.test.ts +101 -0
  147. package/apps/studio/text-imports.d.ts +9 -0
  148. package/{plugins/design/dev-server → apps/studio}/tool-palette.tsx +15 -70
  149. package/{plugins/design/dev-server → apps/studio}/undo-hud.tsx +1 -2
  150. package/{plugins/design/dev-server → apps/studio}/undo-stack.ts +1 -1
  151. package/{plugins/design/dev-server → apps/studio}/use-agent-presence.tsx +5 -3
  152. package/apps/studio/use-annotation-resize.tsx +912 -0
  153. package/{plugins/design/dev-server → apps/studio}/use-annotation-selection.tsx +17 -5
  154. package/{plugins/design/dev-server → apps/studio}/use-annotations-visibility.tsx +1 -1
  155. package/{plugins/design/dev-server → apps/studio}/use-artboard-drag.tsx +1 -1
  156. package/{plugins/design/dev-server → apps/studio}/use-canvas-activity.tsx +3 -3
  157. package/apps/studio/use-canvas-media-drop.tsx +343 -0
  158. package/{plugins/design/dev-server → apps/studio}/use-collab.tsx +21 -16
  159. package/{plugins/design/dev-server → apps/studio}/use-cursor-modifiers.tsx +1 -1
  160. package/{plugins/design/dev-server → apps/studio}/use-keyboard-discipline.tsx +5 -2
  161. package/{plugins/design/dev-server → apps/studio}/use-selection-set.tsx +18 -1
  162. package/{plugins/design/dev-server → apps/studio}/use-snap-guides.tsx +1 -1
  163. package/{plugins/design/dev-server → apps/studio}/use-tool-mode.tsx +8 -2
  164. package/{plugins/design/dev-server → apps/studio}/use-undo-stack.tsx +1 -1
  165. package/apps/studio/whats-new.json +264 -0
  166. package/apps/studio/whats-new.schema.json +86 -0
  167. package/apps/studio/whats-new.ts +87 -0
  168. package/cli/bin/maude.mjs +10 -0
  169. package/cli/commands/design-link.test.mjs +84 -0
  170. package/cli/commands/design.mjs +16 -7
  171. package/cli/commands/design.test.mjs +40 -0
  172. package/cli/commands/help.mjs +8 -2
  173. package/cli/commands/hub.mjs +3 -3
  174. package/cli/commands/hub.test.mjs +1 -1
  175. package/cli/commands/scenario-report.mjs +2 -9
  176. package/cli/lib/design-link.mjs +51 -1
  177. package/cli/lib/flow-design-integration.test.mjs +1 -1
  178. package/cli/lib/gitignore-block.mjs +1 -0
  179. package/cli/lib/plugin-cli-reachability.test.mjs +8 -7
  180. package/cli/lib/preflight.mjs +1 -1
  181. package/package.json +15 -15
  182. package/plugins/design/templates/_shell.html +28 -4
  183. package/plugins/design/templates/brief-board.tsx.template +61 -0
  184. package/plugins/design/templates/canvas.tsx.template +1 -1
  185. package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -1
  186. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  187. package/plugins/flow/.claude-plugin/config.schema.json +16 -0
  188. package/plugins/design/dev-server/annotations-layer.tsx +0 -3402
  189. package/plugins/design/dev-server/client/app.jsx +0 -2985
  190. package/plugins/design/dev-server/client/index.html +0 -15
  191. package/plugins/design/dev-server/dist/client.bundle.js +0 -20692
  192. package/plugins/design/dev-server/dist/comment-mount.js +0 -1924
  193. package/plugins/design/dev-server/dist/styles.css +0 -2710
  194. package/plugins/design/dev-server/dom-selection.ts +0 -156
  195. package/plugins/design/dev-server/sync/migrate-seed.ts +0 -163
  196. package/plugins/design/dev-server/test/canvas-edit.test.ts +0 -139
  197. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +0 -159
  198. package/plugins/design/dev-server/test/sync-agent.test.ts +0 -305
  199. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +0 -200
  200. package/plugins/design/dev-server/use-annotation-resize.tsx +0 -449
  201. /package/{plugins/design/dev-server → apps/studio}/activity.ts +0 -0
  202. /package/{plugins/design/dev-server → apps/studio}/bin/_enumerate-artboards-playwright.mjs +0 -0
  203. /package/{plugins/design/dev-server → apps/studio}/bin/_html-playwright.mjs +0 -0
  204. /package/{plugins/design/dev-server → apps/studio}/bin/_pdf-playwright.mjs +0 -0
  205. /package/{plugins/design/dev-server → apps/studio}/bin/_pptx-playwright.mjs +0 -0
  206. /package/{plugins/design/dev-server → apps/studio}/bin/_pw-launch.mjs +0 -0
  207. /package/{plugins/design/dev-server → apps/studio}/bin/_screenshot-playwright.mjs +0 -0
  208. /package/{plugins/design/dev-server → apps/studio}/bin/_svg-optimize.mjs +0 -0
  209. /package/{plugins/design/dev-server → apps/studio}/bin/_svg-playwright.mjs +0 -0
  210. /package/{plugins/design/dev-server → apps/studio}/bin/asset-sweep.sh +0 -0
  211. /package/{plugins/design/dev-server → apps/studio}/bin/bootstrap-check.sh +0 -0
  212. /package/{plugins/design/dev-server → apps/studio}/bin/check-runtime-bundles.sh +0 -0
  213. /package/{plugins/design/dev-server → apps/studio}/bin/draw-build.sh +0 -0
  214. /package/{plugins/design/dev-server → apps/studio}/bin/handoff.sh +0 -0
  215. /package/{plugins/design/dev-server → apps/studio}/bin/prep.sh +0 -0
  216. /package/{plugins/design/dev-server → apps/studio}/bin/scenario-report.mjs +0 -0
  217. /package/{plugins/design/dev-server → apps/studio}/bin/slug.sh +0 -0
  218. /package/{plugins/design/dev-server → apps/studio}/bin/smoke.sh +0 -0
  219. /package/{plugins/design/dev-server → apps/studio}/bin/svg-optimize.sh +0 -0
  220. /package/{plugins/design/dev-server → apps/studio}/bin/visual-sanity.sh +0 -0
  221. /package/{plugins/design/dev-server → apps/studio}/boot-self-heal.ts +0 -0
  222. /package/{plugins/design/dev-server → apps/studio}/canvas-build.ts +0 -0
  223. /package/{plugins/design/dev-server → apps/studio}/canvas-header.ts +0 -0
  224. /package/{plugins/design/dev-server → apps/studio}/canvas-meta.schema.json +0 -0
  225. /package/{plugins/design/dev-server → apps/studio}/canvas-pipeline.ts +0 -0
  226. /package/{plugins/design/dev-server → apps/studio}/client/hmr.mjs +0 -0
  227. /package/{plugins/design/dev-server → apps/studio}/client/iframe-lazy.mjs +0 -0
  228. /package/{plugins/design/dev-server → apps/studio}/client/styles/0-reset.css +0 -0
  229. /package/{plugins/design/dev-server → apps/studio}/client/styles/1-tokens.css +0 -0
  230. /package/{plugins/design/dev-server → apps/studio}/client/styles/2-layout.css +0 -0
  231. /package/{plugins/design/dev-server → apps/studio}/client/styles/5-utilities.css +0 -0
  232. /package/{plugins/design/dev-server → apps/studio}/collab/ai-activity.ts +0 -0
  233. /package/{plugins/design/dev-server → apps/studio}/collab/awareness-bridge.ts +0 -0
  234. /package/{plugins/design/dev-server → apps/studio}/collab/git-lifecycle.ts +0 -0
  235. /package/{plugins/design/dev-server → apps/studio}/collab/index.ts +0 -0
  236. /package/{plugins/design/dev-server → apps/studio}/collab/persistence.ts +0 -0
  237. /package/{plugins/design/dev-server → apps/studio}/collab/protocol.ts +0 -0
  238. /package/{plugins/design/dev-server → apps/studio}/collab/registry.ts +0 -0
  239. /package/{plugins/design/dev-server → apps/studio}/collab/room.ts +0 -0
  240. /package/{plugins/design/dev-server → apps/studio}/config.schema.json +0 -0
  241. /package/{plugins/design/dev-server → apps/studio}/context.ts +0 -0
  242. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/.min-sizes.json +0 -0
  243. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/lib0_decoding.js +0 -0
  244. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/lib0_encoding.js +0 -0
  245. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/motion.js +0 -0
  246. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/motion_react.js +0 -0
  247. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/pixi-js.js +0 -0
  248. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react-dom.js +0 -0
  249. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react-dom_client.js +0 -0
  250. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react.js +0 -0
  251. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react_jsx-dev-runtime.js +0 -0
  252. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/react_jsx-runtime.js +0 -0
  253. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/y-protocols_awareness.js +0 -0
  254. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/y-protocols_sync.js +0 -0
  255. /package/{plugins/design/dev-server → apps/studio}/dist/runtime/yjs.js +0 -0
  256. /package/{plugins/design/dev-server → apps/studio}/draw/test/brush.test.ts +0 -0
  257. /package/{plugins/design/dev-server → apps/studio}/draw/test/composition.test.ts +0 -0
  258. /package/{plugins/design/dev-server → apps/studio}/draw/test/geometry.test.ts +0 -0
  259. /package/{plugins/design/dev-server → apps/studio}/draw/test/gradient.test.ts +0 -0
  260. /package/{plugins/design/dev-server → apps/studio}/draw/test/layout.test.ts +0 -0
  261. /package/{plugins/design/dev-server → apps/studio}/draw/test/optimize.test.ts +0 -0
  262. /package/{plugins/design/dev-server → apps/studio}/draw/test/palette.test.ts +0 -0
  263. /package/{plugins/design/dev-server → apps/studio}/draw/test/primitives.test.ts +0 -0
  264. /package/{plugins/design/dev-server → apps/studio}/draw/test/serialize.test.ts +0 -0
  265. /package/{plugins/design/dev-server → apps/studio}/examples/README.md +0 -0
  266. /package/{plugins/design/dev-server → apps/studio}/examples/perf-100-artboards.tsx +0 -0
  267. /package/{plugins/design/dev-server → apps/studio}/exporters/_browser-bundles.ts +0 -0
  268. /package/{plugins/design/dev-server → apps/studio}/exporters/canva-handoff-prompt.ts +0 -0
  269. /package/{plugins/design/dev-server → apps/studio}/exporters/canva.ts +0 -0
  270. /package/{plugins/design/dev-server → apps/studio}/exporters/html.ts +0 -0
  271. /package/{plugins/design/dev-server → apps/studio}/exporters/index.ts +0 -0
  272. /package/{plugins/design/dev-server → apps/studio}/exporters/pdf.ts +0 -0
  273. /package/{plugins/design/dev-server → apps/studio}/exporters/png.ts +0 -0
  274. /package/{plugins/design/dev-server → apps/studio}/exporters/pptx.ts +0 -0
  275. /package/{plugins/design/dev-server → apps/studio}/exporters/scope.ts +0 -0
  276. /package/{plugins/design/dev-server → apps/studio}/exporters/svg.ts +0 -0
  277. /package/{plugins/design/dev-server → apps/studio}/exporters/zip.ts +0 -0
  278. /package/{plugins/design/dev-server → apps/studio}/fs-watch.ts +0 -0
  279. /package/{plugins/design/dev-server → apps/studio}/handoff.ts +0 -0
  280. /package/{plugins/design/dev-server → apps/studio}/hmr-broadcast.ts +0 -0
  281. /package/{plugins/design/dev-server → apps/studio}/locator.ts +0 -0
  282. /package/{plugins/design/dev-server → apps/studio}/mem.ts +0 -0
  283. /package/{plugins/design/dev-server → apps/studio}/runtime-bundle.ts +0 -0
  284. /package/{plugins/design/dev-server → apps/studio}/server.mjs +0 -0
  285. /package/{plugins/design/dev-server → apps/studio}/sync/atomic-write.ts +0 -0
  286. /package/{plugins/design/dev-server → apps/studio}/sync/echo-guard.ts +0 -0
  287. /package/{plugins/design/dev-server → apps/studio}/sync/fs-mirror.ts +0 -0
  288. /package/{plugins/design/dev-server → apps/studio}/sync/hubs-config.ts +0 -0
  289. /package/{plugins/design/dev-server → apps/studio}/sync/materialize.ts +0 -0
  290. /package/{plugins/design/dev-server → apps/studio}/sync/origins.ts +0 -0
  291. /package/{plugins/design/dev-server → apps/studio}/sync/untrusted.ts +0 -0
  292. /package/{plugins/design/dev-server → apps/studio}/test/_helpers.ts +0 -0
  293. /package/{plugins/design/dev-server → apps/studio}/test/active-state.test.ts +0 -0
  294. /package/{plugins/design/dev-server → apps/studio}/test/ai-activity.test.ts +0 -0
  295. /package/{plugins/design/dev-server → apps/studio}/test/annotation-strokes-command.test.ts +0 -0
  296. /package/{plugins/design/dev-server → apps/studio}/test/annotations-api.test.ts +0 -0
  297. /package/{plugins/design/dev-server → apps/studio}/test/annotations-draw-modifiers.test.ts +0 -0
  298. /package/{plugins/design/dev-server → apps/studio}/test/artboard-activity-overlay.test.tsx +0 -0
  299. /package/{plugins/design/dev-server → apps/studio}/test/binary-smoke.test.ts +0 -0
  300. /package/{plugins/design/dev-server → apps/studio}/test/boot-self-heal.test.ts +0 -0
  301. /package/{plugins/design/dev-server → apps/studio}/test/bundle-smoke.test.ts +0 -0
  302. /package/{plugins/design/dev-server → apps/studio}/test/canvas-build.test.ts +0 -0
  303. /package/{plugins/design/dev-server → apps/studio}/test/canvas-header.test.ts +0 -0
  304. /package/{plugins/design/dev-server → apps/studio}/test/canvas-hmr-runtime.test.tsx +0 -0
  305. /package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-inline.test.ts +0 -0
  306. /package/{plugins/design/dev-server → apps/studio}/test/canvas-lib-motion.test.ts +0 -0
  307. /package/{plugins/design/dev-server → apps/studio}/test/canvas-meta-api.test.ts +0 -0
  308. /package/{plugins/design/dev-server → apps/studio}/test/canvas-pipeline.test.ts +0 -0
  309. /package/{plugins/design/dev-server → apps/studio}/test/canvas-route.test.ts +0 -0
  310. /package/{plugins/design/dev-server → apps/studio}/test/collab-annotations-bridge.test.ts +0 -0
  311. /package/{plugins/design/dev-server → apps/studio}/test/collab-awareness-bridge.test.ts +0 -0
  312. /package/{plugins/design/dev-server → apps/studio}/test/collab-loopback.test.ts +0 -0
  313. /package/{plugins/design/dev-server → apps/studio}/test/collab-protocol.test.ts +0 -0
  314. /package/{plugins/design/dev-server → apps/studio}/test/collab-reseed-guard.test.ts +0 -0
  315. /package/{plugins/design/dev-server → apps/studio}/test/collab-stress.test.ts +0 -0
  316. /package/{plugins/design/dev-server → apps/studio}/test/comment-mount.test.ts +0 -0
  317. /package/{plugins/design/dev-server → apps/studio}/test/comments-api.test.ts +0 -0
  318. /package/{plugins/design/dev-server → apps/studio}/test/compile-entry.test.ts +0 -0
  319. /package/{plugins/design/dev-server → apps/studio}/test/context-resolve-tokens.test.ts +0 -0
  320. /package/{plugins/design/dev-server → apps/studio}/test/equal-spacing-detector.test.ts +0 -0
  321. /package/{plugins/design/dev-server → apps/studio}/test/exporters/canva.test.ts +0 -0
  322. /package/{plugins/design/dev-server → apps/studio}/test/exporters/endpoint.test.ts +0 -0
  323. /package/{plugins/design/dev-server → apps/studio}/test/exporters/history.test.ts +0 -0
  324. /package/{plugins/design/dev-server → apps/studio}/test/exporters/html.test.ts +0 -0
  325. /package/{plugins/design/dev-server → apps/studio}/test/exporters/pdf.test.ts +0 -0
  326. /package/{plugins/design/dev-server → apps/studio}/test/exporters/png.test.ts +0 -0
  327. /package/{plugins/design/dev-server → apps/studio}/test/exporters/pptx-deck.test.ts +0 -0
  328. /package/{plugins/design/dev-server → apps/studio}/test/exporters/pptx.test.ts +0 -0
  329. /package/{plugins/design/dev-server → apps/studio}/test/exporters/pw-launch.test.ts +0 -0
  330. /package/{plugins/design/dev-server → apps/studio}/test/exporters/scope.test.ts +0 -0
  331. /package/{plugins/design/dev-server → apps/studio}/test/exporters/svg.test.ts +0 -0
  332. /package/{plugins/design/dev-server → apps/studio}/test/exporters/zip.test.ts +0 -0
  333. /package/{plugins/design/dev-server → apps/studio}/test/fixtures/phase-20-annotations.svg +0 -0
  334. /package/{plugins/design/dev-server → apps/studio}/test/fixtures/phase-21-annotations.svg +0 -0
  335. /package/{plugins/design/dev-server → apps/studio}/test/fs-watch.test.ts +0 -0
  336. /package/{plugins/design/dev-server → apps/studio}/test/git-lifecycle.test.ts +0 -0
  337. /package/{plugins/design/dev-server → apps/studio}/test/handoff-static-frames.test.ts +0 -0
  338. /package/{plugins/design/dev-server → apps/studio}/test/handoff.test.ts +0 -0
  339. /package/{plugins/design/dev-server → apps/studio}/test/history-rollback.test.ts +0 -0
  340. /package/{plugins/design/dev-server → apps/studio}/test/hmr-broadcast.test.ts +0 -0
  341. /package/{plugins/design/dev-server → apps/studio}/test/hmr-classify.test.ts +0 -0
  342. /package/{plugins/design/dev-server → apps/studio}/test/input-router.test.ts +0 -0
  343. /package/{plugins/design/dev-server → apps/studio}/test/locator.test.ts +0 -0
  344. /package/{plugins/design/dev-server → apps/studio}/test/marquee-overlay.test.ts +0 -0
  345. /package/{plugins/design/dev-server → apps/studio}/test/move-artboards-command.test.ts +0 -0
  346. /package/{plugins/design/dev-server → apps/studio}/test/participants-chrome.test.ts +0 -0
  347. /package/{plugins/design/dev-server → apps/studio}/test/paths.test.ts +0 -0
  348. /package/{plugins/design/dev-server → apps/studio}/test/runtime-bundle-error-mapping.test.ts +0 -0
  349. /package/{plugins/design/dev-server → apps/studio}/test/runtime-bundle.test.ts +0 -0
  350. /package/{plugins/design/dev-server → apps/studio}/test/server-lifecycle.test.ts +0 -0
  351. /package/{plugins/design/dev-server → apps/studio}/test/shared-doc-foundation.test.ts +0 -0
  352. /package/{plugins/design/dev-server → apps/studio}/test/shared-doc-projection.test.ts +0 -0
  353. /package/{plugins/design/dev-server → apps/studio}/test/snap-distance-pill.test.ts +0 -0
  354. /package/{plugins/design/dev-server → apps/studio}/test/sync-atomic-write.test.ts +0 -0
  355. /package/{plugins/design/dev-server → apps/studio}/test/sync-echo-guard.test.ts +0 -0
  356. /package/{plugins/design/dev-server → apps/studio}/test/sync-fs-mirror.test.ts +0 -0
  357. /package/{plugins/design/dev-server → apps/studio}/test/sync-meta-codec.test.ts +0 -0
  358. /package/{plugins/design/dev-server → apps/studio}/test/sync-untrusted.test.ts +0 -0
  359. /package/{plugins/design/dev-server → apps/studio}/test/system-endpoint.test.ts +0 -0
  360. /package/{plugins/design/dev-server → apps/studio}/test/tool-palette.test.tsx +0 -0
  361. /package/{plugins/design/dev-server → apps/studio}/test/undo-stack.test.ts +0 -0
  362. /package/{plugins/design/dev-server → apps/studio}/test/use-agent-presence.test.tsx +0 -0
  363. /package/{plugins/design/dev-server → apps/studio}/test/use-annotation-selection.test.tsx +0 -0
  364. /package/{plugins/design/dev-server → apps/studio}/test/use-canvas-activity.test.tsx +0 -0
  365. /package/{plugins/design/dev-server → apps/studio}/test/use-cursor-modifiers.test.ts +0 -0
  366. /package/{plugins/design/dev-server → apps/studio}/test/use-keyboard-discipline.test.ts +0 -0
  367. /package/{plugins/design/dev-server → apps/studio}/test/use-selection-set.test.tsx +0 -0
  368. /package/{plugins/design/dev-server → apps/studio}/test/use-snap-guides.test.ts +0 -0
  369. /package/{plugins/design/dev-server → apps/studio}/test/use-undo-stack.test.tsx +0 -0
  370. /package/{plugins/design/dev-server → apps/studio}/test/ws-handshake.test.ts +0 -0
  371. /package/{plugins/design/dev-server → apps/studio}/tsconfig.json +0 -0
  372. /package/{plugins/design/dev-server → apps/studio}/ws.ts +0 -0
@@ -1,2985 +0,0 @@
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 { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
7
- import { createRoot } from 'react-dom/client';
8
-
9
- // Trusted tool→cursor resolver (shares the single TOOL_CURSORS source with the
10
- // canvas runtime). canvas-cursors.ts is dependency-free (a type-only Tool
11
- // import that Bun erases), so this pulls only string constants into the client
12
- // bundle — no React, no input-router. See the tool-cursor handler below.
13
- import { resolveToolCursor } from '../canvas-cursors.ts';
14
-
15
- const SYSTEM_TAB = '__system__';
16
- const THEME_STORE = 'mdcc-theme';
17
- const SHOW_HIDDEN_STORE = 'mdcc-show-hidden';
18
- const SECTIONS_STORE = 'mdcc-sections-expanded';
19
- const SIDEBAR_STORE = 'mdcc-sidebar-open';
20
- const CANVAS_EXT_RE = /\.(tsx|html?)$/i;
21
- // Bun's `define` substitutes this at build time (see build.ts); falls back when
22
- // the bundle is consumed in a context that hasn't run the build.
23
- const MDCC_VERSION = typeof __MDCC_VERSION__ !== 'undefined' ? __MDCC_VERSION__ : 'dev';
24
-
25
- function readInitialTheme() {
26
- if (typeof window === 'undefined') return 'dark';
27
- try {
28
- const stored = localStorage.getItem(THEME_STORE);
29
- if (stored === 'light' || stored === 'dark') return stored;
30
- } catch {}
31
- // Match the data-theme attribute index.html ships with (dark).
32
- return 'dark';
33
- }
34
-
35
- function readBoolStore(key, fallback) {
36
- if (typeof window === 'undefined') return fallback;
37
- try {
38
- const v = localStorage.getItem(key);
39
- if (v === '1') return true;
40
- if (v === '0') return false;
41
- } catch {}
42
- return fallback;
43
- }
44
-
45
- function readJsonStore(key, fallback) {
46
- if (typeof window === 'undefined') return fallback;
47
- try {
48
- const v = localStorage.getItem(key);
49
- return v ? JSON.parse(v) : fallback;
50
- } catch {
51
- return fallback;
52
- }
53
- }
54
-
55
- // Section default-open: working sections (project + non-DS canvas groups)
56
- // open; meta sections (DS + runtime) collapsed. Users can override per-section
57
- // via the chevron; overrides persist in localStorage.
58
- function sectionDefaultOpen(g) {
59
- if (g.kind === 'runtime') return false;
60
- if (g.label === 'Design system') return false;
61
- return true;
62
- }
63
-
64
- // ---------- Utility ----------
65
-
66
- function urlOf(p) {
67
- return '/' + p.split('/').map(encodeURIComponent).join('/');
68
- }
69
-
70
- // Iframe src for a canvas path. TSX canvases go through _canvas-shell.html so
71
- // the bundled React 19 runtime + importmap can mount the default export. HTML
72
- // canvases keep the legacy "serve the file with inspector + Babel injected"
73
- // path. Phase 3.6 contract; the path argument is repo-root-relative
74
- // (e.g. ".design/ui/Foo.tsx").
75
- function canvasUrl(p, cfg, opts) {
76
- if (!p.endsWith('.tsx')) return urlOf(p);
77
- const designRel = (cfg?.designRel || '.design').replace(/^\/+|\/+$/g, '');
78
- // Path under designRoot.
79
- let rel = p;
80
- if (rel.startsWith(designRel + '/')) rel = rel.slice(designRel.length + 1);
81
- // Pass `rel` to URLSearchParams RAW — it does encoding once. Pre-encoding
82
- // with encodeURIComponent then handing to URLSearchParams produced
83
- // `Docs%2520Site.tsx` (the `%` of `%20` got re-encoded as `%25`) and broke
84
- // every UI canvas with a space in its filename.
85
- const params = new URLSearchParams();
86
- params.set('canvas', rel);
87
- params.set('designRel', designRel);
88
- // Gallery thumbnails suppress the shell-owned comment layer (`?comments=0`)
89
- // so previews stay non-interactive; opening the same canvas as a real tab
90
- // omits the flag and gets comments. See canvas-comment-mount.tsx.
91
- if (opts?.thumbnail) params.set('comments', '0');
92
- const ds0 = cfg?.designSystems?.[0];
93
- // Specimen detection: anything under `system/<ds>/preview/` belongs to that
94
- // specific DS, so it must render with *that* DS's tokens — not always the
95
- // first one. In a multi-DS project this is what keeps each design system's
96
- // preview distinct (beta previews use beta tokens, not alpha's).
97
- const specMatch = rel.match(/^system\/([^/]+)\/preview\//);
98
- const specDsEntry = specMatch
99
- ? cfg?.designSystems?.find(
100
- (d) => d.path === `system/${specMatch[1]}` || d.path.endsWith(`/${specMatch[1]}`)
101
- )
102
- : null;
103
- // Resolve tokens path. For a specimen, prefer the matching DS's tokensCssRel
104
- // (fall back to the `system/<ds>/colors_and_type.css` convention). Otherwise
105
- // prefer the first designSystem's tokensCssRel — the project's authoritative
106
- // tokens file. The top-level cfg.tokensCssRel is the legacy default
107
- // (`system/colors_and_type.css`) and usually doesn't exist post-bootstrap.
108
- const tokens = specMatch
109
- ? specDsEntry?.tokensCssRel || `system/${specMatch[1]}/colors_and_type.css`
110
- : ds0?.tokensCssRel || cfg?.tokensCssRel;
111
- if (tokens) params.set('tokens', tokens);
112
- if (cfg?.componentsCssRel) params.set('components', cfg.componentsCssRel);
113
- if (specMatch) {
114
- const dsName = specMatch[1];
115
- params.set('layout', `system/${dsName}/preview/_layout.css`);
116
- if (!cfg?.componentsCssRel) {
117
- params.set('components', `system/${dsName}/preview/_components.css`);
118
- }
119
- } else if (ds0?.path) {
120
- // UI canvas — load the project DS's `_components.css` so the dc-canvas /
121
- // dc-section / dc-artboard chrome (and any DS classes the canvas reuses)
122
- // renders correctly.
123
- if (!cfg?.componentsCssRel) {
124
- params.set('components', `${ds0.path}/preview/_components.css`);
125
- }
126
- }
127
- // T2 (9.1-A) — load the iframe from the segregated canvas origin when the
128
- // server advertises one; fall back to a same-origin relative URL otherwise
129
- // (older server, tests). The trailing path + query are identical either way.
130
- const origin = cfg?.canvasOrigin || '';
131
- return `${origin}/_canvas-shell.html?${params.toString()}`;
132
- }
133
-
134
- function basename(p) {
135
- return p.split('/').pop();
136
- }
137
-
138
- // Strip canvas extensions for display. `Canvas Viewport.tsx` → `Canvas Viewport`.
139
- // Sidecars (`.meta.json`, `.css`, `.registry.json`) keep their extensions so
140
- // the file type stays unambiguous.
141
- function displayName(name) {
142
- return name.replace(CANVAS_EXT_RE, '');
143
- }
144
-
145
- // Primary base = name with the canvas extension stripped. `Canvas Viewport.tsx`
146
- // → `Canvas Viewport`. A sidecar belongs to that primary when its name starts
147
- // with `<base>.` — so `Canvas Viewport.meta.json` and `Canvas Viewport.css`
148
- // both nest under `Canvas Viewport.tsx`. Naïve single-extension stripping
149
- // breaks for multi-dot sidecars like `*.meta.json`.
150
- function canvasBase(name) {
151
- return name.replace(CANVAS_EXT_RE, '');
152
- }
153
-
154
- // Group flat file list into { primary: canvas, sidecars: [...] }. Sidecars
155
- // share the primary base + `.` prefix and don't themselves match the canvas
156
- // extension regex. Orphans (no canvas peer at this dir level) come back as
157
- // `{ primary: orphan, sidecars: [], orphan: true }` so the caller can gate
158
- // them on `showHidden`.
159
- function groupBySidecar(files) {
160
- // Pass 1 — claim primaries; prefer .tsx over .html on tie.
161
- const primaryByBase = new Map();
162
- for (const f of files) {
163
- if (!CANVAS_EXT_RE.test(f.name)) continue;
164
- const base = canvasBase(f.name);
165
- if (!primaryByBase.has(base) || /\.tsx$/i.test(f.name)) primaryByBase.set(base, f);
166
- }
167
- // Pass 2 — match non-canvas files to the longest primary base they prefix.
168
- const sidecarsByBase = new Map();
169
- const orphans = [];
170
- for (const f of files) {
171
- if (CANVAS_EXT_RE.test(f.name)) continue;
172
- let matched = null;
173
- for (const base of primaryByBase.keys()) {
174
- if (f.name === base) continue;
175
- if (f.name.startsWith(`${base}.`)) {
176
- if (!matched || base.length > matched.length) matched = base;
177
- }
178
- }
179
- if (matched) {
180
- const list = sidecarsByBase.get(matched) || [];
181
- list.push(f);
182
- sidecarsByBase.set(matched, list);
183
- } else {
184
- orphans.push(f);
185
- }
186
- }
187
- const canvases = [];
188
- for (const [base, primary] of primaryByBase) {
189
- const sidecars = (sidecarsByBase.get(base) || []).sort((a, b) => a.name.localeCompare(b.name));
190
- canvases.push({ primary, sidecars, orphan: false });
191
- }
192
- canvases.sort((a, b) => a.primary.name.localeCompare(b.primary.name));
193
- orphans.sort((a, b) => a.name.localeCompare(b.name));
194
- return {
195
- canvases,
196
- orphans: orphans.map((f) => ({ primary: f, sidecars: [], orphan: true })),
197
- };
198
- }
199
-
200
- function buildTree(paths, stripPrefix) {
201
- const root = {};
202
- for (const p of paths) {
203
- const stripped = p.startsWith(stripPrefix)
204
- ? p.slice(stripPrefix.length).replace(/^\/+/, '')
205
- : p;
206
- const parts = stripped.split('/');
207
- let node = root;
208
- for (let i = 0; i < parts.length; i++) {
209
- const key = parts[i];
210
- const isFile = i === parts.length - 1;
211
- if (isFile) {
212
- node._files = node._files || [];
213
- node._files.push({ name: key, path: p });
214
- } else {
215
- node[key] = node[key] || {};
216
- node = node[key];
217
- }
218
- }
219
- }
220
- return root;
221
- }
222
-
223
- function filterTree(node, query) {
224
- if (!query) return node;
225
- const q = query.toLowerCase();
226
- const out = {};
227
- let any = false;
228
- const dirs = Object.keys(node).filter((k) => k !== '_files');
229
- for (const d of dirs) {
230
- const filtered = filterTree(node[d], query);
231
- if (filtered) {
232
- out[d] = filtered;
233
- any = true;
234
- }
235
- }
236
- if (node._files) {
237
- const files = node._files.filter(
238
- (f) => f.name.toLowerCase().includes(q) || f.path.toLowerCase().includes(q)
239
- );
240
- if (files.length) {
241
- out._files = files;
242
- any = true;
243
- }
244
- }
245
- return any ? out : null;
246
- }
247
-
248
- function openCount(comments) {
249
- return (comments || []).filter((c) => c.status !== 'resolved').length;
250
- }
251
-
252
- function timeAgo(iso) {
253
- if (!iso) return '';
254
- const t = new Date(iso).getTime();
255
- if (!t) return '';
256
- const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
257
- if (s < 60) return s + 's';
258
- const m = Math.floor(s / 60);
259
- if (m < 60) return m + 'm';
260
- const h = Math.floor(m / 60);
261
- if (h < 24) return h + 'h';
262
- const d = Math.floor(h / 24);
263
- if (d < 7) return d + 'd';
264
- return new Date(iso).toLocaleDateString();
265
- }
266
-
267
- function totalCounts(commentsByFile) {
268
- let all = 0,
269
- open = 0,
270
- resolved = 0;
271
- for (const list of Object.values(commentsByFile || {})) {
272
- for (const c of list || []) {
273
- all++;
274
- if (c.status === 'resolved') resolved++;
275
- else open++;
276
- }
277
- }
278
- return { all, open, resolved };
279
- }
280
-
281
- // ---------- Components ----------
282
-
283
- function Icon({ d, size = 14, color }) {
284
- return (
285
- <svg
286
- width={size}
287
- height={size}
288
- viewBox="0 0 24 24"
289
- fill="none"
290
- stroke={color || 'currentColor'}
291
- strokeWidth="1.5"
292
- strokeLinecap="round"
293
- strokeLinejoin="round"
294
- style={{ flex: 'none' }}
295
- >
296
- <path d={d} />
297
- </svg>
298
- );
299
- }
300
-
301
- // ───── Tree (CV-08 spec) ─────
302
- // File rows use `.tp-row` with optional .dir / .sel / .star / .modified
303
- // modifiers + a leading `.glyph` (▾ open dir, ▸ closed dir / selected file,
304
- // · file). Section headers use `.tp-section-hd` with a `.pill` counter.
305
- // The flat-row model (vs the old nested <details>) mirrors the mock and
306
- // keeps padding-left under explicit control per depth level.
307
-
308
- const TREE_INDENT_BASE = 12;
309
- const TREE_INDENT_STEP = 16;
310
-
311
- function DirRow({ name, depth, defaultOpen, children }) {
312
- const [open, setOpen] = useState(defaultOpen);
313
- return (
314
- <Fragment>
315
- <button
316
- type="button"
317
- role="treeitem"
318
- aria-expanded={open}
319
- tabIndex={-1}
320
- className="tp-row dir"
321
- style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
322
- onClick={() => setOpen((v) => !v)}
323
- >
324
- <span className="glyph" aria-hidden="true">
325
- {open ? '▾' : '▸'}
326
- </span>
327
- <span className="name">{name}</span>
328
- </button>
329
- {open && children}
330
- </Fragment>
331
- );
332
- }
333
-
334
- // DsFolderRow — a per-DS folder inside the DESIGN SYSTEM section.
335
- // Split target: chevron toggles disclosure of the folder's contents; clicking
336
- // the folder name opens the SystemView focused on that DS (single SystemView
337
- // for now; the dsName is plumbed through so a future per-DS view can use it).
338
- function DsFolderRow({ name, dsName, depth, defaultOpen, active, onOpenSystem, children }) {
339
- const [open, setOpen] = useState(defaultOpen);
340
- return (
341
- <Fragment>
342
- <div
343
- className={'tp-row ds-folder' + (active ? ' sel' : '')}
344
- style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
345
- role="treeitem"
346
- aria-expanded={open}
347
- >
348
- <button
349
- type="button"
350
- className="ds-folder-chev"
351
- onClick={() => setOpen((v) => !v)}
352
- aria-label={open ? 'Collapse design system' : 'Expand design system'}
353
- title={open ? 'Collapse' : 'Expand'}
354
- >
355
- <span className="glyph" aria-hidden="true">
356
- {open ? '▾' : '▸'}
357
- </span>
358
- </button>
359
- <button
360
- type="button"
361
- className="ds-folder-open"
362
- onClick={() => onOpenSystem(dsName)}
363
- aria-label={`Open ${dsName} design system view`}
364
- title="Open the design system view"
365
- >
366
- <span className="name">{name}</span>
367
- </button>
368
- </div>
369
- {open && children}
370
- </Fragment>
371
- );
372
- }
373
-
374
- function FileRow({ file, activePath, onOpen, openCount: oc, depth, kind, sidecar }) {
375
- const isSel = file.path === activePath;
376
- const isCanvas = CANVAS_EXT_RE.test(file.name);
377
- // Non-canvas rows (PROJECT *.md, RUNTIME _active.json, ...) are display-only —
378
- // clicking them doesn't open an iframe; we leave the click as no-op + cursor
379
- // hint via `aria-disabled`.
380
- const inert = !isCanvas;
381
- const label = isCanvas ? displayName(file.name) : file.name;
382
- return (
383
- <button
384
- type="button"
385
- role="treeitem"
386
- aria-selected={isSel}
387
- aria-disabled={inert ? 'true' : undefined}
388
- tabIndex={isSel ? 0 : -1}
389
- className={
390
- 'tp-row' +
391
- (isSel ? ' sel' : '') +
392
- (kind === 'runtime' ? ' muted' : '') +
393
- (sidecar ? ' sidecar' : '')
394
- }
395
- style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
396
- title={file.path + (oc ? ` — ${oc} open` : inert ? ' (file index only)' : '')}
397
- onClick={() => {
398
- if (!inert) onOpen(file.path);
399
- }}
400
- >
401
- <span className="glyph" aria-hidden="true">
402
- {isSel ? '▸' : '·'}
403
- </span>
404
- <span className="name">{label}</span>
405
- {oc > 0 && <span className="badge">{oc}</span>}
406
- </button>
407
- );
408
- }
409
-
410
- function CanvasRow({
411
- primary,
412
- sidecars,
413
- depth,
414
- kind,
415
- activePath,
416
- onOpen,
417
- openCount: oc,
418
- showHidden,
419
- forceOpen,
420
- }) {
421
- const hasSidecars = sidecars.length > 0;
422
- const [openState, setOpenState] = useState(false);
423
- // Sidecars are only revealed when the user opts in via `showHidden` — the
424
- // chevron itself only appears in that mode. When `forceOpen` is true (search
425
- // match in a sidecar), override local state so the user sees the hit.
426
- const open = forceOpen || openState;
427
- const isSel = primary.path === activePath;
428
- const showChevron = hasSidecars && showHidden;
429
- if (!showChevron) {
430
- return (
431
- <FileRow
432
- file={primary}
433
- activePath={activePath}
434
- onOpen={onOpen}
435
- openCount={oc}
436
- depth={depth}
437
- kind={kind}
438
- />
439
- );
440
- }
441
- return (
442
- <Fragment>
443
- <button
444
- type="button"
445
- role="treeitem"
446
- aria-selected={isSel}
447
- aria-expanded={open}
448
- tabIndex={isSel ? 0 : -1}
449
- className={'tp-row canvas-row' + (isSel ? ' sel' : '')}
450
- style={{ paddingLeft: TREE_INDENT_BASE + depth * TREE_INDENT_STEP + 'px' }}
451
- title={primary.path}
452
- onClick={(e) => {
453
- // Click the chevron region → toggle disclosure. Click anywhere else → open canvas.
454
- if (e.target.closest('.canvas-chev')) {
455
- setOpenState((v) => !v);
456
- return;
457
- }
458
- onOpen(primary.path);
459
- }}
460
- >
461
- <span
462
- className="glyph canvas-chev"
463
- aria-hidden="true"
464
- onClick={(e) => {
465
- e.stopPropagation();
466
- setOpenState((v) => !v);
467
- }}
468
- >
469
- {open ? '▾' : '▸'}
470
- </span>
471
- <span className="name">{displayName(primary.name)}</span>
472
- {oc > 0 && <span className="badge">{oc}</span>}
473
- </button>
474
- {open &&
475
- sidecars.map((sc) => (
476
- <FileRow
477
- key={sc.path}
478
- file={sc}
479
- activePath={activePath}
480
- onOpen={onOpen}
481
- openCount={0}
482
- depth={depth + 1}
483
- kind={kind}
484
- sidecar
485
- />
486
- ))}
487
- </Fragment>
488
- );
489
- }
490
-
491
- function Tree({
492
- node,
493
- activePath,
494
- onOpen,
495
- commentsByFile,
496
- depth = 1,
497
- kind,
498
- showHidden,
499
- search,
500
- dsFolders,
501
- activeDsName,
502
- onOpenSystem,
503
- }) {
504
- const dirs = Object.keys(node)
505
- .filter((k) => k !== '_files')
506
- .sort();
507
- const files = node._files || [];
508
- // VS Code-style sidecar grouping. Canvas (`.tsx`/`.html`) becomes the primary
509
- // row; same-basename non-canvas files (`.meta.json`, `.css`, …) collapse
510
- // under it. Orphans surface only when `showHidden` is on.
511
- const { canvases, orphans } = useMemo(() => groupBySidecar(files), [files]);
512
- const hasSearch = !!(search && search.trim());
513
- // DS-folder lookup: only meaningful at the top level of a DS group. The
514
- // server emits `dsFolders: [{name, folder}, ...]` so the client knows which
515
- // dir at depth=1 corresponds to a DS root (click → open SystemView).
516
- const dsFolderByName = useMemo(() => {
517
- if (!dsFolders || depth !== 1) return null;
518
- const m = new Map();
519
- for (const f of dsFolders) m.set(f.folder, f);
520
- return m;
521
- }, [dsFolders, depth]);
522
- return (
523
- <Fragment>
524
- {canvases.map((entry) => {
525
- const forceOpen =
526
- hasSearch &&
527
- entry.sidecars.some((sc) => {
528
- const q = search.toLowerCase();
529
- return sc.name.toLowerCase().includes(q) || sc.path.toLowerCase().includes(q);
530
- });
531
- return (
532
- <CanvasRow
533
- key={entry.primary.path}
534
- primary={entry.primary}
535
- sidecars={entry.sidecars}
536
- activePath={activePath}
537
- onOpen={onOpen}
538
- openCount={openCount(commentsByFile[entry.primary.path])}
539
- depth={depth}
540
- kind={kind}
541
- showHidden={showHidden}
542
- forceOpen={forceOpen}
543
- />
544
- );
545
- })}
546
- {showHidden &&
547
- orphans.map((entry) => (
548
- <FileRow
549
- key={entry.primary.path}
550
- file={entry.primary}
551
- activePath={activePath}
552
- onOpen={onOpen}
553
- openCount={openCount(commentsByFile[entry.primary.path])}
554
- depth={depth}
555
- kind={kind}
556
- />
557
- ))}
558
- {dirs.map((d) => {
559
- const dsMatch = dsFolderByName?.get(d);
560
- const childTree = (
561
- <Tree
562
- node={node[d]}
563
- activePath={activePath}
564
- onOpen={onOpen}
565
- commentsByFile={commentsByFile}
566
- depth={depth + 1}
567
- kind={kind}
568
- showHidden={showHidden}
569
- search={search}
570
- activeDsName={activeDsName}
571
- onOpenSystem={onOpenSystem}
572
- />
573
- );
574
- if (dsMatch && onOpenSystem) {
575
- return (
576
- <DsFolderRow
577
- key={d}
578
- name={d}
579
- dsName={dsMatch.name}
580
- depth={depth}
581
- defaultOpen={true}
582
- active={activePath === SYSTEM_TAB && dsMatch.name === activeDsName}
583
- onOpenSystem={onOpenSystem}
584
- >
585
- {childTree}
586
- </DsFolderRow>
587
- );
588
- }
589
- return (
590
- <DirRow key={d} name={d} depth={depth} defaultOpen={true}>
591
- {childTree}
592
- </DirRow>
593
- );
594
- })}
595
- </Fragment>
596
- );
597
- }
598
-
599
- // CV-08 section labels — title + optional SKU pill. The pill carries
600
- // project / DS identity; the mock keeps these tight (1 line). Labels are
601
- // keyed by the server-provided `kind` (PROJECT / DS / UI / RUNTIME).
602
- const SECTION_META = {
603
- project: { title: 'PROJECT', pillFromCount: false },
604
- // Design-system group: pill shows the number of DSes (one row per DS folder
605
- // inside). Computed in Sidebar from `g.dsFolders.length`.
606
- ds: { title: 'DESIGN SYSTEM', pillFromDsCount: true },
607
- canvas: { title: 'UI CANVASES', pillFromCount: true },
608
- runtime: { title: 'RUNTIME · GITIGNORED', pillFromCount: true },
609
- };
610
-
611
- function sectionMetaFor(g) {
612
- if (g.kind === 'project') return SECTION_META.project;
613
- if (g.kind === 'runtime') return SECTION_META.runtime;
614
- // canvas-kind groups: "Design system" → ds, anything else → canvas label
615
- if (g.label === 'Design system') return SECTION_META.ds;
616
- if (g.label === 'UI kit') return SECTION_META.canvas;
617
- return { title: g.label.toUpperCase(), pillFromCount: true };
618
- }
619
-
620
- function Sidebar({
621
- groups,
622
- activePath,
623
- activeDsName,
624
- onOpen,
625
- onOpenSystem,
626
- wsConnected,
627
- search,
628
- setSearch,
629
- commentsByFile,
630
- showHidden,
631
- sectionsExpanded,
632
- onToggleSection,
633
- }) {
634
- const filteredGroups = useMemo(() => {
635
- if (!search) return groups;
636
- return groups.map((g) => ({ ...g, tree: filterTree(g.tree, search), filtered: !!search }));
637
- }, [groups, search]);
638
-
639
- // Mock uses `42 / 42` — total openable canvases, not every listed file.
640
- // We count canvas files (TSX Phase 3.6+ default, HTML legacy) so the counter
641
- // matches "canvases you can mount".
642
- const htmlCount = useMemo(() => {
643
- let total = 0;
644
- for (const g of groups) for (const p of g.paths || []) if (CANVAS_EXT_RE.test(p)) total++;
645
- return total;
646
- }, [groups]);
647
- const htmlShown = useMemo(() => {
648
- let total = 0;
649
- for (const g of filteredGroups)
650
- for (const p of g.paths || []) if (CANVAS_EXT_RE.test(p)) total++;
651
- return total;
652
- }, [filteredGroups]);
653
-
654
- return (
655
- <nav className="sidebar">
656
- <div className="tree-panel-hd">
657
- <span>FILES</span>
658
- <span className="ct" title={wsConnected ? 'live · file index synced' : 'reconnecting…'}>
659
- <span className={'live-dot' + (wsConnected ? ' connected' : '')} aria-hidden="true" />
660
- {htmlShown} / {htmlCount}
661
- </span>
662
- </div>
663
-
664
- <div className="tree-panel-search">
665
- <Icon d="M21 21l-4.35-4.35 M11 19a8 8 0 100-16 8 8 0 000 16z" size={12} />
666
- <input
667
- type="search"
668
- placeholder="filter (⌘F)"
669
- value={search}
670
- onChange={(e) => setSearch(e.target.value)}
671
- aria-label="Filter files"
672
- />
673
- {search ? (
674
- <button
675
- className="search-clear"
676
- onClick={() => setSearch('')}
677
- title="Clear (Esc)"
678
- aria-label="Clear search"
679
- >
680
- ×
681
- </button>
682
- ) : (
683
- <span className="search-kbd" aria-hidden="true">
684
- /
685
- </span>
686
- )}
687
- </div>
688
-
689
- <div className="tree-panel-body" role="tree" aria-label="Project file tree">
690
- {filteredGroups.map((g) => {
691
- // Hide gitignored runtime / orphan-only project sections by default.
692
- // Active search overrides — if the user typed a query, they want hits
693
- // wherever they live.
694
- if (!showHidden && !search && g.kind === 'runtime') return null;
695
- const meta = sectionMetaFor(g);
696
- // Counter pill counts canvases only — sidecars + orphans inflate the
697
- // raw `paths.length` and the FILES header already filters this way.
698
- const canvasCount = (g.paths || []).filter((p) => CANVAS_EXT_RE.test(p)).length;
699
- const pill =
700
- meta.pill ||
701
- (meta.pillFromDsCount ? String(g.dsFolders?.length || 0) : null) ||
702
- (meta.pillFromCount ? String(canvasCount || g.paths?.length || 0) : null);
703
- const hasItems = g.tree && Object.keys(g.tree).length > 0;
704
- const isDs = g.label === 'Design system';
705
- const isProject = g.kind === 'project';
706
- // Project section: when showHidden is off, every row inside is an
707
- // orphan (.md / .json / .css) → empty body. Skip the header in that
708
- // case so the sidebar doesn't show "PROJECT" with nothing under it.
709
- if (!showHidden && !search && isProject && canvasCount === 0) return null;
710
- const defaultOpen = sectionDefaultOpen(g);
711
- const explicit = sectionsExpanded[g.label];
712
- // Active search forces every section open so hits aren't hidden.
713
- const sectionOpen = !!search || (explicit === undefined ? defaultOpen : explicit);
714
- const chev = sectionOpen ? '▾' : '▸';
715
- return (
716
- <Fragment key={g.label}>
717
- <button
718
- type="button"
719
- className="tp-section-hd clickable section-toggle"
720
- onClick={() => onToggleSection(g.label, defaultOpen)}
721
- aria-expanded={sectionOpen}
722
- title={sectionOpen ? 'Collapse section' : 'Expand section'}
723
- >
724
- <span className="chev" aria-hidden="true">
725
- {chev}
726
- </span>
727
- <span className="section-label">{meta.title}</span>
728
- {pill && <span className="pill">{pill}</span>}
729
- </button>
730
- {sectionOpen &&
731
- (hasItems ? (
732
- <Tree
733
- node={g.tree}
734
- activePath={activePath}
735
- onOpen={onOpen}
736
- commentsByFile={commentsByFile}
737
- depth={1}
738
- kind={g.kind}
739
- showHidden={showHidden}
740
- search={search}
741
- dsFolders={g.dsFolders}
742
- activeDsName={activeDsName}
743
- onOpenSystem={isDs ? onOpenSystem : undefined}
744
- />
745
- ) : (
746
- <div className="tp-empty">{search ? 'No matches.' : 'Empty.'}</div>
747
- ))}
748
- </Fragment>
749
- );
750
- })}
751
- </div>
752
- </nav>
753
- );
754
- }
755
-
756
- // Help modal — hosts the cheatsheet that used to live in the left sidebar.
757
- // Triggered from the menubar's Help item. Esc + backdrop click close it.
758
- function HelpModal({ open, onClose }) {
759
- useEffect(() => {
760
- if (!open) return;
761
- function onKey(e) {
762
- if (e.key === 'Escape') onClose();
763
- }
764
- window.addEventListener('keydown', onKey);
765
- return () => window.removeEventListener('keydown', onKey);
766
- }, [open, onClose]);
767
- if (!open) return null;
768
- return (
769
- <div
770
- className="help-modal-backdrop"
771
- role="presentation"
772
- onMouseDown={(e) => {
773
- if (e.target === e.currentTarget) onClose();
774
- }}
775
- >
776
- <div
777
- className="help-modal"
778
- role="dialog"
779
- aria-modal="true"
780
- aria-labelledby="help-modal-title"
781
- >
782
- <header className="help-modal-hd">
783
- <span className="title" id="help-modal-title">
784
- Help · shortcuts &amp; commands
785
- </span>
786
- <span className="sku">MDCC-DEV-SRV / v{MDCC_VERSION}</span>
787
- <button
788
- type="button"
789
- className="help-modal-close"
790
- aria-label="Close (Esc)"
791
- onClick={onClose}
792
- >
793
- ×
794
- </button>
795
- </header>
796
- <div className="help-modal-body">
797
- <details open>
798
- <summary>Canvas selection &amp; tools</summary>
799
- <ul>
800
- <li>
801
- <kbd>V</kbd> <span>move tool — Cmd+click to select, Cmd+Shift to multi</span>
802
- </li>
803
- <li>
804
- <kbd>H</kbd> <span>hand tool — bare drag pans (no Space needed)</span>
805
- </li>
806
- <li>
807
- <kbd>C</kbd> <span>comment tool — hover paints, click drops a pin</span>
808
- </li>
809
- <li>
810
- <kbd>⌘</kbd> + hover <span>preview deepest element under cursor</span>
811
- </li>
812
- <li>
813
- <kbd>⌘</kbd> + click <span>select that element (replace)</span>
814
- </li>
815
- <li>
816
- <kbd>⌘⇧</kbd> + click <span>add deepest to selection (multi)</span>
817
- </li>
818
- <li>
819
- right-click <span>context menu (Copy CSS / Fit / Reset...)</span>
820
- </li>
821
- <li>
822
- <kbd>Esc</kbd> in canvas <span>clear selection + close menu</span>
823
- </li>
824
- </ul>
825
- </details>
826
- <details>
827
- <summary>Annotation tools</summary>
828
- <ul>
829
- <li>
830
- <kbd>B</kbd> <span>pen — freehand stroke</span>
831
- </li>
832
- <li>
833
- <kbd>R</kbd> <span>rectangle — drag to define corners</span>
834
- </li>
835
- <li>
836
- <kbd>O</kbd> <span>ellipse — drag from center outward</span>
837
- </li>
838
- <li>
839
- <kbd>A</kbd> <span>arrow — drag tail → tip</span>
840
- </li>
841
- <li>
842
- <kbd>E</kbd> <span>eraser — click or drag over strokes to remove</span>
843
- </li>
844
- <li>
845
- <kbd>V</kbd> + click stroke <span>select annotation (Shift+click to multi)</span>
846
- </li>
847
- <li>
848
- <kbd>V</kbd> + drag empty <span>marquee-select strokes that overlap</span>
849
- </li>
850
- <li>
851
- double-click rect/ellipse <span>add text inside the shape</span>
852
- </li>
853
- <li>
854
- arrow keys <span>nudge selected annotation 1 unit (Shift = 10)</span>
855
- </li>
856
- <li>
857
- <kbd>Backspace</kbd> <span>delete selected annotations</span>
858
- </li>
859
- <li>
860
- <kbd>⇧P</kbd> <span>presentation — hide annotations for clean screenshot</span>
861
- </li>
862
- </ul>
863
- </details>
864
- <details>
865
- <summary>Tabs &amp; canvas</summary>
866
- <ul>
867
- <li>
868
- click in tree <span>open tab</span>
869
- </li>
870
- <li>
871
- <kbd>×</kbd> on tab <span>close tab</span>
872
- </li>
873
- <li>
874
- <kbd>⌘R</kbd> <span>reload iframe</span>
875
- </li>
876
- <li>
877
- <kbd>/</kbd> <span>focus search</span>
878
- </li>
879
- <li>
880
- <kbd>⌘⇧M</kbd> <span>toggle comments panel</span>
881
- </li>
882
- </ul>
883
- </details>
884
- <details>
885
- <summary>Slash commands</summary>
886
- <ul className="cmds">
887
- <li>
888
- <code>
889
- /design:edit "<i>feedback</i>"
890
- </code>
891
- <span>edit + 4-iter multi-axis loop</span>
892
- </li>
893
- <li>
894
- <code>
895
- /design:edit "<i>…</i>" --perfect
896
- </code>
897
- <span>8-iter polish (4.5/5 aspiration)</span>
898
- </li>
899
- <li>
900
- <code>
901
- /design:edit "<i>…</i>" --no-critic
902
- </code>
903
- <span>raw edit, skip loop</span>
904
- </li>
905
- <li>
906
- <code>
907
- /design:edit "<i>…</i>" --opt-out=<i>scope</i>
908
- </code>
909
- <span>override DS scope (palette/aesthetic/full)</span>
910
- </li>
911
- <li>
912
- <code>
913
- /design:new "<i>Name</i>" "<i>brief</i>"
914
- </code>
915
- <span>scaffold canvas</span>
916
- </li>
917
- <li>
918
- <code>
919
- /design:new "<i>…</i>" --opt-out=aesthetic
920
- </code>
921
- <span>scaffold off-system canvas (gradients/radii/type free)</span>
922
- </li>
923
- <li>
924
- <code>/design:critic</code>
925
- <span>review panel (routed)</span>
926
- </li>
927
- <li>
928
- <code>/design:critic --all</code>
929
- <span>10-critic sweep</span>
930
- </li>
931
- <li>
932
- <code>/design:critic --agent signature-moment-critic</code>
933
- <span>aspiration axis only</span>
934
- </li>
935
- <li>
936
- <code>/design:rollback</code>
937
- <span>undo last edit</span>
938
- </li>
939
- <li>
940
- <code>/design:screenshot</code>
941
- <span>capture canvas</span>
942
- </li>
943
- <li>
944
- <code>/design:setup-docs</code>
945
- <span>refresh README + INDEX</span>
946
- </li>
947
- <li>
948
- <code>/design:handoff</code>
949
- <span>migrate to apps/</span>
950
- </li>
951
- </ul>
952
- </details>
953
- <details>
954
- <summary>Opt-out scope</summary>
955
- <ul>
956
- <li>
957
- <strong>palette</strong>{' '}
958
- <span>
959
- default — tokens + rootClass kept; local namespace overrides colors only. DS
960
- aesthetic still enforced.
961
- </span>
962
- </li>
963
- <li>
964
- <strong>aesthetic</strong>{' '}
965
- <span>
966
- palette + gradients/off-ladder radii/alt type/decorative SVG flags allowed.
967
- </span>
968
- </li>
969
- <li>
970
- <strong>full</strong>{' '}
971
- <span>DS treated as advisory. Type/radii/aesthetic up to canvas.</span>
972
- </li>
973
- <li>
974
- <em>A11y enforced at every scope</em>{' '}
975
- <span>contrast, focus, semantics, motion, touch targets — never relaxed.</span>
976
- </li>
977
- <li>
978
- Persisted on canvas's <code>.meta.json</code> <code>opt_out_scope</code> field —
979
- subsequent <code>/design:edit</code> iterations inherit.
980
- </li>
981
- <li>
982
- Inferred from brief ("modern", "vibrant", "off-system") with one-shot
983
- AskUserQuestion before iter-1 critics fire.
984
- </li>
985
- </ul>
986
- </details>
987
- <details>
988
- <summary>Auto-critic loop</summary>
989
- <ul>
990
- <li>
991
- <strong>Default</strong>{' '}
992
- <span>4 iter · aspiration ≥ 4.0 · stable-but-bland exit</span>
993
- </li>
994
- <li>
995
- <strong>--perfect</strong>{' '}
996
- <span>8 iter · aspiration ≥ 4.5 · broader divergence tolerance</span>
997
- </li>
998
- <li>
999
- <strong>--perfect --all</strong>{' '}
1000
- <span>every critic incl. aspiration · portfolio-grade</span>
1001
- </li>
1002
- <li>
1003
- Exit: <code>solid</code> · <code>stable-but-bland</code> · <code>max-reached</code>{' '}
1004
- · <code>divergent</code>
1005
- </li>
1006
- <li>
1007
- <em>stable-but-bland</em> = correctness clean, aspiration plateau — surface for
1008
- review with lowest 2 axes named
1009
- </li>
1010
- <li>
1011
- When <code>opt_out_scope ∈ &#123;aesthetic, full&#125;</code>: iter-1 checkpoint
1012
- fires — pick (a) run loop, (b) skip auto-loop and review iter 1, (c) a11y-only
1013
- check.
1014
- </li>
1015
- </ul>
1016
- </details>
1017
- <details>
1018
- <summary>Pin-to-element flow</summary>
1019
- <ol>
1020
- <li>Open canvas tab</li>
1021
- <li>
1022
- <kbd>⌘</kbd>+click element
1023
- </li>
1024
- <li>Status bar shows ● selector</li>
1025
- <li>
1026
- Run{' '}
1027
- <code>
1028
- /design:edit "<i>change just this</i>"
1029
- </code>
1030
- </li>
1031
- <li>
1032
- Reload iframe (<kbd>⌘R</kbd>)
1033
- </li>
1034
- </ol>
1035
- </details>
1036
- <details>
1037
- <summary>Comments</summary>
1038
- <ol>
1039
- <li>
1040
- <kbd>⌘</kbd>+click element, then <kbd>⌘C</kbd> <span>or ⌘⇧+click</span>
1041
- </li>
1042
- <li>Numbered pin appears on canvas</li>
1043
- <li>
1044
- <kbd>⌘⇧M</kbd> <span>opens panel — All / Open / Resolved</span>
1045
- </li>
1046
- <li>
1047
- Click row in panel <span>jumps to that file + pin</span>
1048
- </li>
1049
- <li>
1050
- Claude reads <code>_comments/&lt;slug&gt;.json</code> on next <code>/design</code>
1051
- </li>
1052
- </ol>
1053
- </details>
1054
- </div>
1055
- </div>
1056
- </div>
1057
- );
1058
- }
1059
-
1060
- // ───────── Menubar (CV-01/CV-08 top chrome) ─────────
1061
- //
1062
- // Replaces the legacy `.header` action-button toolbar. Mirrors the shared
1063
- // Menubar component from .design/ui/Canvas Viewport.html — brand · menus ·
1064
- // status. View dropdown is wired to the only toggleable panel today (the
1065
- // Comments sidebar); the rest is inert with a phase-tag explaining when it
1066
- // lands.
1067
-
1068
- const MENU_NAMES = ['File', 'Edit', 'View', 'Selection', 'Tools', 'Help'];
1069
-
1070
- function ViewDropdown({ panels, onToggle, onClose }) {
1071
- useEffect(() => {
1072
- function onKey(e) {
1073
- if (e.key === 'Escape') onClose();
1074
- }
1075
- function onDocClick(e) {
1076
- if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
1077
- }
1078
- window.addEventListener('keydown', onKey);
1079
- window.addEventListener('mousedown', onDocClick);
1080
- return () => {
1081
- window.removeEventListener('keydown', onKey);
1082
- window.removeEventListener('mousedown', onDocClick);
1083
- };
1084
- }, [onClose]);
1085
-
1086
- return (
1087
- <div className="mb-dropdown" role="menu" aria-label="View" style={{ left: '146px' }}>
1088
- <div className="mb-dd-hd">Panels</div>
1089
- {panels.map((p) => (
1090
- <button
1091
- key={p.id}
1092
- type="button"
1093
- role="menuitem"
1094
- className={'mb-dd-item' + (p.checked ? ' active' : '')}
1095
- aria-disabled={p.disabled ? 'true' : undefined}
1096
- onClick={() => {
1097
- if (!p.disabled) {
1098
- onToggle(p.id);
1099
- onClose();
1100
- }
1101
- }}
1102
- >
1103
- <span className="lbl">
1104
- <span className="check">{p.checked ? '✓' : ''}</span>
1105
- <span>{p.label}</span>
1106
- </span>
1107
- {p.phase ? (
1108
- <span className="phase-tag">{p.phase}</span>
1109
- ) : (
1110
- <span className="shortcut">{p.shortcut || ''}</span>
1111
- )}
1112
- </button>
1113
- ))}
1114
- <div className="mb-dd-sep" />
1115
- <div className="mb-dd-hd">Zoom</div>
1116
- {[
1117
- { label: 'Zoom In', shortcut: '⌘ +' },
1118
- { label: 'Zoom Out', shortcut: '⌘ −' },
1119
- { label: 'Fit to Screen', shortcut: '⌘ 0' },
1120
- { label: 'Actual Size · 100 %', shortcut: '⌥ ⌘ 0' },
1121
- ].map((z) => (
1122
- <button
1123
- key={z.label}
1124
- type="button"
1125
- role="menuitem"
1126
- className="mb-dd-item"
1127
- aria-disabled="true"
1128
- >
1129
- <span className="lbl">
1130
- <span className="check" />
1131
- <span>{z.label}</span>
1132
- </span>
1133
- <span className="phase-tag">Phase 4</span>
1134
- </button>
1135
- ))}
1136
- </div>
1137
- );
1138
- }
1139
-
1140
- // ─────────────────────────────────────────────────────────────────────────────
1141
- // Phase 5.1 — Selection + Tools dropdowns (mirror ViewDropdown shape).
1142
-
1143
- function SelectionDropdown({ onAction, onClose }) {
1144
- useEffect(() => {
1145
- function onKey(e) {
1146
- if (e.key === 'Escape') onClose();
1147
- }
1148
- function onDocClick(e) {
1149
- if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
1150
- }
1151
- window.addEventListener('keydown', onKey);
1152
- window.addEventListener('mousedown', onDocClick);
1153
- return () => {
1154
- window.removeEventListener('keydown', onKey);
1155
- window.removeEventListener('mousedown', onDocClick);
1156
- };
1157
- }, [onClose]);
1158
- const items = [
1159
- { id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
1160
- { id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⌘ ⇧ A' },
1161
- ];
1162
- return (
1163
- <div className="mb-dropdown" role="menu" aria-label="Selection" style={{ left: '195px' }}>
1164
- {items.map((it) => (
1165
- <button
1166
- key={it.id}
1167
- type="button"
1168
- role="menuitem"
1169
- className="mb-dd-item"
1170
- onClick={() => {
1171
- onAction(it.id);
1172
- onClose();
1173
- }}
1174
- >
1175
- <span className="lbl">
1176
- <span className="check" />
1177
- <span>{it.label}</span>
1178
- </span>
1179
- <span className="shortcut">{it.shortcut}</span>
1180
- </button>
1181
- ))}
1182
- </div>
1183
- );
1184
- }
1185
-
1186
- function ToolsDropdown({ onAction, onClose }) {
1187
- useEffect(() => {
1188
- function onKey(e) {
1189
- if (e.key === 'Escape') onClose();
1190
- }
1191
- function onDocClick(e) {
1192
- if (!e.target.closest('.mb-dropdown, .mb-menu')) onClose();
1193
- }
1194
- window.addEventListener('keydown', onKey);
1195
- window.addEventListener('mousedown', onDocClick);
1196
- return () => {
1197
- window.removeEventListener('keydown', onKey);
1198
- window.removeEventListener('mousedown', onDocClick);
1199
- };
1200
- }, [onClose]);
1201
- // Mirrors DEFAULT_TOOLS in plugins/design/dev-server/use-tool-mode.tsx —
1202
- // kept in sync by hand because the menubar lives in the dev-server shell
1203
- // (no shared bundle with the canvas iframes).
1204
- const tools = [
1205
- { id: 'move', label: 'Move', shortcut: 'V' },
1206
- { id: 'hand', label: 'Hand', shortcut: 'H' },
1207
- { id: 'comment', label: 'Comment', shortcut: 'C' },
1208
- { id: 'pen', label: 'Pen', shortcut: 'B' },
1209
- { id: 'rect', label: 'Rect', shortcut: 'R' },
1210
- { id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
1211
- { id: 'sticky', label: 'Sticky', shortcut: 'N' },
1212
- { id: 'arrow', label: 'Arrow', shortcut: 'A' },
1213
- { id: 'text', label: 'Text', shortcut: 'T' },
1214
- { id: 'eraser', label: 'Eraser', shortcut: 'E' },
1215
- ];
1216
- return (
1217
- <div className="mb-dropdown" role="menu" aria-label="Tools" style={{ left: '253px' }}>
1218
- {tools.map((t) => (
1219
- <button
1220
- key={t.id}
1221
- type="button"
1222
- role="menuitem"
1223
- className="mb-dd-item"
1224
- onClick={() => {
1225
- onAction(t.id);
1226
- onClose();
1227
- }}
1228
- >
1229
- <span className="lbl">
1230
- <span className="check" />
1231
- <span>{t.label}</span>
1232
- </span>
1233
- <span className="shortcut">{t.shortcut}</span>
1234
- </button>
1235
- ))}
1236
- </div>
1237
- );
1238
- }
1239
-
1240
- function Menubar({
1241
- activePath,
1242
- project,
1243
- tabsCount,
1244
- openMenu,
1245
- setOpenMenu,
1246
- commentsPanelOpen,
1247
- onToggleComments,
1248
- onOpenSystem,
1249
- sidebarOpen,
1250
- onToggleSidebar,
1251
- showHidden,
1252
- onToggleShowHidden,
1253
- onOpenHelp,
1254
- annotationsVisible,
1255
- onToggleAnnotations,
1256
- postToActiveCanvas,
1257
- }) {
1258
- const isSystem = activePath === SYSTEM_TAB;
1259
- const stamp = isSystem ? 'SYSTEM' : activePath ? 'CANVAS' : 'IDLE';
1260
- const fileLabel = isSystem ? (
1261
- <b>design system</b>
1262
- ) : activePath ? (
1263
- <>
1264
- {activePath.split('/').slice(0, -1).join('/')}/<b>{displayName(basename(activePath))}</b>
1265
- </>
1266
- ) : (
1267
- <span style={{ color: 'var(--u-fg-3)' }}>no canvas open</span>
1268
- );
1269
-
1270
- const panels = [
1271
- { id: 'tree', label: 'Project Tree', shortcut: 'T', checked: sidebarOpen, disabled: false },
1272
- {
1273
- id: 'comments',
1274
- label: 'Comments Sidebar',
1275
- shortcut: '⌘ ⇧ M',
1276
- checked: commentsPanelOpen,
1277
- disabled: false,
1278
- },
1279
- {
1280
- id: 'hidden',
1281
- label: 'Show hidden files',
1282
- shortcut: 'H',
1283
- checked: showHidden,
1284
- disabled: false,
1285
- },
1286
- { id: 'layers', label: 'Layers Panel', phase: 'Phase 12', disabled: true },
1287
- { id: 'inspector', label: 'Inspector', phase: 'Phase 12', disabled: true },
1288
- {
1289
- id: 'annotate',
1290
- label: 'Annotations',
1291
- shortcut: '⇧ P',
1292
- checked: annotationsVisible,
1293
- disabled: false,
1294
- },
1295
- { id: 'present', label: 'Presentation Mode', phase: 'Phase 6', disabled: true },
1296
- ];
1297
-
1298
- function onMenuClick(key) {
1299
- if (key === 'view' || key === 'selection' || key === 'tools') {
1300
- setOpenMenu(openMenu === key ? null : key);
1301
- } else if (key === 'help') {
1302
- setOpenMenu(null);
1303
- onOpenHelp();
1304
- }
1305
- }
1306
-
1307
- return (
1308
- <header className="mb" role="menubar" aria-label="Application menubar">
1309
- <span className="mb-brand">
1310
- <span className="dot" aria-hidden="true" />
1311
- <span>maude</span>
1312
- </span>
1313
- <nav className="mb-menus" aria-label="Application menus">
1314
- {MENU_NAMES.map((name) => {
1315
- const key = name.toLowerCase();
1316
- const hasDropdown = key === 'view' || key === 'selection' || key === 'tools';
1317
- const interactive = hasDropdown || key === 'help';
1318
- const open = openMenu === key;
1319
- return (
1320
- <button
1321
- key={key}
1322
- type="button"
1323
- className="mb-menu"
1324
- role="menuitem"
1325
- aria-haspopup={hasDropdown ? 'menu' : undefined}
1326
- aria-expanded={hasDropdown ? open : undefined}
1327
- aria-disabled={interactive ? undefined : 'true'}
1328
- title={interactive ? '' : 'Coming in a later phase'}
1329
- onClick={() => onMenuClick(key)}
1330
- >
1331
- {name}
1332
- </button>
1333
- );
1334
- })}
1335
- </nav>
1336
- {openMenu === 'view' && (
1337
- <ViewDropdown
1338
- panels={panels}
1339
- onToggle={(id) => {
1340
- if (id === 'tree') onToggleSidebar();
1341
- else if (id === 'comments') onToggleComments();
1342
- else if (id === 'hidden') onToggleShowHidden();
1343
- else if (id === 'annotate') onToggleAnnotations();
1344
- }}
1345
- onClose={() => setOpenMenu(null)}
1346
- />
1347
- )}
1348
- {openMenu === 'selection' && (
1349
- <SelectionDropdown
1350
- onAction={(id) => {
1351
- if (id === 'deselect-all') postToActiveCanvas({ dgn: 'selection-clear' });
1352
- else if (id === 'select-all-annotations')
1353
- postToActiveCanvas({ dgn: 'annotation-select-all' });
1354
- }}
1355
- onClose={() => setOpenMenu(null)}
1356
- />
1357
- )}
1358
- {openMenu === 'tools' && (
1359
- <ToolsDropdown
1360
- onAction={(tool) => postToActiveCanvas({ dgn: 'tool-set', tool })}
1361
- onClose={() => setOpenMenu(null)}
1362
- />
1363
- )}
1364
- <div className="mb-spacer" />
1365
- <div className="mb-status">
1366
- <span className="cv-stamp">{stamp}</span>
1367
- <span className="file" title={activePath || ''}>
1368
- {fileLabel}
1369
- </span>
1370
- <span className="sep" />
1371
- <span>
1372
- <span className="accent-dot">●</span> <b>{tabsCount}</b> ARTBOARDS
1373
- </span>
1374
- <span className="sep" />
1375
- <span title="Pan/zoom in Phase 4">
1376
- ZOOM <b>100%</b>
1377
- </span>
1378
- <span className="sep" />
1379
- <span className="ok">
1380
- <b>{project || 'MDCC'}</b>
1381
- </span>
1382
- </div>
1383
- </header>
1384
- );
1385
- }
1386
-
1387
- function ThemeToggle({ theme, onToggle }) {
1388
- // Show the icon of the theme you'll switch TO — clearer affordance than current state.
1389
- // Sun + Moon paths are condensed Lucide-style (single-path so the existing <Icon> works).
1390
- const sun =
1391
- 'M12 7a5 5 0 100 10 5 5 0 000-10z M12 3v1 M12 20v1 M21 12h-1 M4 12H3 M16.95 7.05l-.71.71 M7.05 16.95l-.71.71 M16.95 16.95l-.71-.71 M7.05 7.05l-.71-.71';
1392
- const moon = 'M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z';
1393
- const next = theme === 'dark' ? 'light' : 'dark';
1394
- return (
1395
- <button
1396
- type="button"
1397
- className="theme-toggle"
1398
- onClick={onToggle}
1399
- title={`Switch to ${next} theme`}
1400
- aria-label={`Switch to ${next} theme`}
1401
- >
1402
- <Icon d={theme === 'dark' ? sun : moon} size={14} />
1403
- <span className="theme-toggle-label">{next}</span>
1404
- </button>
1405
- );
1406
- }
1407
-
1408
- function Wordmark({ project, port, version }) {
1409
- return (
1410
- <div className="wm" aria-label="maude design server">
1411
- <span className="wm-glyph">maude-design-server</span>
1412
- <span className="wm-sub">
1413
- <span>CANVAS · {(project || 'MAUDE').toUpperCase()}</span>
1414
- <span className="wm-sep">/</span>
1415
- <b>v{version}</b>
1416
- <span className="wm-sep">/</span>
1417
- <span>localhost:{port || '4399'}</span>
1418
- </span>
1419
- </div>
1420
- );
1421
- }
1422
-
1423
- function Viewport({
1424
- tabs,
1425
- activePath,
1426
- registerIframe,
1427
- systemData,
1428
- onOpenFromSystem,
1429
- onSelectDs,
1430
- project,
1431
- cfg,
1432
- }) {
1433
- return (
1434
- <div className="viewport">
1435
- {tabs.length === 0 && (
1436
- <>
1437
- <Wordmark
1438
- project={project}
1439
- port={typeof window !== 'undefined' ? window.location.port : ''}
1440
- version={MDCC_VERSION}
1441
- />
1442
- <div className="empty-state">
1443
- <div className="big">No mock open</div>
1444
- <div className="small">
1445
- ← Click a <code>.tsx</code> (or legacy <code>.html</code>) file in the tree, or open
1446
- the <strong>Design system</strong> view above it.
1447
- <br />
1448
- <br />
1449
- Tabs work like in an editor — close with the × on each tab. <kbd>⌘R</kbd> reloads the
1450
- active iframe.
1451
- <br />
1452
- <br />
1453
- <strong>Element selection:</strong> hold <kbd>⌘</kbd> inside the canvas and hover for
1454
- a preview, click to select. <kbd>⌘⇧</kbd>+click adds to a multi-selection.{' '}
1455
- <kbd>V</kbd>/<kbd>H</kbd>/<kbd>C</kbd> swap tool; right-click opens the context menu.
1456
- <br />
1457
- <br />
1458
- Active file, selection, and comments are tracked in <code>_active.json</code> +{' '}
1459
- <code>_comments/</code> — Claude reads them when you run <code>/design</code>.
1460
- </div>
1461
- </div>
1462
- </>
1463
- )}
1464
- {tabs.map((t) => {
1465
- if (t.path === SYSTEM_TAB) {
1466
- return (
1467
- <div key={t.path} className={'system-view' + (t.path === activePath ? ' active' : '')}>
1468
- <SystemView
1469
- data={systemData}
1470
- onOpen={onOpenFromSystem}
1471
- cfg={cfg}
1472
- onSelectDs={onSelectDs}
1473
- />
1474
- </div>
1475
- );
1476
- }
1477
- return (
1478
- <iframe
1479
- key={t.path}
1480
- ref={(el) => registerIframe(t.path, el)}
1481
- src={canvasUrl(t.path, cfg)}
1482
- className={t.path === activePath ? 'active' : ''}
1483
- data-path={t.path}
1484
- // T2 (9.1-A) — only sandbox + delegate clipboard when the canvas is
1485
- // served cross-origin (canvasOrigin present = the split is on). In
1486
- // the default same-origin mode these attrs are omitted so behavior
1487
- // is identical to pre-9.1. allow-same-origin gives the cross-origin
1488
- // frame its OWN origin (own WS/fetch/storage), NOT the parent's.
1489
- {...(cfg?.canvasOrigin
1490
- ? { sandbox: 'allow-scripts allow-same-origin', allow: 'clipboard-write' }
1491
- : {})}
1492
- />
1493
- );
1494
- })}
1495
- </div>
1496
- );
1497
- }
1498
-
1499
- // ---------- SystemView ----------
1500
- //
1501
- // DDR-048 — the System view renders the USER's design-system tokens. It does
1502
- // NOT read from `document.documentElement` (that would surface the dev-server
1503
- // shell's amber-rust chrome theme from styles/1-tokens.css, which is NOT a
1504
- // user template) and it does NOT assume any canonical token-name contract.
1505
- // Whatever the user's `colors_and_type.css` declared — names, values, theme
1506
- // blocks — is what shows up here.
1507
-
1508
- // Order kinds match the typical reading flow of a tokens file. Unknown kinds
1509
- // fall through to `other` so a custom token group still renders, just last.
1510
- const TOKEN_GROUP_ORDER = [
1511
- 'color',
1512
- 'space',
1513
- 'radius',
1514
- 'shadow',
1515
- 'leading',
1516
- 'weight',
1517
- 'motion',
1518
- 'font',
1519
- 'other',
1520
- ];
1521
- const TOKEN_GROUP_LABELS = {
1522
- color: 'colors',
1523
- space: 'spacing',
1524
- radius: 'radii',
1525
- shadow: 'shadows',
1526
- leading: 'leading',
1527
- weight: 'weights',
1528
- motion: 'motion',
1529
- font: 'font stacks',
1530
- other: 'other',
1531
- };
1532
-
1533
- function isSwatchKind(kind) {
1534
- return kind === 'color';
1535
- }
1536
-
1537
- function TokenLadder({ tokens, tokenGroups, tokensPath }) {
1538
- if (!tokens || tokens.length === 0) {
1539
- return (
1540
- <section className="sv-section sv-section-tokens">
1541
- <h2>
1542
- tokens<span className="sv-h-num">0</span>
1543
- </h2>
1544
- <div className="sv-empty">
1545
- <p>
1546
- No tokens parsed from{' '}
1547
- {tokensPath ? <code>{tokensPath}</code> : 'the configured tokens file'}. Does the file
1548
- exist and contain CSS custom properties (<code>--name: value;</code>)?
1549
- </p>
1550
- </div>
1551
- </section>
1552
- );
1553
- }
1554
-
1555
- const groups = tokenGroups || {};
1556
- const kinds = Array.from(new Set([...TOKEN_GROUP_ORDER, ...Object.keys(groups)])).filter(
1557
- (k) => groups[k]?.length
1558
- );
1559
-
1560
- return (
1561
- <>
1562
- {kinds.map((kind) => {
1563
- const list = groups[kind];
1564
- const swatch = isSwatchKind(kind);
1565
- return (
1566
- <section className={'sv-section sv-section-tokens sv-section-' + kind} key={kind}>
1567
- <h2>
1568
- tokens · {TOKEN_GROUP_LABELS[kind] || kind}
1569
- <span className="sv-h-num">{list.length}</span>
1570
- </h2>
1571
- <div className="sv-tokens-ladder">
1572
- {list.map((t) => (
1573
- <div className="sv-tok-cell" key={t.name + '|' + t.value}>
1574
- {swatch ? (
1575
- <div className="sv-tok-swatch" style={{ background: t.value }} />
1576
- ) : null}
1577
- <div className="sv-tok-meta">
1578
- <code className="sv-tok-name">{t.name}</code>
1579
- <span className="sv-tok-value">{t.value || '—'}</span>
1580
- </div>
1581
- </div>
1582
- ))}
1583
- </div>
1584
- </section>
1585
- );
1586
- })}
1587
- </>
1588
- );
1589
- }
1590
-
1591
- // Find a leading partner by name suffix: --fs-base → --lh-base, --type-xl → --lh-xl.
1592
- // Returns null when no convention match exists; caller omits the lineHeight style.
1593
- function findLeadingFor(typeToken, leadingTokens) {
1594
- const m = typeToken.name.match(/^--(?:type|fs|text)-(.+)$/);
1595
- if (!m) return null;
1596
- const suffix = m[1];
1597
- return (
1598
- (leadingTokens || []).find(
1599
- (t) => /^--(?:lh|leading|line-height)-/.test(t.name) && t.name.endsWith('-' + suffix)
1600
- )?.value ?? null
1601
- );
1602
- }
1603
-
1604
- // Best-effort sample font: prefer body / sans / display tokens, fall back to
1605
- // the first font-kind token, fall back to system-ui. Avoids the shell's
1606
- // Berkeley Mono leaking into user-facing previews.
1607
- function sampleFontFamily(fontTokens) {
1608
- if (!fontTokens?.length) return undefined;
1609
- const prefer = ['body', 'sans', 'display', 'text', 'family'];
1610
- for (const tag of prefer) {
1611
- const hit = fontTokens.find((t) => t.name.includes(tag));
1612
- if (hit) return hit.value;
1613
- }
1614
- return fontTokens[0].value;
1615
- }
1616
-
1617
- function TypeLadder({ tokenGroups }) {
1618
- const typeTokens = tokenGroups?.fontsize || [];
1619
- if (typeTokens.length === 0) return null;
1620
- const leadingTokens = tokenGroups?.leading || [];
1621
- const sampleFont = sampleFontFamily(tokenGroups?.font);
1622
-
1623
- return (
1624
- <section className="sv-section sv-section-type">
1625
- <h2>
1626
- type · ladder<span className="sv-h-num">{typeTokens.length}</span>
1627
- </h2>
1628
- <div className="sv-type-list">
1629
- {typeTokens.map((t) => {
1630
- const lh = findLeadingFor(t, leadingTokens);
1631
- const style = { fontSize: t.value };
1632
- if (lh) style.lineHeight = lh;
1633
- if (sampleFont) style.fontFamily = sampleFont;
1634
- return (
1635
- <div className="sv-type-row" key={t.name}>
1636
- <code className="sv-type-tok">{t.name}</code>
1637
- <span className="sv-type-sample" style={style}>
1638
- The catalog is the system.
1639
- </span>
1640
- </div>
1641
- );
1642
- })}
1643
- </div>
1644
- </section>
1645
- );
1646
- }
1647
-
1648
- function SystemView({ data, onOpen, cfg, onSelectDs }) {
1649
- if (!data) {
1650
- return (
1651
- <div className="sv-empty">
1652
- <p>Loading design system…</p>
1653
- </div>
1654
- );
1655
- }
1656
- const {
1657
- previewGallery,
1658
- uiKitsGallery,
1659
- systemDir,
1660
- tokens,
1661
- tokenGroups,
1662
- tokensPath,
1663
- ds,
1664
- availableDesignSystems,
1665
- } = data;
1666
- const empty =
1667
- (!previewGallery || !previewGallery.length) && (!uiKitsGallery || !uiKitsGallery.length);
1668
- const hasPicker = Array.isArray(availableDesignSystems) && availableDesignSystems.length > 1;
1669
- const selectedName = ds?.name ?? availableDesignSystems?.[0]?.name ?? '';
1670
-
1671
- return (
1672
- <div className="sv">
1673
- <header className="sv-header">
1674
- <span className="sv-sku">MDCC-DSN/01</span>
1675
- <span className="sv-title">design system view</span>
1676
- {hasPicker ? (
1677
- <label className="sv-ds-picker">
1678
- <span className="sv-ds-picker-label">DS</span>
1679
- <select value={selectedName} onChange={(e) => onSelectDs && onSelectDs(e.target.value)}>
1680
- {availableDesignSystems.map((d) => (
1681
- <option key={d.name} value={d.name}>
1682
- {d.name}
1683
- </option>
1684
- ))}
1685
- </select>
1686
- </label>
1687
- ) : null}
1688
- <span className="sv-loc">
1689
- <code>{systemDir}</code>
1690
- </span>
1691
- </header>
1692
-
1693
- {ds?.description ? <p className="sv-ds-description">{ds.description}</p> : null}
1694
-
1695
- <TokenLadder tokens={tokens} tokenGroups={tokenGroups} tokensPath={tokensPath} />
1696
- <TypeLadder tokenGroups={tokenGroups} />
1697
-
1698
- {empty ? (
1699
- <div className="sv-empty">
1700
- <p>
1701
- No <code>preview/</code> or <code>ui_kits/</code> folders found under{' '}
1702
- <code>{systemDir}</code>.
1703
- </p>
1704
- </div>
1705
- ) : (
1706
- <>
1707
- <Gallery
1708
- title="preview"
1709
- items={previewGallery}
1710
- onOpen={onOpen}
1711
- kind="preview"
1712
- cfg={cfg}
1713
- />
1714
- <Gallery title="ui kits" items={uiKitsGallery} onOpen={onOpen} kind="ui_kits" cfg={cfg} />
1715
- </>
1716
- )}
1717
- </div>
1718
- );
1719
- }
1720
-
1721
- function Gallery({ title, items, onOpen, kind, cfg }) {
1722
- if (!items || items.length === 0) return null;
1723
- return (
1724
- <section className="sv-section">
1725
- <h2>
1726
- {title} <span className="sv-count">{items.length}</span>
1727
- </h2>
1728
- <div className={'sv-previews sv-previews-' + kind}>
1729
- {items.map((p) => (
1730
- <article key={p.path} className="sv-preview-card" onClick={() => onOpen(p.path)}>
1731
- <div className="sv-preview-frame">
1732
- <iframe
1733
- src={canvasUrl(p.path, cfg, { thumbnail: true })}
1734
- title={p.label}
1735
- scrolling="no"
1736
- {...(cfg?.canvasOrigin ? { sandbox: 'allow-scripts allow-same-origin' } : {})}
1737
- />
1738
- </div>
1739
- <div className="sv-preview-foot">
1740
- <strong>{p.label}</strong>
1741
- <code>{p.path}</code>
1742
- </div>
1743
- </article>
1744
- ))}
1745
- </div>
1746
- </section>
1747
- );
1748
- }
1749
-
1750
- // ---------- Comment composer / viewer ----------
1751
-
1752
- function CommentBar({ activePath, comments }) {
1753
- // Phase 6 — the shell-side composer + chip strip + focused-row chrome were
1754
- // removed. The iframe overlay (comments-overlay.tsx) owns composer + pin
1755
- // bubbles + thread popover. BottomBar shrinks to a live open-count summary
1756
- // so the shell still surfaces total review activity at a glance.
1757
- if (!activePath) return null;
1758
- const openComments = (comments || []).filter((c) => c.status !== 'resolved');
1759
- if (openComments.length === 0) return null;
1760
- return (
1761
- <div className="comment-bar">
1762
- <div className="cb-row strip">
1763
- <span className="cb-label">
1764
- {openComments.length} open comment{openComments.length === 1 ? '' : 's'}
1765
- </span>
1766
- </div>
1767
- </div>
1768
- );
1769
- }
1770
-
1771
- function StatusBarSlot({ label, children, className = '' }) {
1772
- return (
1773
- <span className={'sb-slot ' + className} role="group" aria-label={label}>
1774
- {children}
1775
- </span>
1776
- );
1777
- }
1778
-
1779
- function StatusBar({
1780
- activePath,
1781
- selected,
1782
- wsConnected,
1783
- openCount,
1784
- theme,
1785
- onToggleTheme,
1786
- onClearSelected,
1787
- syncStatus,
1788
- }) {
1789
- const isSystem = activePath === SYSTEM_TAB;
1790
- const text =
1791
- selected && selected.selector
1792
- ? selected.selector + (selected.text ? ` — "${selected.text.slice(0, 60)}"` : '')
1793
- : '';
1794
- const title =
1795
- selected && selected.dom_path
1796
- ? selected.dom_path.join(' > ')
1797
- : selected
1798
- ? selected.selector
1799
- : '';
1800
- return (
1801
- <div className="statusbar" role="contentinfo">
1802
- <StatusBarSlot label="Active file" className="sb-active">
1803
- <span className="sb-key">active</span>
1804
- <span className="sb-file" title={activePath || ''}>
1805
- {isSystem ? '▦ design system' : activePath || '—'}
1806
- </span>
1807
- </StatusBarSlot>
1808
-
1809
- {selected && selected.selector && !isSystem && (
1810
- <StatusBarSlot label="Selected element" className="sb-selected">
1811
- <span className="sb-dot" aria-hidden="true">
1812
-
1813
- </span>
1814
- <span className="sb-sel-text" title={title}>
1815
- {text}
1816
- </span>
1817
- <button
1818
- type="button"
1819
- className="sb-clear-sel"
1820
- onClick={onClearSelected}
1821
- title="Clear (Esc inside iframe)"
1822
- aria-label="Clear selection"
1823
- >
1824
- ×
1825
- </button>
1826
- </StatusBarSlot>
1827
- )}
1828
-
1829
- <StatusBarSlot label="Open comments" className="sb-unread">
1830
- <span className="sb-key">comments</span>
1831
- <span className="sb-count">{openCount}</span>
1832
- </StatusBarSlot>
1833
-
1834
- <StatusBarSlot label="Connection" className="sb-live">
1835
- <span className={'sb-live-dot' + (wsConnected ? ' connected' : '')} aria-hidden="true" />
1836
- <span className="sb-key">{wsConnected ? 'live' : 'reconnecting'}</span>
1837
- </StatusBarSlot>
1838
-
1839
- {/* DDR-060 / 9.1-D — linked-to-hub-but-nothing-syncs state lives here in the
1840
- status bar (DS-styled, hover for detail) instead of a floating off-brand
1841
- pill. Only shown when the project is linked + has 0 syncable canvases. */}
1842
- {syncStatus?.notSyncable && (
1843
- <StatusBarSlot label="Hub sync" className="sb-sync">
1844
- <span className="sb-sync-dot" aria-hidden="true" />
1845
- <span
1846
- className="sb-key"
1847
- title={syncStatus.reason || 'Linked to a hub, but no canvases are syncable.'}
1848
- >
1849
- 0 syncable{syncStatus.tsxCount > 0 ? ` · ${syncStatus.tsxCount} tsx` : ''}
1850
- </span>
1851
- </StatusBarSlot>
1852
- )}
1853
-
1854
- <span className="sb-spacer" />
1855
-
1856
- <StatusBarSlot label="Theme" className="sb-theme">
1857
- <ThemeToggle theme={theme} onToggle={onToggleTheme} />
1858
- </StatusBarSlot>
1859
- </div>
1860
- );
1861
- }
1862
-
1863
- // ---------- Right sidebar — Comments panel ----------
1864
-
1865
- function CommentsPanel({
1866
- commentsByFile,
1867
- filter,
1868
- setFilter,
1869
- activePath,
1870
- focusedId,
1871
- onJump,
1872
- onResolve,
1873
- onReopen,
1874
- onDelete,
1875
- }) {
1876
- const counts = totalCounts(commentsByFile);
1877
- // Build groups: [{ file, comments: filtered }]
1878
- const files = Object.keys(commentsByFile || {}).sort();
1879
- const groups = [];
1880
- for (const f of files) {
1881
- const all = commentsByFile[f] || [];
1882
- const filtered = all.filter((c) => {
1883
- if (filter === 'open') return c.status !== 'resolved';
1884
- if (filter === 'resolved') return c.status === 'resolved';
1885
- return true;
1886
- });
1887
- if (filtered.length === 0) continue;
1888
- // Number is fixed by all-list order so it matches pin numbers (which are based on position in the array of selector-having comments)
1889
- const numberedAll = all.filter((c) => c.selector);
1890
- groups.push({
1891
- file: f,
1892
- comments: filtered.map((c) => ({
1893
- ...c,
1894
- n: numberedAll.findIndex((x) => x.id === c.id) + 1,
1895
- })),
1896
- });
1897
- }
1898
-
1899
- return (
1900
- <aside className="rsidebar">
1901
- <div className="rsidebar-header">
1902
- <h2>
1903
- <span>Comments</span>
1904
- <span className="total">{counts.all}</span>
1905
- </h2>
1906
- <div className="rsidebar-filters" role="tablist">
1907
- <button
1908
- className={'rsidebar-filter' + (filter === 'all' ? ' active' : '')}
1909
- role="tab"
1910
- aria-selected={filter === 'all'}
1911
- onClick={() => setFilter('all')}
1912
- >
1913
- All <span className="fc">{counts.all}</span>
1914
- </button>
1915
- <button
1916
- className={'rsidebar-filter' + (filter === 'open' ? ' active' : '')}
1917
- role="tab"
1918
- aria-selected={filter === 'open'}
1919
- onClick={() => setFilter('open')}
1920
- >
1921
- Open <span className="fc">{counts.open}</span>
1922
- </button>
1923
- <button
1924
- className={'rsidebar-filter' + (filter === 'resolved' ? ' active' : '')}
1925
- role="tab"
1926
- aria-selected={filter === 'resolved'}
1927
- onClick={() => setFilter('resolved')}
1928
- >
1929
- Resolved <span className="fc">{counts.resolved}</span>
1930
- </button>
1931
- </div>
1932
- </div>
1933
- <div className="rsidebar-body">
1934
- {groups.length === 0 ? (
1935
- <div className="rsidebar-empty">
1936
- <p>No comments {filter !== 'all' ? `with status “${filter}”` : 'yet'}.</p>
1937
- <p style={{ marginTop: 12 }}>
1938
- Open a canvas, hold <kbd>⌘</kbd> and click an element, then press <kbd>C</kbd> — or
1939
- hold <kbd>⌘⇧</kbd> and click directly.
1940
- </p>
1941
- </div>
1942
- ) : (
1943
- groups.map((g) => (
1944
- <div key={g.file} className="rs-group">
1945
- <button className="rs-group-h" onClick={() => onJump(g.file, null)} title={g.file}>
1946
- <span className="rs-group-name">{displayName(basename(g.file))}</span>
1947
- <span className="rs-group-count">{g.comments.length}</span>
1948
- </button>
1949
- {g.comments.map((c) => (
1950
- <div
1951
- key={c.id}
1952
- className={
1953
- 'rs-comment' +
1954
- (c.status === 'resolved' ? ' resolved' : '') +
1955
- (c.id === focusedId ? ' active-pin' : '')
1956
- }
1957
- onClick={() => onJump(g.file, c.id)}
1958
- >
1959
- <div className="rs-comment-head">
1960
- <span className="rs-num">{c.n || '·'}</span>
1961
- <span className="rs-time">{timeAgo(c.created)}</span>
1962
- </div>
1963
- <div className="rs-comment-text">{c.text}</div>
1964
- <div className="rs-comment-foot">
1965
- <code title={(c.dom_path || []).join(' > ')}>{c.selector || '—'}</code>
1966
- <div className="rs-comment-actions">
1967
- {c.status === 'resolved' ? (
1968
- <button
1969
- className="rs-act"
1970
- onClick={(e) => {
1971
- e.stopPropagation();
1972
- onReopen(c.id);
1973
- }}
1974
- >
1975
-
1976
- </button>
1977
- ) : (
1978
- <button
1979
- className="rs-act"
1980
- onClick={(e) => {
1981
- e.stopPropagation();
1982
- onResolve(c.id);
1983
- }}
1984
- >
1985
-
1986
- </button>
1987
- )}
1988
- <button
1989
- className="rs-act danger"
1990
- onClick={(e) => {
1991
- e.stopPropagation();
1992
- onDelete(c.id);
1993
- }}
1994
- >
1995
- ×
1996
- </button>
1997
- </div>
1998
- </div>
1999
- </div>
2000
- ))}
2001
- </div>
2002
- ))
2003
- )}
2004
- </div>
2005
- </aside>
2006
- );
2007
- }
2008
-
2009
- // ---------- Sync banner (Phase 9 Task 8 — hub-down offline mode) ----------
2010
-
2011
- // Renders nothing when online with no flash (the common case). Yellow strip
2012
- // while offline (with queued-edit count), red when offline > 24h, green flash
2013
- // for 3s right after a reconnect. Driven entirely by the 'sync:status' payload
2014
- // the dev-server's linked-mode sync runtime broadcasts.
2015
- function SyncBanner({ status }) {
2016
- if (!status || status.linked === false) return null;
2017
- // DDR-060 / 9.1-D — the "linked but 0 syncable" state is surfaced in the
2018
- // status bar (sb-sync slot), NOT as a floating banner. This component owns
2019
- // only the transient offline / reconnect-flash banner (Task 8).
2020
- if (status.notSyncable) return null;
2021
- const { state, queuedOps, flash, conflicts } = status;
2022
- const showFlash = flash === 'synced';
2023
- const offline = state === 'offline' || state === 'offline-long';
2024
- if (!offline && !showFlash) return null;
2025
-
2026
- let bg;
2027
- let fg;
2028
- let border;
2029
- let text;
2030
- if (showFlash) {
2031
- bg = '#dcfce7';
2032
- fg = '#166534';
2033
- border = '#86efac';
2034
- text = 'Synced with hub';
2035
- } else if (state === 'offline-long') {
2036
- bg = '#fee2e2';
2037
- fg = '#991b1b';
2038
- border = '#fca5a5';
2039
- text = `Long offline — ${queuedOps} edit(s) queued. Consider \`git commit && git push\` as backup.`;
2040
- } else {
2041
- bg = '#fef9c3';
2042
- fg = '#854d0e';
2043
- border = '#fde047';
2044
- text = `Working offline · ${queuedOps} edit(s) queued · will sync when the hub reconnects.`;
2045
- }
2046
- const conflictNote =
2047
- conflicts && conflicts.length > 0 ? ` (${conflicts.length} conflict notice(s))` : '';
2048
-
2049
- return (
2050
- <div
2051
- role="status"
2052
- aria-live="polite"
2053
- style={{
2054
- position: 'fixed',
2055
- top: 12,
2056
- left: '50%',
2057
- transform: 'translateX(-50%)',
2058
- zIndex: 10003,
2059
- display: 'flex',
2060
- alignItems: 'center',
2061
- gap: 10,
2062
- padding: '8px 14px',
2063
- background: bg,
2064
- color: fg,
2065
- border: `1px solid ${border}`,
2066
- borderRadius: 999,
2067
- boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
2068
- font: '500 13px/1.2 system-ui, -apple-system, sans-serif',
2069
- maxWidth: '80vw',
2070
- }}
2071
- >
2072
- <span>
2073
- {text}
2074
- {conflictNote}
2075
- </span>
2076
- </div>
2077
- );
2078
- }
2079
-
2080
- // ---------- App ----------
2081
-
2082
- function App() {
2083
- const [groups, setGroups] = useState([]);
2084
- const [project, setProject] = useState('Design');
2085
- const [tabs, setTabs] = useState([]);
2086
- const [activePath, setActivePath] = useState(null);
2087
- const [selected, setSelected] = useState(null);
2088
- const [wsConnected, setWsConnected] = useState(false);
2089
- // Phase 8 Task 7 — git lifecycle reload prompt. Server has already flushed
2090
- // every dirty Y.Doc to disk by the time this state populates, so accepting
2091
- // the reload is data-loss-safe (DDR-051 §3).
2092
- const [gitLifecycle, setGitLifecycle] = useState(null);
2093
- // Phase 9 Task 8 — hub-down offline mode banner. Driven by the 'sync:status'
2094
- // WS message the linked-mode sync runtime emits. null in solo mode.
2095
- const [syncStatus, setSyncStatus] = useState(null);
2096
- const [search, setSearch] = useState('');
2097
- const [systemData, setSystemData] = useState(null);
2098
- // Loaded once at boot from /_config — informs canvasUrl() so TSX iframes
2099
- // can pass the right ?designRel + ?tokens query to the canvas mount shell.
2100
- const [cfg, setCfg] = useState({ designRel: '.design' });
2101
- useEffect(() => {
2102
- let cancelled = false;
2103
- fetch('/_config')
2104
- .then((r) => r.json())
2105
- .then((data) => {
2106
- if (cancelled) return;
2107
- const designRel = (data.designRoot || '.design').replace(/^\/+|\/+$/g, '');
2108
- setCfg({
2109
- designRel,
2110
- tokensCssRel: data.tokensCssRel,
2111
- // Pass through designSystems so canvasUrl can resolve the right
2112
- // tokens/components paths per-DS. Top-level tokensCssRel is the
2113
- // legacy default; designSystems[0].tokensCssRel is the project's
2114
- // authoritative value (post DS-bootstrap).
2115
- designSystems: data.designSystems,
2116
- // T2 (9.1-A) — segregated canvas-content origin. canvasUrl() prepends
2117
- // it so iframes load cross-origin (hub-pushed JSX is then walled off
2118
- // from the main origin's /_api). Absent on older servers → relative
2119
- // URL fallback keeps same-origin behavior.
2120
- canvasOrigin: data.canvasOrigin,
2121
- });
2122
- })
2123
- .catch(() => {});
2124
- return () => {
2125
- cancelled = true;
2126
- };
2127
- }, []);
2128
- // Backfill the sync banner on mount from /_sync-status. The 'sync:status' WS
2129
- // broadcast is one-shot for the zero-syncable case (DDR-060 / 9.1-D), so a
2130
- // tab that connects after boot would otherwise miss it. {linked:false} (solo)
2131
- // leaves the banner null.
2132
- useEffect(() => {
2133
- let cancelled = false;
2134
- fetch('/_sync-status')
2135
- .then((r) => r.json())
2136
- .then((data) => {
2137
- if (cancelled || !data || data.linked === false) return;
2138
- setSyncStatus(data);
2139
- })
2140
- .catch(() => {});
2141
- return () => {
2142
- cancelled = true;
2143
- };
2144
- }, []);
2145
- const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
2146
- // Phase 6 — the in-iframe composer owns drafting; the shell no longer holds
2147
- // a `draft` state. Mutations route through postMessage → WS instead.
2148
- const [focusedCommentId, setFocusedCommentId] = useState(null);
2149
- const [commentsPanelOpen, setCommentsPanelOpen] = useState(false);
2150
- const [commentsFilter, setCommentsFilter] = useState('open'); // 'all' | 'open' | 'resolved'
2151
- const [theme, setTheme] = useState(readInitialTheme);
2152
- const [openMenu, setOpenMenu] = useState(null);
2153
- const [sidebarOpen, setSidebarOpen] = useState(() => readBoolStore(SIDEBAR_STORE, true));
2154
- const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
2155
- const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
2156
- const [helpOpen, setHelpOpen] = useState(false);
2157
- const [annotationsVisible, setAnnotationsVisible] = useState(true);
2158
- const wsRef = useRef(null);
2159
- const iframesRef = useRef(new Map());
2160
-
2161
- // Phase 5.1 — postMessage bridge from menubar dropdowns to the canvas iframe.
2162
- // The canvas-shell listens for these `dgn:*` messages and dispatches into the
2163
- // matching local provider (annotations visibility / both selection stores /
2164
- // tool mode). Mirrors the existing `force-clear` / `select-clear` channel.
2165
- const postToActiveCanvas = useCallback(
2166
- (payload) => {
2167
- const el = activePath ? iframesRef.current.get(activePath) : null;
2168
- if (!el || !el.contentWindow) return;
2169
- try {
2170
- el.contentWindow.postMessage(payload, '*');
2171
- } catch {}
2172
- },
2173
- [activePath]
2174
- );
2175
-
2176
- const toggleAnnotations = useCallback(() => {
2177
- setAnnotationsVisible((v) => {
2178
- const next = !v;
2179
- const el = activePath ? iframesRef.current.get(activePath) : null;
2180
- if (el && el.contentWindow) {
2181
- try {
2182
- el.contentWindow.postMessage({ dgn: 'view-annotations', visible: next }, '*');
2183
- } catch {}
2184
- }
2185
- return next;
2186
- });
2187
- }, [activePath]);
2188
-
2189
- // Sync theme to <html data-theme> + localStorage on every change.
2190
- useEffect(() => {
2191
- try {
2192
- document.documentElement.setAttribute('data-theme', theme);
2193
- localStorage.setItem(THEME_STORE, theme);
2194
- } catch {}
2195
- // System-review D9 — the canvas-shell chrome (workspace plane, floating
2196
- // toolbar, minimap, zoom HUD, halos) follows the Maude theme. Broadcast to
2197
- // EVERY open canvas iframe (not just activePath — several may be open); the
2198
- // iframe's canvas-shell sets `data-maude-theme` and re-themes its floating
2199
- // chrome via the --maude-chrome-* family. Artboards keep their DS theme.
2200
- // Mirrors the git-lifecycle broadcast-to-all loop below. On the initial
2201
- // mount run iframesRef is empty (no canvas open yet) — a freshly-loaded
2202
- // iframe instead gets the current theme from the `dgn:'loaded'` handler.
2203
- for (const el of iframesRef.current.values()) {
2204
- try {
2205
- el.contentWindow.postMessage({ dgn: 'theme', theme }, '*');
2206
- } catch {}
2207
- }
2208
- }, [theme]);
2209
-
2210
- // Persist sidebar / hidden-files / DS-body toggles. Mirror theme pattern.
2211
- useEffect(() => {
2212
- try {
2213
- localStorage.setItem(SIDEBAR_STORE, sidebarOpen ? '1' : '0');
2214
- } catch {}
2215
- }, [sidebarOpen]);
2216
- useEffect(() => {
2217
- try {
2218
- localStorage.setItem(SHOW_HIDDEN_STORE, showHidden ? '1' : '0');
2219
- } catch {}
2220
- }, [showHidden]);
2221
- useEffect(() => {
2222
- try {
2223
- localStorage.setItem(SECTIONS_STORE, JSON.stringify(sectionsExpanded));
2224
- } catch {}
2225
- }, [sectionsExpanded]);
2226
-
2227
- const toggleSection = useCallback((label, defaultOpen) => {
2228
- setSectionsExpanded((prev) => {
2229
- const cur = prev[label];
2230
- const isOpen = cur === undefined ? defaultOpen : cur;
2231
- return { ...prev, [label]: !isOpen };
2232
- });
2233
- }, []);
2234
-
2235
- const toggleTheme = useCallback(() => {
2236
- setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
2237
- }, []);
2238
-
2239
- // ----- Tree -----
2240
- const loadTree = useCallback(async () => {
2241
- try {
2242
- const r = await fetch('/_index-data');
2243
- const data = await r.json();
2244
- setProject(data.project || 'Design');
2245
- const built = data.groups.map((g) => ({
2246
- ...g,
2247
- tree: buildTree(g.paths, g.stripPrefix),
2248
- }));
2249
- setGroups(built);
2250
- } catch (e) {
2251
- console.error('failed to load tree', e);
2252
- }
2253
- }, []);
2254
-
2255
- useEffect(() => {
2256
- loadTree();
2257
- }, [loadTree]);
2258
-
2259
- // ----- System data (lazy) -----
2260
- // `dsName` scopes to a single design-system entry (DDR-048). The initial
2261
- // call is unscoped — server returns `availableDesignSystems[]` + a default
2262
- // — so the picker can render without a probe round-trip. Subsequent calls
2263
- // (e.g. picker change) pass the chosen DS name and we replace systemData
2264
- // wholesale (tokens + previews + ds metadata all shift together).
2265
- const loadSystemData = useCallback(async (dsName) => {
2266
- try {
2267
- const url = dsName ? `/_system-data?ds=${encodeURIComponent(dsName)}` : '/_system-data';
2268
- const r = await fetch(url);
2269
- if (!r.ok) {
2270
- console.error('failed to load system-data', r.status);
2271
- return;
2272
- }
2273
- const data = await r.json();
2274
- // If the initial unscoped fetch has a defaultDesignSystem but no `ds`
2275
- // attached (multi-DS project), kick off a scoped fetch so the visible
2276
- // tokens + previews match the default DS, not the union root scan.
2277
- if (!dsName && data?.defaultDesignSystem && !data.ds) {
2278
- setSystemData(data);
2279
- const r2 = await fetch(`/_system-data?ds=${encodeURIComponent(data.defaultDesignSystem)}`);
2280
- if (r2.ok) setSystemData(await r2.json());
2281
- return;
2282
- }
2283
- setSystemData(data);
2284
- } catch (e) {
2285
- console.error('failed to load system-data', e);
2286
- }
2287
- }, []);
2288
-
2289
- // ----- Comments — initial load of all files -----
2290
- const loadAllComments = useCallback(async () => {
2291
- try {
2292
- const r = await fetch('/_comments-all');
2293
- const data = await r.json();
2294
- setCommentsByFile(data || {});
2295
- } catch (e) {
2296
- console.error('failed to load comments', e);
2297
- }
2298
- }, []);
2299
-
2300
- useEffect(() => {
2301
- loadAllComments();
2302
- }, [loadAllComments]);
2303
-
2304
- // ----- WebSocket -----
2305
- useEffect(() => {
2306
- function connect() {
2307
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
2308
- const ws = new WebSocket(proto + '//' + location.host + '/_ws');
2309
- wsRef.current = ws;
2310
- ws.addEventListener('open', () => setWsConnected(true));
2311
- ws.addEventListener('close', () => {
2312
- setWsConnected(false);
2313
- setTimeout(connect, 1000);
2314
- });
2315
- ws.addEventListener('error', () => {});
2316
- ws.addEventListener('message', (e) => {
2317
- try {
2318
- const m = JSON.parse(e.data);
2319
- if (m.type === 'snapshot' && m.state) {
2320
- setSelected(m.state.selected);
2321
- } else if (m.type === 'selected') {
2322
- setSelected(m.selected);
2323
- } else if (m.type === 'comments' && typeof m.file === 'string') {
2324
- setCommentsByFile((prev) => ({ ...prev, [m.file]: m.comments || [] }));
2325
- } else if (m.type === 'ai-activity' && typeof m.file === 'string') {
2326
- // Phase 8 Task 4 — relay to every open iframe; each canvas's
2327
- // AiBanner filters by its own file path. Lightweight broadcast
2328
- // (one envelope per change, not per iframe count).
2329
- for (const el of iframesRef.current.values()) {
2330
- try {
2331
- el.contentWindow.postMessage(
2332
- { dgn: 'ai-activity', file: m.file, entry: m.entry },
2333
- '*'
2334
- );
2335
- } catch {}
2336
- }
2337
- } else if (m.type === 'sync:status' && m.payload) {
2338
- // Phase 9 Task 8 — hub connection state for the offline banner.
2339
- setSyncStatus(m.payload);
2340
- } else if (m.type === 'git-lifecycle' && m.payload) {
2341
- // Phase 8 Task 7 — branch switch / pull mid-session. Server has
2342
- // already flushed every dirty Y.Doc to JSON; just prompt the user.
2343
- // Single confirm covers all open iframes — reload reseeds them all.
2344
- setGitLifecycle(m.payload);
2345
- // Also relay to iframes so canvas-level "Reload?" UI (if any)
2346
- // can react. Outer banner is the primary prompt.
2347
- for (const el of iframesRef.current.values()) {
2348
- try {
2349
- el.contentWindow.postMessage({ dgn: 'git-lifecycle', payload: m.payload }, '*');
2350
- } catch {}
2351
- }
2352
- }
2353
- } catch {}
2354
- });
2355
- }
2356
- connect();
2357
- return () => wsRef.current && wsRef.current.close();
2358
- }, []);
2359
-
2360
- function wsSend(obj) {
2361
- const ws = wsRef.current;
2362
- try {
2363
- if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj));
2364
- } catch {}
2365
- }
2366
-
2367
- // ----- Tab management (single-canvas) -----
2368
- // Single-canvas model: opening a file REPLACES the active one (no tab strip).
2369
- // The `tabs` state stays as a 0-or-1 array so the rest of the plumbing
2370
- // (iframesRef, comments push, WS `tabs` message) doesn't need refactoring.
2371
- // ARTBOARDS slot in the menubar reads `tabs.length` and reports 0 or 1.
2372
- const openTab = useCallback((path) => {
2373
- setTabs((prev) => {
2374
- // Drop the previously-open iframe so we don't leak DOM nodes.
2375
- for (const t of prev) if (t.path !== path) iframesRef.current.delete(t.path);
2376
- return [{ path }];
2377
- });
2378
- setActivePath(path);
2379
- setFocusedCommentId(null);
2380
- }, []);
2381
-
2382
- const openSystem = useCallback(
2383
- (dsName) => {
2384
- // DsFolderRow passes the clicked DS name → scope the System view to it so
2385
- // each folder shows its own tokens + previews. The no-arg callers (menubar,
2386
- // keyboard reopen) only load default data on first open.
2387
- const ds = typeof dsName === 'string' ? dsName : undefined;
2388
- if (ds) loadSystemData(ds);
2389
- else if (!systemData) loadSystemData();
2390
- openTab(SYSTEM_TAB);
2391
- },
2392
- [systemData, loadSystemData, openTab]
2393
- );
2394
-
2395
- useEffect(() => {
2396
- wsSend({ type: 'tabs', tabs: tabs.map((t) => t.path).filter((p) => p !== SYSTEM_TAB) });
2397
- }, [tabs]);
2398
-
2399
- useEffect(() => {
2400
- if (activePath && activePath !== SYSTEM_TAB) wsSend({ type: 'active', file: activePath });
2401
- else if (activePath === SYSTEM_TAB) wsSend({ type: 'active', file: '' });
2402
- else wsSend({ type: 'active', file: '' });
2403
- }, [activePath]);
2404
-
2405
- const closeTab = useCallback(
2406
- (path) => {
2407
- setTabs((prev) => {
2408
- const idx = prev.findIndex((t) => t.path === path);
2409
- if (idx < 0) return prev;
2410
- const next = prev.filter((t) => t.path !== path);
2411
- if (path === activePath) {
2412
- if (next.length === 0) setActivePath(null);
2413
- else setActivePath(next[Math.max(0, idx - 1)].path);
2414
- }
2415
- return next;
2416
- });
2417
- iframesRef.current.delete(path);
2418
- },
2419
- [activePath]
2420
- );
2421
-
2422
- const reloadActive = useCallback(() => {
2423
- if (!activePath || activePath === SYSTEM_TAB) {
2424
- if (activePath === SYSTEM_TAB) loadSystemData();
2425
- return;
2426
- }
2427
- const el = iframesRef.current.get(activePath);
2428
- if (el) el.src = el.src;
2429
- }, [activePath, loadSystemData]);
2430
-
2431
- const reloadTree = useCallback(() => loadTree(), [loadTree]);
2432
-
2433
- const clearSelected = useCallback(() => {
2434
- wsSend({ type: 'clear-select' });
2435
- setSelected(null);
2436
- if (activePath && activePath !== SYSTEM_TAB) {
2437
- const el = iframesRef.current.get(activePath);
2438
- if (el && el.contentWindow) {
2439
- try {
2440
- el.contentWindow.postMessage({ dgn: 'force-clear' }, '*');
2441
- } catch {}
2442
- }
2443
- }
2444
- }, [activePath]);
2445
-
2446
- // ----- Push comments to iframe whenever they change for active file -----
2447
- useEffect(() => {
2448
- if (!activePath || activePath === SYSTEM_TAB) return;
2449
- const el = iframesRef.current.get(activePath);
2450
- if (!el || !el.contentWindow) return;
2451
- const list = commentsByFile[activePath] || [];
2452
- try {
2453
- el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*');
2454
- } catch {}
2455
- }, [activePath, commentsByFile]);
2456
-
2457
- // ----- Inbound messages from iframes -----
2458
- useEffect(() => {
2459
- function onMessage(e) {
2460
- // Cross-origin hardening (DDR-054): only accept dgn control messages from
2461
- // the canvas-content origin — the split origin when on, else our own origin
2462
- // for the same-origin iframe. Drops spoofed messages from any other window.
2463
- // The handlers below relay to inert stores (comments / selection — the
2464
- // "safe to sync" set), so the blast radius was small, but unchecked inbound
2465
- // postMessage is a confused-deputy seam the F1 hardening should close.
2466
- const expectedOrigin = cfg?.canvasOrigin || window.location.origin;
2467
- if (e.origin !== expectedOrigin) return;
2468
- const m = e.data;
2469
- if (!m || typeof m !== 'object' || !m.dgn) return;
2470
- if (m.dgn === 'tool-cursor') {
2471
- // Phase 24 — show the active canvas tool's cursor across the WHOLE app
2472
- // shell (sidebar, top bar, everything) so the custom cursor is visible
2473
- // everywhere in maude, not just inside the canvas iframe. The canvas
2474
- // sends only a tool TOKEN; we resolve it to a cursor string from our own
2475
- // trusted map (resolveToolCursor) and apply THAT — never a raw
2476
- // canvas-supplied value. A malicious synced canvas (DDR-054) can thus
2477
- // only pick a known, always-visible glyph; it cannot inject an
2478
- // invisible/displaced SVG cursor as a clickjacking aid over the un-CSP'd
2479
- // shell (phase-24 ethical-hacker Finding 2; DDR-067).
2480
- const cursor = resolveToolCursor(m.tool);
2481
- if (cursor) {
2482
- document.body.style.cursor = cursor;
2483
- let el = document.getElementById('dc-app-cursor');
2484
- if (!el) {
2485
- el = document.createElement('style');
2486
- el.id = 'dc-app-cursor';
2487
- document.head.appendChild(el);
2488
- }
2489
- el.textContent = `* { cursor: ${cursor} !important; }`;
2490
- }
2491
- return;
2492
- }
2493
- if (m.dgn === 'select' && m.selection) {
2494
- wsSend({ type: 'select', selection: m.selection });
2495
- setSelected(m.selection);
2496
- } else if (m.dgn === 'select-set') {
2497
- // Canvas multi-select. Payload shape:
2498
- // null → empty selection
2499
- // Selection → length-1 (back-compat with legacy single-element shape)
2500
- // Selection[] → N > 1
2501
- // For shell purposes we track the focused entry (head of array, or
2502
- // the bare object) — comments + halo only act on one element at a
2503
- // time today. Multi-target editing is an explicit Phase-4.1 non-goal.
2504
- const payload = m.selection;
2505
- if (payload == null) {
2506
- wsSend({ type: 'clear-select' });
2507
- setSelected(null);
2508
- } else if (Array.isArray(payload)) {
2509
- const head = payload[0] ?? null;
2510
- if (head) wsSend({ type: 'select', selection: head });
2511
- setSelected(head);
2512
- } else {
2513
- wsSend({ type: 'select', selection: payload });
2514
- setSelected(payload);
2515
- }
2516
- } else if (m.dgn === 'clear-select') {
2517
- wsSend({ type: 'clear-select' });
2518
- setSelected(null);
2519
- } else if (m.dgn === 'comment-compose' && m.selection) {
2520
- // Phase 6 — the iframe overlay owns the composer surface now. The
2521
- // shell just mirrors `selected` so the StatusBar / sidebar still
2522
- // reflect the target, and skips the legacy `startDraftFor` path that
2523
- // opened the shell-side composer. Legacy `.html` mocks (no
2524
- // canvas-shell mount) fall through to the same path; they lose the
2525
- // shell composer in this phase. Acceptable per Phase 6 scope.
2526
- setSelected(m.selection);
2527
- } else if (m.dgn === 'comment-submit' && m.payload && typeof m.payload.text === 'string') {
2528
- // Phase 6 — iframe overlay finished composing. Relay through the
2529
- // existing WS `comments-add` channel; server-side persistence +
2530
- // broadcast back are identical to the legacy shell-composer flow.
2531
- const p = m.payload;
2532
- const txt = String(p.text).trim();
2533
- if (txt) {
2534
- wsSend({
2535
- type: 'comments-add',
2536
- payload: {
2537
- file: p.file,
2538
- selector: p.selector,
2539
- dom_path: p.dom_path,
2540
- tag: p.tag,
2541
- classes: p.classes,
2542
- bounds: p.bounds,
2543
- html_excerpt: p.html_excerpt,
2544
- text: txt,
2545
- },
2546
- });
2547
- }
2548
- } else if (m.dgn === 'comment-patch' && m.id && m.patch && typeof m.patch === 'object') {
2549
- // Phase 6 — thread popover routes resolve / reopen through here.
2550
- wsSend({ type: 'comments-patch', id: m.id, patch: m.patch });
2551
- } else if (m.dgn === 'comment-delete' && m.id) {
2552
- wsSend({ type: 'comments-delete', id: m.id });
2553
- setFocusedCommentId((prev) => (prev === m.id ? null : prev));
2554
- } else if (m.dgn === 'comment-click' && m.id) {
2555
- setFocusedCommentId(m.id);
2556
- } else if (m.dgn === 'loaded' && m.file) {
2557
- // iframe finished loading — push current comments + carry over focused pin if any
2558
- const list = commentsByFile[m.file] || [];
2559
- const el = [...iframesRef.current.entries()].find(([k]) => k === m.file)?.[1];
2560
- if (el && el.contentWindow) {
2561
- try {
2562
- el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*');
2563
- } catch {}
2564
- // System-review D9 — seed the just-loaded canvas with the current
2565
- // chrome theme so a canvas opened AFTER a theme toggle starts
2566
- // correct (no flash from the dark default).
2567
- try {
2568
- el.contentWindow.postMessage({ dgn: 'theme', theme }, '*');
2569
- } catch {}
2570
- if (focusedCommentId && list.some((c) => c.id === focusedCommentId)) {
2571
- try {
2572
- el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*');
2573
- } catch {}
2574
- }
2575
- }
2576
- } else if (m.dgn === 'export-request' && m.id && m.payload) {
2577
- // The export dialog renders inside the canvas iframe (canvas origin),
2578
- // but /_api/export is a privileged MAIN-origin endpoint deliberately
2579
- // kept off the canvas allowlist (DDR-060). A direct in-iframe fetch
2580
- // therefore 403s ("Forbidden (canvas origin)"). Bridge it: run the
2581
- // export here on the trusted main origin, stream the download, and
2582
- // report status back to the iframe. Origin is already validated
2583
- // (e.origin === expectedOrigin) above, so only the real canvas iframe
2584
- // can ask — this is NOT a generic fetch proxy.
2585
- void runBridgedExport(e.source, m.id, m.payload);
2586
- } else if (m.dgn === 'export-history-request' && m.id) {
2587
- // Same bridge for the dialog's Recent tab (/_api/export-history is
2588
- // also main-origin-only).
2589
- void runBridgedHistory(e.source, m.id);
2590
- }
2591
- }
2592
- // Reply target for the export bridge: the canvas iframe's own origin.
2593
- const replyOrigin = cfg?.canvasOrigin || window.location.origin;
2594
- async function runBridgedExport(source, id, payload) {
2595
- const reply = (msg) => {
2596
- try {
2597
- if (source) source.postMessage({ dgn: 'export-result', id, ...msg }, replyOrigin);
2598
- } catch {}
2599
- };
2600
- try {
2601
- const r = await fetch('/_api/export', {
2602
- method: 'POST',
2603
- headers: { 'content-type': 'application/json' },
2604
- body: JSON.stringify(payload),
2605
- });
2606
- if (!r.ok) {
2607
- reply({ ok: false, error: (await r.text()) || String(r.status) });
2608
- return;
2609
- }
2610
- const disp = r.headers.get('Content-Disposition') || '';
2611
- const fn = /filename="([^"]+)"/.exec(disp);
2612
- const filename = (fn && fn[1]) || 'export';
2613
- const blob = await r.blob();
2614
- const url = URL.createObjectURL(blob);
2615
- const a = document.createElement('a');
2616
- a.href = url;
2617
- a.download = filename;
2618
- document.body.appendChild(a);
2619
- a.click();
2620
- a.remove();
2621
- URL.revokeObjectURL(url);
2622
- reply({ ok: true, filename });
2623
- } catch (err) {
2624
- reply({ ok: false, error: err && err.message ? err.message : String(err) });
2625
- }
2626
- }
2627
- async function runBridgedHistory(source, id) {
2628
- let history = [];
2629
- try {
2630
- const r = await fetch('/_api/export-history');
2631
- if (r.ok) {
2632
- const data = await r.json();
2633
- if (Array.isArray(data.history)) history = data.history;
2634
- }
2635
- } catch {
2636
- /* best-effort — empty list */
2637
- }
2638
- try {
2639
- if (source) source.postMessage({ dgn: 'export-history-result', id, history }, replyOrigin);
2640
- } catch {}
2641
- }
2642
- window.addEventListener('message', onMessage);
2643
- return () => window.removeEventListener('message', onMessage);
2644
- }, [commentsByFile, focusedCommentId, cfg, theme]);
2645
-
2646
- // Tell the active canvas iframe to drop any persistent selection (canvas
2647
- // SelectionSet) — used when the comment composer closes via submit /
2648
- // cancel / Esc. canvas-shell listens for `force-clear` on the window
2649
- // message channel and calls selSet.clear().
2650
- const clearActiveCanvasSelection = useCallback(() => {
2651
- if (!activePath || activePath === SYSTEM_TAB) return;
2652
- const el = iframesRef.current.get(activePath);
2653
- if (el && el.contentWindow) {
2654
- try {
2655
- el.contentWindow.postMessage({ dgn: 'force-clear' }, '*');
2656
- } catch {}
2657
- }
2658
- }, [activePath]);
2659
-
2660
- const resolveComment = useCallback((id) => {
2661
- wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } });
2662
- }, []);
2663
- const reopenComment = useCallback((id) => {
2664
- wsSend({ type: 'comments-patch', id, patch: { status: 'open' } });
2665
- }, []);
2666
- const deleteComment = useCallback((id) => {
2667
- wsSend({ type: 'comments-delete', id });
2668
- setFocusedCommentId((prev) => (prev === id ? null : prev));
2669
- }, []);
2670
-
2671
- // Jump from right-sidebar list to a comment: open file tab if needed, focus pin.
2672
- // The iframe may be freshly mounted; the loaded handler also re-sends focus if focusedCommentId matches.
2673
- const jumpToComment = useCallback(
2674
- (file, id) => {
2675
- if (file && file !== activePath) {
2676
- setTabs((prev) => (prev.find((t) => t.path === file) ? prev : [...prev, { path: file }]));
2677
- setActivePath(file);
2678
- }
2679
- if (id == null) {
2680
- setFocusedCommentId(null);
2681
- return;
2682
- }
2683
- setFocusedCommentId(id);
2684
- // Try sending focus immediately (existing iframe) and again after a short delay (newly opened tab).
2685
- const send = () => {
2686
- const el = iframesRef.current.get(file);
2687
- if (el && el.contentWindow) {
2688
- try {
2689
- el.contentWindow.postMessage({ dgn: 'comment-focus', id }, '*');
2690
- } catch {}
2691
- }
2692
- };
2693
- send();
2694
- setTimeout(send, 200);
2695
- },
2696
- [activePath]
2697
- );
2698
-
2699
- // ----- Keyboard shortcuts (no Cmd+W — let browser close the tab) -----
2700
- useEffect(() => {
2701
- function onKey(e) {
2702
- const meta = e.metaKey || e.ctrlKey;
2703
- const inEditable =
2704
- ['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName) ||
2705
- document.activeElement?.isContentEditable;
2706
- // Phase 4.1: shell-side letter shortcuts (H/T/S) must not double-fire
2707
- // inside a focused canvas iframe — the canvas input router owns those
2708
- // letters as tool-mode keys (V/H/C). Cmd-modified shortcuts (⌘R, ⌘⇧M,
2709
- // ⌘F) still fire regardless of focus, mirroring browser convention.
2710
- const inCanvasIframe = document.activeElement?.tagName === 'IFRAME';
2711
-
2712
- // Cmd+R — reload active iframe (override browser reload)
2713
- if (meta && (e.key === 'r' || e.key === 'R')) {
2714
- e.preventDefault();
2715
- reloadActive();
2716
- return;
2717
- }
2718
- // Cmd+Shift+M / Ctrl+Shift+M — toggle right "Comments" panel
2719
- if (meta && e.shiftKey && (e.key === 'm' || e.key === 'M')) {
2720
- e.preventDefault();
2721
- setCommentsPanelOpen((v) => !v);
2722
- return;
2723
- }
2724
- // Cmd+C / Ctrl+C — Phase 4.1 removed the shell-side comment-drop chord.
2725
- // Canvas comment-drop is the `C` tool letter (press C in the canvas,
2726
- // then click the element) or right-click "Add comment". Cmd+C now
2727
- // reverts to native browser copy.
2728
- if (meta && !e.shiftKey && !e.altKey && (e.key === 'c' || e.key === 'C')) {
2729
- if (
2730
- selected &&
2731
- selected.selector &&
2732
- activePath &&
2733
- activePath !== SYSTEM_TAB &&
2734
- !inEditable &&
2735
- console &&
2736
- console.warn
2737
- ) {
2738
- console.warn(
2739
- 'Cmd+C comment-drop deprecated — press C inside the canvas to enter Comment tool, then click the element.'
2740
- );
2741
- }
2742
- // Fall through to native copy.
2743
- }
2744
- if (inEditable) return;
2745
- // / — focus search (or ⌘F per CV-08 placeholder hint)
2746
- if (e.key === '/') {
2747
- e.preventDefault();
2748
- const inp = document.querySelector('.tree-panel-search input');
2749
- if (inp) inp.focus();
2750
- return;
2751
- }
2752
- if (meta && (e.key === 'f' || e.key === 'F')) {
2753
- e.preventDefault();
2754
- if (!sidebarOpen) setSidebarOpen(true);
2755
- setTimeout(() => {
2756
- const inp = document.querySelector('.tree-panel-search input');
2757
- if (inp) inp.focus();
2758
- }, 0);
2759
- return;
2760
- }
2761
- // T / H / S are bare-letter shell shortcuts. When focus is inside a
2762
- // canvas iframe, the canvas input router claims V/H/C — bail out
2763
- // here so the canvas owns the key and the sidebar/system view don't
2764
- // double-fire on focused-canvas keypresses.
2765
- if (inCanvasIframe) {
2766
- // Esc still bubbles below (composer / focused-pin clear).
2767
- if (e.key !== 'Escape') return;
2768
- }
2769
- // T — toggle Project Tree (sidebar)
2770
- if (e.key === 't' || e.key === 'T') {
2771
- if (e.shiftKey || meta) return;
2772
- e.preventDefault();
2773
- setSidebarOpen((v) => !v);
2774
- return;
2775
- }
2776
- // H — toggle show-hidden (sidecars + project/runtime orphans)
2777
- if (e.key === 'h' || e.key === 'H') {
2778
- if (e.shiftKey || meta) return;
2779
- e.preventDefault();
2780
- setShowHidden((v) => !v);
2781
- return;
2782
- }
2783
- // S — toggle Design system view
2784
- if ((e.key === 's' || e.key === 'S') && !meta && !e.shiftKey) {
2785
- e.preventDefault();
2786
- if (activePath === SYSTEM_TAB) {
2787
- closeTab(SYSTEM_TAB);
2788
- } else {
2789
- openSystem();
2790
- }
2791
- return;
2792
- }
2793
- // ? or F1 — open Help modal
2794
- if (e.key === '?' || e.key === 'F1') {
2795
- e.preventDefault();
2796
- setHelpOpen(true);
2797
- return;
2798
- }
2799
- // Esc — clear focused pin. The in-place composer (Phase 6) and thread
2800
- // popover handle their own Esc inside the iframe.
2801
- if (e.key === 'Escape') {
2802
- if (focusedCommentId) {
2803
- setFocusedCommentId(null);
2804
- return;
2805
- }
2806
- }
2807
- }
2808
- window.addEventListener('keydown', onKey);
2809
- return () => window.removeEventListener('keydown', onKey);
2810
- }, [
2811
- reloadActive,
2812
- selected,
2813
- activePath,
2814
- focusedCommentId,
2815
- sidebarOpen,
2816
- openSystem,
2817
- closeTab,
2818
- clearActiveCanvasSelection,
2819
- ]);
2820
-
2821
- const registerIframe = useCallback((path, el) => {
2822
- if (el) iframesRef.current.set(path, el);
2823
- }, []);
2824
-
2825
- const activeFileComments =
2826
- activePath && activePath !== SYSTEM_TAB ? commentsByFile[activePath] || [] : [];
2827
- const totalOpen = totalCounts(commentsByFile).open;
2828
-
2829
- // Suppress the native browser context menu across the shell — the canvas
2830
- // input-router already handles right-click inside the canvas host, but
2831
- // sidebar / menubar / statusbar / floating chrome would otherwise leak the
2832
- // native menu on top of our `.dc-context-menu` (or alone, outside canvas).
2833
- // Editable fields (search box, future text inputs) keep the native menu so
2834
- // copy/paste still works.
2835
- const onShellContextMenu = useCallback((e) => {
2836
- const t = e.target;
2837
- if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
2838
- return;
2839
- }
2840
- e.preventDefault();
2841
- }, []);
2842
-
2843
- return (
2844
- <div
2845
- className={
2846
- 'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')
2847
- }
2848
- onContextMenu={onShellContextMenu}
2849
- >
2850
- <SyncBanner status={syncStatus} />
2851
- {gitLifecycle && (
2852
- <div
2853
- role="status"
2854
- aria-live="polite"
2855
- style={{
2856
- position: 'fixed',
2857
- top: 12,
2858
- left: '50%',
2859
- transform: 'translateX(-50%)',
2860
- zIndex: 10002,
2861
- display: 'flex',
2862
- alignItems: 'center',
2863
- gap: 12,
2864
- padding: '8px 14px',
2865
- background: '#dbeafe',
2866
- color: '#1e40af',
2867
- border: '1px solid #93c5fd',
2868
- borderRadius: 999,
2869
- boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
2870
- font: '500 13px/1.2 system-ui, -apple-system, sans-serif',
2871
- }}
2872
- >
2873
- <span>Repo state changed — reload to sync?</span>
2874
- <button
2875
- type="button"
2876
- onClick={() => {
2877
- try {
2878
- window.location.reload();
2879
- } catch {}
2880
- }}
2881
- style={{
2882
- padding: '3px 10px',
2883
- background: '#2563eb',
2884
- color: '#fff',
2885
- border: 'none',
2886
- borderRadius: 4,
2887
- font: '500 11px/1.2 system-ui',
2888
- cursor: 'pointer',
2889
- }}
2890
- >
2891
- Reload
2892
- </button>
2893
- <button
2894
- type="button"
2895
- onClick={() => setGitLifecycle(null)}
2896
- style={{
2897
- padding: '3px 10px',
2898
- background: 'transparent',
2899
- color: '#1e40af',
2900
- border: '1px solid #93c5fd',
2901
- borderRadius: 4,
2902
- font: '500 11px/1.2 system-ui',
2903
- cursor: 'pointer',
2904
- }}
2905
- >
2906
- Dismiss
2907
- </button>
2908
- </div>
2909
- )}
2910
- <Sidebar
2911
- groups={groups}
2912
- activePath={activePath}
2913
- activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
2914
- onOpen={openTab}
2915
- onOpenSystem={openSystem}
2916
- wsConnected={wsConnected}
2917
- search={search}
2918
- setSearch={setSearch}
2919
- commentsByFile={commentsByFile}
2920
- showHidden={showHidden}
2921
- sectionsExpanded={sectionsExpanded}
2922
- onToggleSection={toggleSection}
2923
- />
2924
- <div className="main">
2925
- <Menubar
2926
- activePath={activePath}
2927
- project={project}
2928
- tabsCount={tabs.length}
2929
- openMenu={openMenu}
2930
- setOpenMenu={setOpenMenu}
2931
- commentsPanelOpen={commentsPanelOpen}
2932
- onToggleComments={() => setCommentsPanelOpen((v) => !v)}
2933
- onOpenSystem={openSystem}
2934
- sidebarOpen={sidebarOpen}
2935
- onToggleSidebar={() => setSidebarOpen((v) => !v)}
2936
- showHidden={showHidden}
2937
- onToggleShowHidden={() => setShowHidden((v) => !v)}
2938
- onOpenHelp={() => setHelpOpen(true)}
2939
- annotationsVisible={annotationsVisible}
2940
- onToggleAnnotations={toggleAnnotations}
2941
- postToActiveCanvas={postToActiveCanvas}
2942
- />
2943
- <Viewport
2944
- tabs={tabs}
2945
- activePath={activePath}
2946
- registerIframe={registerIframe}
2947
- systemData={systemData}
2948
- onOpenFromSystem={openTab}
2949
- onSelectDs={loadSystemData}
2950
- project={project}
2951
- cfg={cfg}
2952
- />
2953
- {activePath && activePath !== SYSTEM_TAB && (
2954
- <CommentBar activePath={activePath} comments={activeFileComments} />
2955
- )}
2956
- <StatusBar
2957
- activePath={activePath}
2958
- selected={selected}
2959
- wsConnected={wsConnected}
2960
- openCount={totalOpen}
2961
- theme={theme}
2962
- onToggleTheme={toggleTheme}
2963
- onClearSelected={clearSelected}
2964
- syncStatus={syncStatus}
2965
- />
2966
- </div>
2967
- {commentsPanelOpen && (
2968
- <CommentsPanel
2969
- commentsByFile={commentsByFile}
2970
- filter={commentsFilter}
2971
- setFilter={setCommentsFilter}
2972
- activePath={activePath}
2973
- focusedId={focusedCommentId}
2974
- onJump={jumpToComment}
2975
- onResolve={resolveComment}
2976
- onReopen={reopenComment}
2977
- onDelete={deleteComment}
2978
- />
2979
- )}
2980
- <HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
2981
- </div>
2982
- );
2983
- }
2984
-
2985
- createRoot(document.getElementById('root')).render(<App />);