@1agh/maude 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +166 -0
  3. package/cli/bin/maude.exe +15 -0
  4. package/cli/bin/maude.mjs +45 -0
  5. package/cli/bin/mdcc.exe +10 -0
  6. package/cli/bin/mdcc.mjs +7 -0
  7. package/cli/cli-wrapper.cjs +67 -0
  8. package/cli/commands/config.mjs +94 -0
  9. package/cli/commands/design.mjs +386 -0
  10. package/cli/commands/help.mjs +57 -0
  11. package/cli/commands/init.mjs +178 -0
  12. package/cli/commands/version.mjs +7 -0
  13. package/cli/install.cjs +113 -0
  14. package/cli/lib/argv.mjs +37 -0
  15. package/cli/lib/argv.test.mjs +46 -0
  16. package/cli/lib/copy-tree.mjs +78 -0
  17. package/package.json +94 -0
  18. package/plugins/design/dev-server/annotations-context-toolbar.tsx +397 -0
  19. package/plugins/design/dev-server/annotations-layer.tsx +1717 -0
  20. package/plugins/design/dev-server/api.ts +674 -0
  21. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +50 -0
  22. package/plugins/design/dev-server/bin/bootstrap-check.sh +83 -0
  23. package/plugins/design/dev-server/bin/canvas-edit.sh +48 -0
  24. package/plugins/design/dev-server/bin/handoff.sh +27 -0
  25. package/plugins/design/dev-server/bin/screenshot.sh +232 -0
  26. package/plugins/design/dev-server/bin/server-up.sh +135 -0
  27. package/plugins/design/dev-server/bin/slug.sh +22 -0
  28. package/plugins/design/dev-server/bin/smoke.sh +272 -0
  29. package/plugins/design/dev-server/build.ts +267 -0
  30. package/plugins/design/dev-server/canvas-build.ts +219 -0
  31. package/plugins/design/dev-server/canvas-edit.ts +388 -0
  32. package/plugins/design/dev-server/canvas-header.ts +165 -0
  33. package/plugins/design/dev-server/canvas-icons.tsx +131 -0
  34. package/plugins/design/dev-server/canvas-lib-inline.ts +260 -0
  35. package/plugins/design/dev-server/canvas-lib-resolver.ts +85 -0
  36. package/plugins/design/dev-server/canvas-lib.tsx +1995 -0
  37. package/plugins/design/dev-server/canvas-meta.schema.json +181 -0
  38. package/plugins/design/dev-server/canvas-pipeline.ts +270 -0
  39. package/plugins/design/dev-server/canvas-shell.tsx +813 -0
  40. package/plugins/design/dev-server/client/app.jsx +2027 -0
  41. package/plugins/design/dev-server/client/hmr.mjs +85 -0
  42. package/plugins/design/dev-server/client/iframe-lazy.mjs +121 -0
  43. package/plugins/design/dev-server/client/index.html +15 -0
  44. package/plugins/design/dev-server/client/styles/0-reset.css +18 -0
  45. package/plugins/design/dev-server/client/styles/1-tokens.css +297 -0
  46. package/plugins/design/dev-server/client/styles/2-layout.css +35 -0
  47. package/plugins/design/dev-server/client/styles/3-shell.css +906 -0
  48. package/plugins/design/dev-server/client/styles/4-components.css +1268 -0
  49. package/plugins/design/dev-server/client/styles/5-utilities.css +4 -0
  50. package/plugins/design/dev-server/client/styles/_index.css +24 -0
  51. package/plugins/design/dev-server/client/styles.css +1419 -0
  52. package/plugins/design/dev-server/config.schema.json +147 -0
  53. package/plugins/design/dev-server/context-menu.tsx +343 -0
  54. package/plugins/design/dev-server/context.ts +173 -0
  55. package/plugins/design/dev-server/dist/client.bundle.js +20323 -0
  56. package/plugins/design/dev-server/dist/styles.css +2875 -0
  57. package/plugins/design/dev-server/examples/README.md +9 -0
  58. package/plugins/design/dev-server/examples/perf-100-artboards.tsx +113 -0
  59. package/plugins/design/dev-server/fs-watch.ts +63 -0
  60. package/plugins/design/dev-server/handoff.ts +721 -0
  61. package/plugins/design/dev-server/history.ts +125 -0
  62. package/plugins/design/dev-server/hmr-broadcast.ts +114 -0
  63. package/plugins/design/dev-server/http.ts +413 -0
  64. package/plugins/design/dev-server/input-router.tsx +485 -0
  65. package/plugins/design/dev-server/inspect.ts +365 -0
  66. package/plugins/design/dev-server/locator.ts +159 -0
  67. package/plugins/design/dev-server/mem.ts +97 -0
  68. package/plugins/design/dev-server/runtime-bundle.ts +235 -0
  69. package/plugins/design/dev-server/server.mjs +1246 -0
  70. package/plugins/design/dev-server/server.ts +131 -0
  71. package/plugins/design/dev-server/test/_helpers.ts +81 -0
  72. package/plugins/design/dev-server/test/active-state.test.ts +145 -0
  73. package/plugins/design/dev-server/test/annotations-api.test.ts +146 -0
  74. package/plugins/design/dev-server/test/annotations-layer.test.ts +419 -0
  75. package/plugins/design/dev-server/test/binary-smoke.test.ts +47 -0
  76. package/plugins/design/dev-server/test/bundle-smoke.test.ts +29 -0
  77. package/plugins/design/dev-server/test/canvas-build.test.ts +78 -0
  78. package/plugins/design/dev-server/test/canvas-edit.test.ts +139 -0
  79. package/plugins/design/dev-server/test/canvas-header.test.ts +127 -0
  80. package/plugins/design/dev-server/test/canvas-lib-inline.test.ts +146 -0
  81. package/plugins/design/dev-server/test/canvas-lib-resolver.test.ts +112 -0
  82. package/plugins/design/dev-server/test/canvas-meta-api.test.ts +236 -0
  83. package/plugins/design/dev-server/test/canvas-pipeline.test.ts +180 -0
  84. package/plugins/design/dev-server/test/canvas-route.test.ts +176 -0
  85. package/plugins/design/dev-server/test/fs-watch.test.ts +41 -0
  86. package/plugins/design/dev-server/test/handoff-static-frames.test.ts +215 -0
  87. package/plugins/design/dev-server/test/handoff.test.ts +281 -0
  88. package/plugins/design/dev-server/test/history-rollback.test.ts +62 -0
  89. package/plugins/design/dev-server/test/hmr-broadcast.test.ts +108 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +316 -0
  91. package/plugins/design/dev-server/test/locator.test.ts +214 -0
  92. package/plugins/design/dev-server/test/perf-harness.ts +193 -0
  93. package/plugins/design/dev-server/test/phase-3.6-smoke.test.ts +77 -0
  94. package/plugins/design/dev-server/test/runtime-bundle.test.ts +69 -0
  95. package/plugins/design/dev-server/test/server-lifecycle.test.ts +28 -0
  96. package/plugins/design/dev-server/test/tool-palette.test.tsx +55 -0
  97. package/plugins/design/dev-server/test/use-annotation-selection.test.tsx +77 -0
  98. package/plugins/design/dev-server/test/use-artboard-drag.test.ts +325 -0
  99. package/plugins/design/dev-server/test/use-selection-set.test.tsx +166 -0
  100. package/plugins/design/dev-server/test/use-snap-guides.test.ts +190 -0
  101. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +93 -0
  102. package/plugins/design/dev-server/test/ws-handshake.test.ts +33 -0
  103. package/plugins/design/dev-server/tool-palette.tsx +278 -0
  104. package/plugins/design/dev-server/tsconfig.json +26 -0
  105. package/plugins/design/dev-server/use-annotation-selection.tsx +92 -0
  106. package/plugins/design/dev-server/use-annotations-visibility.tsx +43 -0
  107. package/plugins/design/dev-server/use-artboard-drag.tsx +445 -0
  108. package/plugins/design/dev-server/use-selection-set.tsx +224 -0
  109. package/plugins/design/dev-server/use-snap-guides.tsx +215 -0
  110. package/plugins/design/dev-server/use-tool-mode.tsx +114 -0
  111. package/plugins/design/dev-server/ws.ts +90 -0
  112. package/plugins/design/templates/_shell.html +177 -0
  113. package/plugins/design/templates/canvas.tsx.template +54 -0
  114. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +277 -0
  115. package/plugins/design/templates/design-system-inspiration/_README.md +71 -0
  116. package/plugins/design/templates/design-system-inspiration/audience-consumer/components-banner.html +68 -0
  117. package/plugins/design/templates/design-system-inspiration/audience-consumer/components-empty-state-generous.html +39 -0
  118. package/plugins/design/templates/design-system-inspiration/audience-consumer/components-feature-grid.html +62 -0
  119. package/plugins/design/templates/design-system-inspiration/audience-consumer/components-marketing-card.html +63 -0
  120. package/plugins/design/templates/design-system-inspiration/audience-consumer/components-testimonial.html +80 -0
  121. package/plugins/design/templates/design-system-inspiration/audience-developer/components-code-block.html +71 -0
  122. package/plugins/design/templates/design-system-inspiration/audience-developer/components-diff-view.html +65 -0
  123. package/plugins/design/templates/design-system-inspiration/audience-developer/components-log-stream.html +62 -0
  124. package/plugins/design/templates/design-system-inspiration/audience-developer/components-monospace-table.html +57 -0
  125. package/plugins/design/templates/design-system-inspiration/audience-developer/components-terminal-pane.html +67 -0
  126. package/plugins/design/templates/design-system-inspiration/audience-developer/type-mono.html +57 -0
  127. package/plugins/design/templates/design-system-inspiration/audience-pro/colors-presence.html +74 -0
  128. package/plugins/design/templates/design-system-inspiration/audience-pro/components-command-palette.html +90 -0
  129. package/plugins/design/templates/design-system-inspiration/audience-pro/components-keyboard.html +51 -0
  130. package/plugins/design/templates/design-system-inspiration/audience-pro/components-list.html +89 -0
  131. package/plugins/design/templates/design-system-inspiration/audience-pro/components-shortcuts-overlay.html +85 -0
  132. package/plugins/design/templates/design-system-inspiration/audience-pro/components-toast-menu.html +80 -0
  133. package/plugins/design/templates/design-system-inspiration/core/INDEX.md.tpl +25 -0
  134. package/plugins/design/templates/design-system-inspiration/core/README.orchestration.md.tpl +71 -0
  135. package/plugins/design/templates/design-system-inspiration/core/README.philosophy.md.tpl +77 -0
  136. package/plugins/design/templates/design-system-inspiration/core/SKILL.md.tpl +50 -0
  137. package/plugins/design/templates/design-system-inspiration/core/colors_and_type.css.tpl +111 -0
  138. package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +28 -0
  139. package/plugins/design/templates/design-system-inspiration/core/preview/_layout.css +113 -0
  140. package/plugins/design/templates/design-system-inspiration/core/preview/colors-accent.html +48 -0
  141. package/plugins/design/templates/design-system-inspiration/core/preview/colors-surfaces.html +49 -0
  142. package/plugins/design/templates/design-system-inspiration/core/preview/colors-text.html +52 -0
  143. package/plugins/design/templates/design-system-inspiration/core/preview/components-buttons.html +83 -0
  144. package/plugins/design/templates/design-system-inspiration/core/preview/components-cards.html +79 -0
  145. package/plugins/design/templates/design-system-inspiration/core/preview/components-inputs.html +82 -0
  146. package/plugins/design/templates/design-system-inspiration/core/preview/motion.html +66 -0
  147. package/plugins/design/templates/design-system-inspiration/core/preview/spacing-scale.html +53 -0
  148. package/plugins/design/templates/design-system-inspiration/core/preview/type-scale.html +62 -0
  149. package/plugins/design/templates/design-system-inspiration/foundations/borders.html +39 -0
  150. package/plugins/design/templates/design-system-inspiration/foundations/elevation.html +46 -0
  151. package/plugins/design/templates/design-system-inspiration/foundations/focus.html +48 -0
  152. package/plugins/design/templates/design-system-inspiration/foundations/grid.html +51 -0
  153. package/plugins/design/templates/design-system-inspiration/foundations/iconography.html +73 -0
  154. package/plugins/design/templates/design-system-inspiration/foundations/opacity.html +45 -0
  155. package/plugins/design/templates/design-system-inspiration/foundations/radii.html +40 -0
  156. package/plugins/design/templates/design-system-inspiration/foundations/selection.html +45 -0
  157. package/plugins/design/templates/design-system-inspiration/meta/accessibility.html +62 -0
  158. package/plugins/design/templates/design-system-inspiration/meta/i18n.html +73 -0
  159. package/plugins/design/templates/design-system-inspiration/meta/presence-multiplayer.html +71 -0
  160. package/plugins/design/templates/design-system-inspiration/meta/tokens-index.html +80 -0
  161. package/plugins/design/templates/design-system-inspiration/patterns/patterns-auth.html +78 -0
  162. package/plugins/design/templates/design-system-inspiration/patterns/patterns-data-density.html +61 -0
  163. package/plugins/design/templates/design-system-inspiration/patterns/patterns-error-pages.html +70 -0
  164. package/plugins/design/templates/design-system-inspiration/patterns/patterns-form-layouts.html +70 -0
  165. package/plugins/design/templates/design-system-inspiration/patterns/patterns-onboarding.html +71 -0
  166. package/plugins/design/templates/design-system-inspiration/patterns/patterns-pricing.html +83 -0
  167. package/plugins/design/templates/design-system-inspiration/platform-desktop/components-resize-panels.html +63 -0
  168. package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-index.html +55 -0
  169. package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-showcase.html +302 -0
  170. package/plugins/design/templates/design-system-inspiration/platform-mobile/components-bottom-sheet.html +63 -0
  171. package/plugins/design/templates/design-system-inspiration/platform-mobile/components-pull-to-refresh.html +74 -0
  172. package/plugins/design/templates/design-system-inspiration/platform-mobile/components-segmented-control.html +51 -0
  173. package/plugins/design/templates/design-system-inspiration/platform-mobile/components-tab-bar.html +57 -0
  174. package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-index.html +58 -0
  175. package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-showcase.html +237 -0
  176. package/plugins/design/templates/design-system-inspiration/status/colors-status.html +49 -0
  177. package/plugins/design/templates/design-system-inspiration/status/components-status.html +63 -0
  178. package/plugins/design/templates/design-system-inspiration/status/skeletons.html +74 -0
  179. package/plugins/design/templates/design-system-inspiration/theme-both/colors-themes-side-by-side.html +59 -0
  180. package/plugins/design/templates/design-system-inspiration/universal/components-callout.html +74 -0
  181. package/plugins/design/templates/design-system-inspiration/universal/components-dialogs.html +81 -0
  182. package/plugins/design/templates/design-system-inspiration/universal/components-tables.html +101 -0
  183. package/plugins/design/templates/design-system-inspiration/universal/components-toggles.html +74 -0
  184. package/plugins/design/templates/design-system-inspiration/universal/components-tooltips.html +74 -0
  185. package/plugins/design/templates/design-system-inspiration/universal/empty-state.html.tpl +34 -0
  186. package/plugins/design/templates/design-system-inspiration/universal/logo.html +42 -0
  187. package/plugins/design/templates/ds-specimen.tsx.template +59 -0
  188. package/plugins/flow/.claude-plugin/config.schema.json +398 -0
  189. package/plugins/flow/README.md +45 -0
  190. package/plugins/flow/templates/ai-skeleton/INDEX.md +50 -0
  191. package/plugins/flow/templates/ai-skeleton/README.md +46 -0
  192. package/plugins/flow/templates/ai-skeleton/browser/har/.gitkeep +0 -0
  193. package/plugins/flow/templates/ai-skeleton/browser/snapshots/.gitkeep +0 -0
  194. package/plugins/flow/templates/ai-skeleton/business/README.md +12 -0
  195. package/plugins/flow/templates/ai-skeleton/context/README.md +12 -0
  196. package/plugins/flow/templates/ai-skeleton/decisions/README.md +20 -0
  197. package/plugins/flow/templates/ai-skeleton/design-import/.gitkeep +0 -0
  198. package/plugins/flow/templates/ai-skeleton/dev-logs/.gitkeep +0 -0
  199. package/plugins/flow/templates/ai-skeleton/device/.gitkeep +0 -0
  200. package/plugins/flow/templates/ai-skeleton/docs/README.md +5 -0
  201. package/plugins/flow/templates/ai-skeleton/logs/.gitkeep +0 -0
  202. package/plugins/flow/templates/ai-skeleton/logs/README.md +13 -0
  203. package/plugins/flow/templates/ai-skeleton/plans/README.md +16 -0
  204. package/plugins/flow/templates/ai-skeleton/plans/archive/.gitkeep +0 -0
  205. package/plugins/flow/templates/ai-skeleton/release-guide.md +35 -0
  206. package/plugins/flow/templates/ai-skeleton/reviews/README.md +15 -0
  207. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +26 -0
  208. package/plugins/flow/templates/ai-skeleton/scenarios/_lib/.gitkeep +0 -0
  209. package/plugins/flow/templates/ai-skeleton/state/STATE.md +25 -0
  210. package/plugins/flow/templates/ai-skeleton/templates/HANDOFF.md +32 -0
  211. package/plugins/flow/templates/ai-skeleton/workflows.config.json +74 -0
@@ -0,0 +1,1995 @@
1
+ /**
2
+ * @file canvas-lib.tsx — dev-server-bundled canvas library
3
+ * @scope plugins/design/dev-server/canvas-lib.tsx
4
+ * Ships with the dev-server install; resolved at canvas build time
5
+ * via the `@maude/canvas-lib` virtual specifier. Per DDR-025, this
6
+ * is the single source of truth — no project-side copy.
7
+ * @purpose Shared primitives + helpers + hooks for every TSX canvas
8
+ * (UI mocks + DS specimens). Imported via the virtual module
9
+ * specifier `@maude/canvas-lib`, which the dev-server's Bun.build
10
+ * resolver maps to this file. On /design:handoff the used exports
11
+ * are AST-inlined into the emitted registry-item so the consumer
12
+ * never sees the `@maude/canvas-lib` specifier.
13
+ *
14
+ * Exports (cold-reader cheat sheet):
15
+ *
16
+ * Frame envelope ─────────────────────────────────────────────────────────
17
+ * DesignCanvas Root wrapper. <div class="dc-canvas"> holding a
18
+ * transformable <div class="dc-world"> world plane.
19
+ * DCArtboard children inside the world are absolutely
20
+ * positioned in world coords; pan/zoom (Phase 4 T2)
21
+ * applies a single transform to the world plane.
22
+ * DCSection Group label. Inside DesignCanvas it collapses to a
23
+ * transparent wrapper (DCArtboard children take their
24
+ * own world coords); standalone it keeps the legacy
25
+ * <section>/<header><h2> chrome for specimens.
26
+ * DCArtboard Bordered artboard with SKU strip header. Inside
27
+ * DesignCanvas it absolutely-positions itself in world
28
+ * coords resolved from meta.layout (or default grid).
29
+ * Standalone it renders a fixed-size block at its given
30
+ * width/height (specimens / legacy uses).
31
+ * DCPostIt <aside class="dc-postit"> — sticky-note annotation.
32
+ *
33
+ * Specimen helpers ───────────────────────────────────────────────────────
34
+ * SpecimenHeader The .specimen-hd row (sku + crumbs + ThemeToggle).
35
+ * SpecimenMeta <dl class="specimen-meta"> ladder from entries[].
36
+ * KbdHint <kbd> chrome.
37
+ * TokenChip Inline visualiser for a var(--*) value.
38
+ * ColorSwatch Square + label for a color token.
39
+ * TypeScaleRow One row of a type-ladder specimen.
40
+ * ThemeToggle Light/dark <button> group writing data-theme on <html>.
41
+ *
42
+ * Hooks ──────────────────────────────────────────────────────────────────
43
+ * useTokens(prefix?) Resolves CSS custom properties from <html> computed style.
44
+ * useTheme() Current theme + setter, syncs to <html data-theme>.
45
+ * useArtboardBounds(ref) ResizeObserver wrapper returning {width,height}.
46
+ *
47
+ * Authoring vocabulary. Lift these before re-implementing equivalents. The
48
+ * surface intentionally mirrors the .html-era specimen idioms one-for-one
49
+ * (`.specimen-hd`, `.specimen-meta`, `.sku`, `.swatch`, `.stamp`) so existing
50
+ * `_components.css` rules still target them.
51
+ *
52
+ * data-cd-id IDs are injected by canvas-pipeline.ts pass 1 — including on the
53
+ * primitives below. That's fine; pipeline IDs change every time the lib
54
+ * changes. Don't pin lib-internal IDs in tests.
55
+ *
56
+ * Phase 4 (2026-05-19) — DesignCanvas became a transformable world plane.
57
+ * The engine is always on; a single-artboard canvas just defaults to
58
+ * fit-to-screen and looks identical to pre-Phase 4. Layout + viewport state
59
+ * live in `<file>.meta.json` via `window.__canvas_meta__` (T5 wiring).
60
+ *
61
+ * Phase 4.0.5 (2026-05-19) — relocated from `<designRoot>/_lib/canvas-lib.tsx`
62
+ * per DDR-025; single source in dev-server. No project-side copy is scaffolded
63
+ * anymore; legacy `_lib/` directories in downstream projects get a one-cycle
64
+ * deprecation log at dev-server boot.
65
+ */
66
+
67
+ import {
68
+ type CSSProperties,
69
+ type ReactNode,
70
+ type PointerEvent as ReactPointerEvent,
71
+ type RefObject,
72
+ createContext,
73
+ isValidElement,
74
+ useCallback,
75
+ useContext,
76
+ useEffect,
77
+ useLayoutEffect,
78
+ useMemo,
79
+ useRef,
80
+ useState,
81
+ } from 'react';
82
+
83
+ import { CanvasShell } from './canvas-shell.tsx';
84
+ import { type DragState, useArtboardDrag } from './use-artboard-drag.tsx';
85
+ import { useSelectionSetOptional } from './use-selection-set.tsx';
86
+ import { ToolProvider, useToolModeOptional } from './use-tool-mode.tsx';
87
+
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+ // Module constants
90
+
91
+ const ZOOM_MIN = 0.1;
92
+ const ZOOM_MAX = 4.0;
93
+ const ZOOM_STEP_IN = 1.2;
94
+ const ZOOM_STEP_OUT = 1 / 1.2;
95
+ const WHEEL_ZOOM_K = 0.0015; // larger = more sensitive wheel
96
+ const SETTLE_MS = 500;
97
+ const PUBLISH_MS = 50;
98
+
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+ // Engine CSS (Phase 4) — injected once per iframe inside DesignCanvas's mount.
101
+ // The visual chrome of `.dc-artboard` (borders, label strip, SKU type) still
102
+ // lives in the DS's _components.css. Engine CSS ONLY covers positioning + the
103
+ // world transform. Idempotent via the `dc-engine-css` id check.
104
+
105
+ const ENGINE_CSS = `
106
+ .dc-canvas {
107
+ position: absolute;
108
+ inset: 0;
109
+ overflow: hidden;
110
+ outline: none;
111
+ background-color: var(--bg-1, #f4f1ea);
112
+ background-image:
113
+ linear-gradient(var(--border-subtle, rgba(0,0,0,0.08)) 1px, transparent 1px),
114
+ linear-gradient(90deg, var(--border-subtle, rgba(0,0,0,0.08)) 1px, transparent 1px);
115
+ background-size: 24px 24px;
116
+ }
117
+ .dc-canvas:focus { outline: none; }
118
+ .dc-world {
119
+ position: absolute;
120
+ top: 0;
121
+ left: 0;
122
+ /* CSS zoom drives the scale; transform handles pan. transform-origin is
123
+ irrelevant under zoom (zoom anchors top-left of the box). will-change
124
+ hints to the compositor that this layer changes often. */
125
+ will-change: transform;
126
+ }
127
+ .dc-section-collapsed { display: contents; }
128
+
129
+ .dc-canvas .dc-artboard {
130
+ background: var(--bg-0, #ffffff);
131
+ color: var(--fg-0, #2a2520);
132
+ border: 1px solid var(--fg-0, #2a2520);
133
+ box-shadow: 6px 6px 0 var(--fg-0, #2a2520);
134
+ display: flex;
135
+ flex-direction: column;
136
+ overflow: hidden;
137
+ }
138
+ .dc-canvas .dc-artboard.dc-positioned { position: absolute; }
139
+ .dc-canvas .dc-artboard-label {
140
+ flex-shrink: 0;
141
+ background: var(--bg-2, #e8e3d8);
142
+ border-bottom: 1px solid var(--fg-0, #2a2520);
143
+ padding: 6px 14px;
144
+ font-size: 10px;
145
+ letter-spacing: 0.06em;
146
+ text-transform: uppercase;
147
+ color: var(--fg-1, #4a3f30);
148
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
149
+ }
150
+ .dc-canvas .dc-artboard-body {
151
+ flex: 1;
152
+ position: relative;
153
+ overflow: hidden;
154
+ }
155
+ button.dc-artboard-label {
156
+ appearance: none;
157
+ border-width: 0 0 1px 0;
158
+ font: inherit;
159
+ cursor: pointer;
160
+ text-align: left;
161
+ display: block;
162
+ width: 100%;
163
+ }
164
+ button.dc-artboard-label:focus-visible { outline: 2px solid var(--accent, #d63b1f); outline-offset: -2px; }
165
+ /* Active-artboard ring is in canvas-shell HALO_CSS (subtle 1 px tint). */
166
+ /* Phase 4.2 — drag chrome. */
167
+ .dc-canvas[data-active-tool="move"] .dc-artboard-label { cursor: grab; }
168
+ .dc-canvas[data-active-tool="move"] .dc-artboard-label:active { cursor: grabbing; }
169
+ .dc-canvas .dc-artboard.dc-dragging { opacity: 0.3; }
170
+ .dc-canvas .dc-artboard-ghost {
171
+ position: absolute;
172
+ pointer-events: none;
173
+ opacity: 0.5;
174
+ background: var(--bg-0, #ffffff);
175
+ border: 1px solid var(--fg-0, #2a2520);
176
+ box-shadow: 6px 6px 0 var(--fg-0, #2a2520);
177
+ z-index: 4;
178
+ }
179
+ .dc-canvas .dc-artboard-ghost-label {
180
+ background: var(--bg-2, #e8e3d8);
181
+ border-bottom: 1px solid var(--fg-0, #2a2520);
182
+ padding: 6px 14px;
183
+ font-size: 10px;
184
+ letter-spacing: 0.06em;
185
+ text-transform: uppercase;
186
+ color: var(--fg-1, #4a3f30);
187
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
188
+ }
189
+ `.trim();
190
+
191
+ function ensureEngineStyles(): void {
192
+ if (typeof document === 'undefined') return;
193
+ if (document.getElementById('dc-engine-css')) return;
194
+ const s = document.createElement('style');
195
+ s.id = 'dc-engine-css';
196
+ s.textContent = ENGINE_CSS;
197
+ document.head.appendChild(s);
198
+ }
199
+
200
+ // ─────────────────────────────────────────────────────────────────────────────
201
+ // World context — published by DesignCanvas. Consumed by DCArtboard (for
202
+ // world-coord positioning) and by DCSection (which collapses inside the
203
+ // canvas) and by future T3 components (MiniMap, ZoomToolbar).
204
+
205
+ export interface ArtboardRect {
206
+ id: string;
207
+ x: number;
208
+ y: number;
209
+ w: number;
210
+ h: number;
211
+ }
212
+
213
+ export interface ViewportState {
214
+ x: number;
215
+ y: number;
216
+ zoom: number;
217
+ }
218
+
219
+ export interface WorldContextValue {
220
+ /** Look up a DCArtboard's world-coord rect by its `id` prop. */
221
+ rectFor: (id: string) => ArtboardRect | null;
222
+ /** All artboards in render order, with resolved world-coord positions. */
223
+ artboards: ArtboardRect[];
224
+ /** Current pan/zoom state. `null` until the first useLayoutEffect runs. */
225
+ viewport: ViewportState | null;
226
+ /** id of the artboard closest to the viewport center (recomputed on settle). */
227
+ activeArtboardId: string | null;
228
+ /** The fixed-bleed `.dc-canvas` host (visible iframe area). */
229
+ hostRef: RefObject<HTMLDivElement | null>;
230
+ /** The transformable `.dc-world` element. */
231
+ worldRef: RefObject<HTMLDivElement | null>;
232
+ }
233
+
234
+ const WorldContext = createContext<WorldContextValue | null>(null);
235
+
236
+ function useWorldContext(): WorldContextValue | null {
237
+ return useContext(WorldContext);
238
+ }
239
+
240
+ // Phase 5.1: annotations-layer needs the world `<div>` to portal a stroke SVG
241
+ // inside it (so CSS zoom + translate apply natively, no per-frame projection
242
+ // math). Expose only the ref — the rest of WorldContextValue stays internal.
243
+ export function useWorldRefContext(): RefObject<HTMLDivElement | null> | null {
244
+ const ctx = useContext(WorldContext);
245
+ return ctx?.worldRef ?? null;
246
+ }
247
+
248
+ // ─────────────────────────────────────────────────────────────────────────────
249
+ // Layout synthesis. Default grid + fit-to-screen compute. Phase 4 T2 will
250
+ // replace the "always re-fit on resize" useLayoutEffect with the
251
+ // useViewportController hook; for T1 we hold the world at fit-to-screen
252
+ // (or at meta.viewport if seeded) and don't yet expose pan/zoom inputs.
253
+
254
+ const VP_GRID = { cols: 3, w: 1280, h: 820, gutter: 80 } as const;
255
+
256
+ interface ArtboardSeed {
257
+ id: string;
258
+ w: number;
259
+ h: number;
260
+ }
261
+
262
+ /**
263
+ * Walk a children subtree harvesting DCArtboard descriptors in render order.
264
+ * DCSection (and any other wrapper) is traversed but doesn't appear in the
265
+ * harvest. Identity-matches against the DCArtboard reference so renamed
266
+ * imports don't trip it; falls back to displayName for minified builds.
267
+ */
268
+ function harvestArtboards(children: ReactNode): ArtboardSeed[] {
269
+ const out: ArtboardSeed[] = [];
270
+ let auto = 0;
271
+ function visit(node: ReactNode): void {
272
+ if (node == null || typeof node === 'boolean') return;
273
+ if (Array.isArray(node)) {
274
+ for (const c of node) visit(c);
275
+ return;
276
+ }
277
+ if (!isValidElement(node)) return;
278
+ const type = node.type;
279
+ const isArtboard =
280
+ type === DCArtboard ||
281
+ (typeof type === 'function' &&
282
+ (type as { displayName?: string }).displayName === 'DCArtboard');
283
+ if (isArtboard) {
284
+ const props = node.props as { id?: string; width?: number; height?: number };
285
+ out.push({
286
+ id: typeof props.id === 'string' && props.id.length > 0 ? props.id : `__ab_${auto}`,
287
+ w: typeof props.width === 'number' ? props.width : VP_GRID.w,
288
+ h: typeof props.height === 'number' ? props.height : VP_GRID.h,
289
+ });
290
+ auto++;
291
+ return;
292
+ }
293
+ const childProp = (node.props as { children?: ReactNode } | null | undefined)?.children;
294
+ if (childProp != null) visit(childProp);
295
+ }
296
+ visit(children);
297
+ return out;
298
+ }
299
+
300
+ function synthDefaultGrid(seeds: ArtboardSeed[]): ArtboardRect[] {
301
+ // Render order (the order DCArtboards appear in JSX), not alphabetical —
302
+ // authors label artboards DS-01 / DS-02 / CV-01 etc. and expect that
303
+ // numeric order to show top-left → bottom-right, but their ids are usually
304
+ // semantic (`landing`, `docs-article`, `cmd-k`, `about`) which would
305
+ // shuffle the numeric order.
306
+ // Column / row size come from the largest artboard so canvases with mixed
307
+ // dimensions (width=1440 on Docs Site, width=1280 elsewhere) don't bleed
308
+ // past a 1280-step grid.
309
+ if (seeds.length === 0) return [];
310
+ const cellW = seeds.reduce((m, s) => Math.max(m, s.w), 0) || VP_GRID.w;
311
+ const cellH = seeds.reduce((m, s) => Math.max(m, s.h), 0) || VP_GRID.h;
312
+ return seeds.map((seed, i) => {
313
+ const col = i % VP_GRID.cols;
314
+ const row = Math.floor(i / VP_GRID.cols);
315
+ return {
316
+ id: seed.id,
317
+ x: col * (cellW + VP_GRID.gutter),
318
+ y: row * (cellH + VP_GRID.gutter),
319
+ w: seed.w,
320
+ h: seed.h,
321
+ };
322
+ });
323
+ }
324
+
325
+ function computeFit(rects: ArtboardRect[], hostEl: HTMLElement, pad = 24): ViewportState {
326
+ if (rects.length === 0) return { x: 0, y: 0, zoom: 1 };
327
+ let xMin = Number.POSITIVE_INFINITY;
328
+ let yMin = Number.POSITIVE_INFINITY;
329
+ let xMax = Number.NEGATIVE_INFINITY;
330
+ let yMax = Number.NEGATIVE_INFINITY;
331
+ for (const r of rects) {
332
+ if (r.x < xMin) xMin = r.x;
333
+ if (r.y < yMin) yMin = r.y;
334
+ if (r.x + r.w > xMax) xMax = r.x + r.w;
335
+ if (r.y + r.h > yMax) yMax = r.y + r.h;
336
+ }
337
+ const bw = xMax - xMin;
338
+ const bh = yMax - yMin;
339
+ const vw = hostEl.clientWidth;
340
+ const vh = hostEl.clientHeight;
341
+ if (!vw || !vh || bw <= 0 || bh <= 0) return { x: 0, y: 0, zoom: 1 };
342
+ const zoom = Math.min((vw - pad * 2) / bw, (vh - pad * 2) / bh, 1.0);
343
+ const x = (vw - bw * zoom) / 2 - xMin * zoom;
344
+ const y = (vh - bh * zoom) / 2 - yMin * zoom;
345
+ return { x, y, zoom };
346
+ }
347
+
348
+ /**
349
+ * Read the canvas-meta sidecar that the dev-server's `_shell.html` injects
350
+ * on `window.__canvas_meta__` (Phase 4 T5). Returns undefined if the canvas
351
+ * is mounted outside the shell (specimens / unit tests).
352
+ */
353
+ function readCanvasMeta():
354
+ | {
355
+ layout?: { artboards?: ArtboardRect[] };
356
+ viewport?: ViewportState;
357
+ }
358
+ | undefined {
359
+ if (typeof window === 'undefined') return undefined;
360
+ const w = window as unknown as {
361
+ __canvas_meta__?: {
362
+ layout?: { artboards?: ArtboardRect[] };
363
+ viewport?: ViewportState;
364
+ };
365
+ };
366
+ return w.__canvas_meta__;
367
+ }
368
+
369
+ /**
370
+ * Returns the repo-relative path the shell stashed alongside the meta so
371
+ * onSettle PATCHes know which sidecar to write back to.
372
+ */
373
+ function readCanvasMetaFile(): string | null {
374
+ if (typeof window === 'undefined') return null;
375
+ const w = window as unknown as { __canvas_meta_file__?: string };
376
+ return typeof w.__canvas_meta_file__ === 'string' ? w.__canvas_meta_file__ : null;
377
+ }
378
+
379
+ /**
380
+ * PATCH the canvas-meta sidecar with `{ viewport }` or `{ layout }`. Best-effort
381
+ * fire-and-forget — failures are logged but don't disrupt the canvas.
382
+ *
383
+ * Phase 4.2 (DDR-027): artboard `w`/`h` is JSX-authoritative. The writer
384
+ * strips any `w`/`h` keys from `layout.artboards[]` before PATCH so a drag
385
+ * commit only persists the position pair `{ id, x, y }`. The reader stays
386
+ * tolerant of legacy entries that still carry `w`/`h` (Phase 4 default-grid
387
+ * snapshots remain readable until the next drag overwrites them).
388
+ */
389
+ /**
390
+ * Wire shape persisted to `meta.layout.artboards[]`. DDR-027: positions only,
391
+ * size is JSX-authoritative. Distinct from the in-memory `ArtboardRect` which
392
+ * still carries `w`/`h` for layout math.
393
+ */
394
+ interface PersistedArtboardLayout {
395
+ id: string;
396
+ x: number;
397
+ y: number;
398
+ }
399
+
400
+ function patchCanvasMeta(patch: {
401
+ viewport?: ViewportState;
402
+ layout?: { artboards: ArtboardRect[] };
403
+ }): void {
404
+ if (typeof window === 'undefined' || typeof fetch === 'undefined') return;
405
+ const file = readCanvasMetaFile();
406
+ if (!file) return;
407
+ const sanitized: {
408
+ viewport?: ViewportState;
409
+ layout?: { artboards: PersistedArtboardLayout[] };
410
+ } = {};
411
+ if (patch.viewport) sanitized.viewport = patch.viewport;
412
+ if (patch.layout?.artboards) {
413
+ sanitized.layout = {
414
+ artboards: patch.layout.artboards.map((r) => ({
415
+ id: r.id,
416
+ x: r.x,
417
+ y: r.y,
418
+ })),
419
+ };
420
+ }
421
+ fetch('/_api/canvas-meta', {
422
+ method: 'PATCH',
423
+ headers: { 'content-type': 'application/json' },
424
+ body: JSON.stringify({ file, patch: sanitized }),
425
+ }).catch((err) => {
426
+ console.warn('[canvas-lib] persist viewport failed:', err);
427
+ });
428
+ }
429
+
430
+ // ─────────────────────────────────────────────────────────────────────────────
431
+ // useViewportController (Phase 4 T2)
432
+ //
433
+ // Owns the canvas's pan/zoom/pinch/spacebar-drag/middle-mouse-drag/Cmd shortcuts.
434
+ // Scoped to the canvas iframe via hostRef — no shell-level keyboard leaks.
435
+ // While the user is actively gesturing, the hook writes the world transform
436
+ // straight to `worldRef.current.style.transform` for a 60 fps path; React state
437
+ // publishes on a 50 ms throttle so MiniMap + ZoomToolbar consumers re-render
438
+ // at a comfortable cadence. `onSettle` fires 500 ms after the last input so
439
+ // T5 can persist the viewport without flooding writes during a drag.
440
+
441
+ export interface ViewportControllerOptions {
442
+ hostRef: RefObject<HTMLDivElement | null>;
443
+ worldRef: RefObject<HTMLDivElement | null>;
444
+ /** Computes a fit-to-screen viewport for the current artboard set. */
445
+ computeFit: () => ViewportState;
446
+ /**
447
+ * Initial viewport. Read once at mount. Return `null` to defer until the
448
+ * host has a measured size (the hook will re-init on the first ResizeObserver
449
+ * tick that produces a non-zero host).
450
+ */
451
+ getInitial: () => ViewportState | null;
452
+ /** Called debounced ~500 ms after the last input. */
453
+ onSettle?: (v: ViewportState) => void;
454
+ /**
455
+ * Jump-target rects (in world coords) for Cmd+Option+1..9. The N-th entry
456
+ * fits that rect inside the host. Provided by DesignCanvas which has the
457
+ * artboard list. Optional — keyboard jumps no-op when omitted.
458
+ */
459
+ jumpTargets?: ArtboardRect[];
460
+ /**
461
+ * Phase 4.1 hand-tool support. When this predicate returns `true`, bare
462
+ * left-button pointerdown initiates a pan drag (no Space required). The
463
+ * predicate is read per-event so the consumer can return the live tool
464
+ * state. Omit / return `false` to keep the Phase-4 behavior (drag only
465
+ * with Space or middle-mouse).
466
+ */
467
+ isPanDragActive?: () => boolean;
468
+ }
469
+
470
+ export interface ViewportControllerHandle {
471
+ /** Current viewport state. Throttled — for tight render loops use the ref. */
472
+ viewport: ViewportState;
473
+ /** Snap to an arbitrary viewport (used by MiniMap drag, T3). */
474
+ setViewport: (v: ViewportState) => void;
475
+ /** Pan by (dx, dy) screen px. */
476
+ panBy: (dx: number, dy: number) => void;
477
+ /** Multiply zoom by factor, preserving (cx, cy) screen px under the cursor. */
478
+ zoomAt: (factor: number, cx: number, cy: number) => void;
479
+ /** Cmd+0 — fit-to-screen on the artboard union. */
480
+ fit: () => void;
481
+ /** Cmd+1 — actual size (zoom = 1.0), recentered on viewport midpoint. */
482
+ reset: () => void;
483
+ /** Cmd+= — zoom in 1.2× at host center. */
484
+ zoomIn: () => void;
485
+ /** Cmd+- — zoom out at host center. */
486
+ zoomOut: () => void;
487
+ /** Jump to the rect at `index` with smooth fit (used by T4 click-to-focus). */
488
+ jumpTo: (rect: ArtboardRect) => void;
489
+ /** Animate to a target viewport over `durationMs` (reduced-motion = instant). */
490
+ animateTo: (target: ViewportState, durationMs?: number) => void;
491
+ /** True while the user is actively gesturing (drag / wheel run). */
492
+ isInteracting: boolean;
493
+ }
494
+
495
+ function clampZoom(z: number): number {
496
+ if (!Number.isFinite(z)) return 1;
497
+ return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
498
+ }
499
+
500
+ function prefersReducedMotion(): boolean {
501
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
502
+ try {
503
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
504
+ } catch {
505
+ return false;
506
+ }
507
+ }
508
+
509
+ function fitRectIntoHost(rect: ArtboardRect, hostEl: HTMLElement, pad = 24): ViewportState {
510
+ return computeFit([rect], hostEl, pad);
511
+ }
512
+
513
+ export function useViewportController(opts: ViewportControllerOptions): ViewportControllerHandle {
514
+ const {
515
+ hostRef,
516
+ worldRef,
517
+ computeFit: computeFitFn,
518
+ getInitial,
519
+ onSettle,
520
+ jumpTargets,
521
+ isPanDragActive,
522
+ } = opts;
523
+ const isPanDragActiveRef = useRef<(() => boolean) | undefined>(isPanDragActive);
524
+ isPanDragActiveRef.current = isPanDragActive;
525
+
526
+ // Canonical viewport in a ref — synchronous, drives the world transform.
527
+ const vpRef = useRef<ViewportState>({ x: 0, y: 0, zoom: 1 });
528
+ const [viewport, setViewportPublished] = useState<ViewportState>({ x: 0, y: 0, zoom: 1 });
529
+ const [isInteracting, setIsInteracting] = useState(false);
530
+ const interactingRef = useRef(false);
531
+ const isInteractingStateRef = useRef(false);
532
+
533
+ // Throttle / settle timers.
534
+ const publishTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
535
+ const settleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
536
+ const interactEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
537
+
538
+ // Stable refs to options that may change between renders.
539
+ const computeFitRef = useRef(computeFitFn);
540
+ computeFitRef.current = computeFitFn;
541
+ const onSettleRef = useRef(onSettle);
542
+ onSettleRef.current = onSettle;
543
+ const jumpTargetsRef = useRef(jumpTargets);
544
+ jumpTargetsRef.current = jumpTargets;
545
+
546
+ // worldRef is stable across renders — read inside callbacks lazily, no dep.
547
+ // Use CSS `zoom` (not `transform: scale`) for the scale dimension. `zoom`
548
+ // re-flows layout at the new size so the browser re-rasterizes text at the
549
+ // target resolution — text stays crisp at any zoom level. `transform: scale`
550
+ // upscales a cached layer, which produces the pixelation users see at
551
+ // zoom > ~1.5. CSS `zoom` is supported in Chrome / Safari / Edge (always)
552
+ // and Firefox 126+; for a dev-server design tool that's full coverage.
553
+ //
554
+ // ! Subtle: CSS `zoom: N` makes `transform: translate(Xpx, Ypx)` translate by
555
+ // ! N×X / N×Y screen pixels (translate is in the *pre-zoom* coord space, then
556
+ // ! the whole layer is zoomed). Our controller's `vpRef` holds the translate
557
+ // ! in *screen* pixels (the same convention as `transform: scale(N)
558
+ // ! translate(...)` had), so we divide by zoom at write time to convert into
559
+ // ! the CSS-zoom world. The data model stays simple and pan/zoom math (in
560
+ // ! particular zoom-around-cursor) keeps using screen-px throughout.
561
+ // biome-ignore lint/correctness/useExhaustiveDependencies: refs only — stable identity by design.
562
+ const writeTransform = useCallback((v: ViewportState) => {
563
+ const el = worldRef.current;
564
+ if (!el) return;
565
+ const z = v.zoom || 1;
566
+ el.style.transform = `translate(${v.x / z}px, ${v.y / z}px)`;
567
+ el.style.zoom = String(z);
568
+ el.style.visibility = 'visible';
569
+ }, []);
570
+
571
+ const schedulePublish = useCallback(() => {
572
+ if (publishTimerRef.current != null) return;
573
+ publishTimerRef.current = setTimeout(() => {
574
+ publishTimerRef.current = null;
575
+ setViewportPublished({ ...vpRef.current });
576
+ }, PUBLISH_MS);
577
+ }, []);
578
+
579
+ const scheduleSettle = useCallback(() => {
580
+ if (settleTimerRef.current != null) clearTimeout(settleTimerRef.current);
581
+ settleTimerRef.current = setTimeout(() => {
582
+ settleTimerRef.current = null;
583
+ const cb = onSettleRef.current;
584
+ if (cb) cb({ ...vpRef.current });
585
+ }, SETTLE_MS);
586
+ }, []);
587
+
588
+ // Read the interacting flag from a ref so this callback identity stays
589
+ // stable across renders — otherwise applyViewport (and the listeners that
590
+ // close over it) get torn down on every state update, eating mid-gesture
591
+ // pointer events.
592
+ const markInteracting = useCallback(() => {
593
+ interactingRef.current = true;
594
+ if (!isInteractingStateRef.current) {
595
+ isInteractingStateRef.current = true;
596
+ setIsInteracting(true);
597
+ }
598
+ if (interactEndTimerRef.current != null) clearTimeout(interactEndTimerRef.current);
599
+ interactEndTimerRef.current = setTimeout(() => {
600
+ interactingRef.current = false;
601
+ isInteractingStateRef.current = false;
602
+ setIsInteracting(false);
603
+ interactEndTimerRef.current = null;
604
+ }, 220);
605
+ }, []);
606
+
607
+ const applyViewport = useCallback(
608
+ (next: ViewportState) => {
609
+ const clamped: ViewportState = {
610
+ x: Number.isFinite(next.x) ? next.x : 0,
611
+ y: Number.isFinite(next.y) ? next.y : 0,
612
+ zoom: clampZoom(next.zoom),
613
+ };
614
+ vpRef.current = clamped;
615
+ writeTransform(clamped);
616
+ schedulePublish();
617
+ scheduleSettle();
618
+ markInteracting();
619
+ },
620
+ [writeTransform, schedulePublish, scheduleSettle, markInteracting]
621
+ );
622
+
623
+ // Imperative API ------------------------------------------------------------
624
+
625
+ const setViewport = useCallback((v: ViewportState) => applyViewport(v), [applyViewport]);
626
+
627
+ const panBy = useCallback(
628
+ (dx: number, dy: number) => {
629
+ const v = vpRef.current;
630
+ applyViewport({ x: v.x + dx, y: v.y + dy, zoom: v.zoom });
631
+ },
632
+ [applyViewport]
633
+ );
634
+
635
+ const zoomAt = useCallback(
636
+ (factor: number, cx: number, cy: number) => {
637
+ const v = vpRef.current;
638
+ const newZoom = clampZoom(v.zoom * factor);
639
+ // World coord under (cx, cy) before the zoom change.
640
+ const wx = (cx - v.x) / v.zoom;
641
+ const wy = (cy - v.y) / v.zoom;
642
+ const next: ViewportState = {
643
+ x: cx - wx * newZoom,
644
+ y: cy - wy * newZoom,
645
+ zoom: newZoom,
646
+ };
647
+ applyViewport(next);
648
+ },
649
+ [applyViewport]
650
+ );
651
+
652
+ const fit = useCallback(() => {
653
+ const next = computeFitRef.current();
654
+ applyViewport(next);
655
+ }, [applyViewport]);
656
+
657
+ const reset = useCallback(() => {
658
+ const host = hostRef.current;
659
+ if (!host) {
660
+ applyViewport({ x: 0, y: 0, zoom: 1 });
661
+ return;
662
+ }
663
+ const cx = host.clientWidth / 2;
664
+ const cy = host.clientHeight / 2;
665
+ zoomAt(1 / vpRef.current.zoom, cx, cy);
666
+ }, [hostRef, applyViewport, zoomAt]);
667
+
668
+ const zoomIn = useCallback(() => {
669
+ const host = hostRef.current;
670
+ if (!host) return;
671
+ zoomAt(ZOOM_STEP_IN, host.clientWidth / 2, host.clientHeight / 2);
672
+ }, [hostRef, zoomAt]);
673
+
674
+ const zoomOut = useCallback(() => {
675
+ const host = hostRef.current;
676
+ if (!host) return;
677
+ zoomAt(ZOOM_STEP_OUT, host.clientWidth / 2, host.clientHeight / 2);
678
+ }, [hostRef, zoomAt]);
679
+
680
+ // Animation — rAF-driven ease-out cubic, falls through to apply on each
681
+ // frame so MiniMap / ZoomToolbar follow the trajectory live.
682
+ const animationRef = useRef<number | null>(null);
683
+
684
+ const animateTo = useCallback(
685
+ (target: ViewportState, durationMs = 240) => {
686
+ if (animationRef.current != null) {
687
+ cancelAnimationFrame(animationRef.current);
688
+ animationRef.current = null;
689
+ }
690
+ const dur = prefersReducedMotion() ? 0 : Math.max(0, durationMs);
691
+ if (dur === 0) {
692
+ applyViewport(target);
693
+ return;
694
+ }
695
+ const start: ViewportState = { ...vpRef.current };
696
+ const t0 =
697
+ typeof performance !== 'undefined' && typeof performance.now === 'function'
698
+ ? performance.now()
699
+ : Date.now();
700
+ const tick = (now: number) => {
701
+ const t = Math.min(1, (now - t0) / dur);
702
+ const e = 1 - (1 - t) ** 3; // ease-out cubic
703
+ applyViewport({
704
+ x: start.x + (target.x - start.x) * e,
705
+ y: start.y + (target.y - start.y) * e,
706
+ zoom: clampZoom(start.zoom + (target.zoom - start.zoom) * e),
707
+ });
708
+ if (t < 1) {
709
+ animationRef.current = requestAnimationFrame(tick);
710
+ } else {
711
+ animationRef.current = null;
712
+ }
713
+ };
714
+ animationRef.current = requestAnimationFrame(tick);
715
+ },
716
+ [applyViewport]
717
+ );
718
+
719
+ const jumpTo = useCallback(
720
+ (rect: ArtboardRect) => {
721
+ const host = hostRef.current;
722
+ if (!host) return;
723
+ animateTo(fitRectIntoHost(rect, host));
724
+ },
725
+ [hostRef, animateTo]
726
+ );
727
+
728
+ // Mount / event wiring ------------------------------------------------------
729
+
730
+ // Initial viewport. Intentionally one-shot — caller drives re-fit via the `fit()` handle.
731
+ // biome-ignore lint/correctness/useExhaustiveDependencies: one-shot mount; caller controls re-fits.
732
+ useLayoutEffect(() => {
733
+ const initial = getInitial();
734
+ if (initial) {
735
+ vpRef.current = { ...initial };
736
+ writeTransform(vpRef.current);
737
+ setViewportPublished({ ...vpRef.current });
738
+ }
739
+ // If host has no size yet, refit when ResizeObserver delivers one.
740
+ const host = hostRef.current;
741
+ if (!host || typeof ResizeObserver === 'undefined') return;
742
+ let hadSize = host.clientWidth > 0 && host.clientHeight > 0;
743
+ const ro = new ResizeObserver(() => {
744
+ if (interactingRef.current) return; // never re-fit during a gesture
745
+ if (!hadSize && host.clientWidth > 0 && host.clientHeight > 0) {
746
+ hadSize = true;
747
+ const refit = computeFitRef.current();
748
+ vpRef.current = { ...refit };
749
+ writeTransform(vpRef.current);
750
+ setViewportPublished({ ...vpRef.current });
751
+ }
752
+ });
753
+ ro.observe(host);
754
+ return () => ro.disconnect();
755
+ }, []);
756
+
757
+ // Pointer + wheel + key listeners — all scoped to hostRef so the shell
758
+ // keyboard and other iframes stay quiet.
759
+ // biome-ignore lint/correctness/useExhaustiveDependencies: pan/zoom callbacks are useCallback-stable; listeners mount once on host.
760
+ useEffect(() => {
761
+ const host = hostRef.current;
762
+ if (!host) return;
763
+
764
+ // Auto-focus on pointer enter — without this, keyboard shortcuts (Space
765
+ // for pan, Cmd+0/1/+/-) silently fail until the user clicks inside the
766
+ // iframe. Focusing the host element pulls keyboard focus into this
767
+ // iframe's contentWindow so the window-scoped keydown listener below
768
+ // receives events natively.
769
+ const onPointerEnter = () => {
770
+ try {
771
+ if (typeof window !== 'undefined' && document.activeElement !== host) {
772
+ host.focus({ preventScroll: true });
773
+ }
774
+ } catch {
775
+ /* ignore */
776
+ }
777
+ };
778
+
779
+ const spaceHeld = { current: false };
780
+ const panState: {
781
+ active: boolean;
782
+ pointerId: number;
783
+ lastX: number;
784
+ lastY: number;
785
+ } = { active: false, pointerId: -1, lastX: 0, lastY: 0 };
786
+
787
+ const onWheel = (e: WheelEvent) => {
788
+ e.preventDefault();
789
+ const rect = host.getBoundingClientRect();
790
+ const cx = e.clientX - rect.left;
791
+ const cy = e.clientY - rect.top;
792
+ // Mac trackpad pinch fires wheel with ctrlKey:true automatically, even
793
+ // without a physical Ctrl press — so the same branch covers both
794
+ // Ctrl+wheel (mouse) and pinch-zoom (trackpad).
795
+ if (e.ctrlKey || e.metaKey) {
796
+ const factor = Math.exp(-e.deltaY * WHEEL_ZOOM_K);
797
+ zoomAt(factor, cx, cy);
798
+ return;
799
+ }
800
+ // Shift+wheel → horizontal pan. Some browsers / OSes auto-swap
801
+ // deltaX↔deltaY when shift is held (Chromium on Linux does, macOS
802
+ // doesn't, Safari sometimes does); some don't. Read whichever axis
803
+ // actually carries energy so the gesture lands horizontally either way.
804
+ if (e.shiftKey) {
805
+ const d = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
806
+ panBy(-d, 0);
807
+ return;
808
+ }
809
+ // Default: trackpad two-finger scroll → 2D pan. The negation keeps the
810
+ // "content follows your fingers" mapping (Mac natural scroll). Mouse
811
+ // wheels with only deltaY pan vertically.
812
+ panBy(-e.deltaX, -e.deltaY);
813
+ };
814
+
815
+ const onPointerDown = (e: PointerEvent) => {
816
+ const isMiddle = e.button === 1;
817
+ const isLeftWithSpace = e.button === 0 && spaceHeld.current;
818
+ // Phase 4.1 hand tool: bare left-button initiates pan when the consumer
819
+ // signals hand-mode via `isPanDragActive`. Read per-event so the live
820
+ // tool state controls the gate.
821
+ const isLeftWithHandTool =
822
+ e.button === 0 && !spaceHeld.current && !!isPanDragActiveRef.current?.();
823
+ if (!isMiddle && !isLeftWithSpace && !isLeftWithHandTool) return;
824
+ e.preventDefault();
825
+ try {
826
+ host.setPointerCapture(e.pointerId);
827
+ } catch {
828
+ /* ignore */
829
+ }
830
+ panState.active = true;
831
+ panState.pointerId = e.pointerId;
832
+ panState.lastX = e.clientX;
833
+ panState.lastY = e.clientY;
834
+ host.style.cursor = 'grabbing';
835
+ };
836
+
837
+ const onPointerMove = (e: PointerEvent) => {
838
+ if (!panState.active || e.pointerId !== panState.pointerId) return;
839
+ const dx = e.clientX - panState.lastX;
840
+ const dy = e.clientY - panState.lastY;
841
+ panState.lastX = e.clientX;
842
+ panState.lastY = e.clientY;
843
+ panBy(dx, dy);
844
+ };
845
+
846
+ const endPan = (e: PointerEvent) => {
847
+ if (!panState.active || e.pointerId !== panState.pointerId) return;
848
+ try {
849
+ host.releasePointerCapture(e.pointerId);
850
+ } catch {
851
+ /* ignore */
852
+ }
853
+ panState.active = false;
854
+ panState.pointerId = -1;
855
+ host.style.cursor = spaceHeld.current ? 'grab' : '';
856
+ };
857
+
858
+ const onKeyDown = (e: KeyboardEvent) => {
859
+ // Spacebar pan affordance — only when no input is focused.
860
+ if (e.code === 'Space' && !isEditableTarget(e.target)) {
861
+ spaceHeld.current = true;
862
+ host.style.cursor = panState.active ? 'grabbing' : 'grab';
863
+ e.preventDefault();
864
+ return;
865
+ }
866
+ const mod = e.metaKey || e.ctrlKey;
867
+ if (!mod) return;
868
+ // Cmd+Option+1..9 → jump to artboard N (Option avoids Chrome's
869
+ // Cmd+1..9 tab-switching shortcut).
870
+ if (e.altKey && /^Digit[1-9]$/.test(e.code)) {
871
+ const n = Number(e.code.slice(-1));
872
+ const target = jumpTargetsRef.current?.[n - 1];
873
+ if (target) {
874
+ e.preventDefault();
875
+ jumpTo(target);
876
+ }
877
+ return;
878
+ }
879
+ if (e.altKey) return;
880
+ switch (e.key) {
881
+ case '0':
882
+ e.preventDefault();
883
+ fit();
884
+ return;
885
+ case '1':
886
+ e.preventDefault();
887
+ reset();
888
+ return;
889
+ case '=':
890
+ case '+':
891
+ e.preventDefault();
892
+ zoomIn();
893
+ return;
894
+ case '-':
895
+ e.preventDefault();
896
+ zoomOut();
897
+ return;
898
+ }
899
+ };
900
+ const onKeyUp = (e: KeyboardEvent) => {
901
+ if (e.code === 'Space') {
902
+ spaceHeld.current = false;
903
+ host.style.cursor = panState.active ? 'grabbing' : '';
904
+ }
905
+ };
906
+
907
+ host.tabIndex = host.tabIndex >= 0 ? host.tabIndex : 0; // focusable for kbd
908
+
909
+ // Wheel listener lives on the document at the CAPTURE phase. Bubble-phase
910
+ // on `host` is too late — an inner scrollable element (e.g. CSS
911
+ // `overflow: auto` somewhere in an artboard's content tree) consumes the
912
+ // wheel first and the bubble never reaches us, which is why
913
+ // shift+wheel-for-horizontal-pan would silently drop on some pages.
914
+ // We still check the event target is inside this canvas before acting,
915
+ // so wheels happening in shell chrome or another iframe pass through.
916
+ const doc = host.ownerDocument || document;
917
+ const captureWheel = (e: WheelEvent) => {
918
+ if (!host.contains(e.target as Node)) return;
919
+ onWheel(e);
920
+ };
921
+ const captureKeyDown = (e: KeyboardEvent) => {
922
+ // Don't intercept keyboard events from input fields anywhere.
923
+ if (isEditableTarget(e.target)) return;
924
+ onKeyDown(e);
925
+ };
926
+ const captureKeyUp = (e: KeyboardEvent) => onKeyUp(e);
927
+
928
+ doc.addEventListener('wheel', captureWheel, { passive: false, capture: true });
929
+ doc.addEventListener('keydown', captureKeyDown, { capture: true });
930
+ doc.addEventListener('keyup', captureKeyUp, { capture: true });
931
+ host.addEventListener('pointerenter', onPointerEnter);
932
+ host.addEventListener('pointerdown', onPointerDown);
933
+ host.addEventListener('pointermove', onPointerMove);
934
+ host.addEventListener('pointerup', endPan);
935
+ host.addEventListener('pointercancel', endPan);
936
+
937
+ return () => {
938
+ doc.removeEventListener('wheel', captureWheel, { capture: true } as EventListenerOptions);
939
+ doc.removeEventListener('keydown', captureKeyDown, { capture: true } as EventListenerOptions);
940
+ doc.removeEventListener('keyup', captureKeyUp, { capture: true } as EventListenerOptions);
941
+ host.removeEventListener('pointerenter', onPointerEnter);
942
+ host.removeEventListener('pointerdown', onPointerDown);
943
+ host.removeEventListener('pointermove', onPointerMove);
944
+ host.removeEventListener('pointerup', endPan);
945
+ host.removeEventListener('pointercancel', endPan);
946
+ };
947
+ }, [hostRef]);
948
+
949
+ // Final settle on unmount — drop pending timers, flush onSettle synchronously
950
+ // so persistence-on-close (T5) still records the last viewport.
951
+ useEffect(() => {
952
+ return () => {
953
+ if (publishTimerRef.current != null) clearTimeout(publishTimerRef.current);
954
+ if (settleTimerRef.current != null) {
955
+ clearTimeout(settleTimerRef.current);
956
+ const cb = onSettleRef.current;
957
+ if (cb) cb({ ...vpRef.current });
958
+ }
959
+ if (interactEndTimerRef.current != null) clearTimeout(interactEndTimerRef.current);
960
+ if (animationRef.current != null) cancelAnimationFrame(animationRef.current);
961
+ };
962
+ }, []);
963
+
964
+ return {
965
+ viewport,
966
+ setViewport,
967
+ panBy,
968
+ zoomAt,
969
+ fit,
970
+ reset,
971
+ zoomIn,
972
+ zoomOut,
973
+ jumpTo,
974
+ animateTo,
975
+ isInteracting,
976
+ };
977
+ }
978
+
979
+ // Helper — true when the event target is an editable input (so spacebar
980
+ // pan doesn't fight typing inside a canvas-embedded textarea).
981
+ function isEditableTarget(t: EventTarget | null): boolean {
982
+ if (!t || !(t as HTMLElement).tagName) return false;
983
+ const el = t as HTMLElement;
984
+ const tag = el.tagName;
985
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
986
+ if (el.isContentEditable) return true;
987
+ return false;
988
+ }
989
+
990
+ // Controller context — published by DesignCanvas so DCMiniMap + DCZoomToolbar
991
+ // can issue pan/zoom operations.
992
+ const ControllerContext = createContext<ViewportControllerHandle | null>(null);
993
+
994
+ export function useViewportControllerContext(): ViewportControllerHandle | null {
995
+ return useContext(ControllerContext);
996
+ }
997
+
998
+ // Drag-state context (Phase 4.2) — published by DesignCanvas so SnapGuideOverlay
999
+ // (mounted by CanvasShell) can read the active drag's snap guides + so each
1000
+ // DCArtboard can know whether it's being dragged as a follower (multi-select
1001
+ // drag). A single source-of-truth: only one drag can be active at a time, so
1002
+ // the bus holds a single DragState. Each DCArtboard's hook writes here when
1003
+ // non-idle and resets to idle on release.
1004
+ interface DragStateBus {
1005
+ current: DragState;
1006
+ setCurrent: (s: DragState) => void;
1007
+ /** Commit drag positions — DesignCanvas wires this to patchCanvasMeta. */
1008
+ commitPositions: (moved: { id: string; x: number; y: number }[]) => void;
1009
+ }
1010
+
1011
+ const DragStateContext = createContext<DragStateBus | null>(null);
1012
+
1013
+ export function useDragStateContext(): DragStateBus | null {
1014
+ return useContext(DragStateContext);
1015
+ }
1016
+
1017
+ // ─────────────────────────────────────────────────────────────────────────────
1018
+ // Frame envelope
1019
+
1020
+ interface DesignCanvasProps {
1021
+ children: ReactNode;
1022
+ /** Per-overlay opt-out. `false` hides it; omit or `true` shows it. */
1023
+ controls?: { minimap?: boolean; toolbar?: boolean };
1024
+ }
1025
+
1026
+ /**
1027
+ * DesignCanvas mounts the universal canvas input grammar (hover preview,
1028
+ * Cmd-click select, multi-select, tool modes V/H/C, right-click menu) for
1029
+ * every TSX canvas. There's no opt-out — the legacy Cmd-only inspector
1030
+ * overlay path was removed in favor of one consistent affordance everywhere.
1031
+ *
1032
+ * `ToolProvider` lives above `DesignCanvasInner` so the viewport
1033
+ * controller's `isPanDragActive` predicate can read the live tool state
1034
+ * via `useToolModeOptional` (hand-mode bare-drag pan).
1035
+ */
1036
+ export function DesignCanvas(props: DesignCanvasProps) {
1037
+ return (
1038
+ <ToolProvider>
1039
+ <DesignCanvasInner {...props} />
1040
+ </ToolProvider>
1041
+ );
1042
+ }
1043
+ DesignCanvas.displayName = 'DesignCanvas';
1044
+
1045
+ function DesignCanvasInner({ children, controls }: DesignCanvasProps) {
1046
+ ensureEngineStyles();
1047
+
1048
+ const hostRef = useRef<HTMLDivElement | null>(null);
1049
+ const worldRef = useRef<HTMLDivElement | null>(null);
1050
+
1051
+ const seeds = useMemo(() => harvestArtboards(children), [children]);
1052
+
1053
+ // Merge JSX-derived defaults with meta-persisted positions. Per DDR-027,
1054
+ // artboard size is JSX-authoritative; meta tolerates legacy w/h fields for
1055
+ // back-compat with Phase 4 snapshots but never lets a missing meta size
1056
+ // zero-out the rendered box.
1057
+ const initialArtboards = useCallback((): ArtboardRect[] => {
1058
+ const meta = readCanvasMeta();
1059
+ const defaults = synthDefaultGrid(seeds);
1060
+ const metaLayout = meta?.layout?.artboards;
1061
+ if (!Array.isArray(metaLayout) || metaLayout.length === 0) return defaults;
1062
+ const byId = new Map<string, ArtboardRect>();
1063
+ for (const r of metaLayout) {
1064
+ if (r && typeof r.id === 'string') byId.set(r.id, r);
1065
+ }
1066
+ return defaults.map((d) => {
1067
+ const m = byId.get(d.id);
1068
+ if (!m) return d;
1069
+ return {
1070
+ id: d.id,
1071
+ x: Number.isFinite(m.x) ? m.x : d.x,
1072
+ y: Number.isFinite(m.y) ? m.y : d.y,
1073
+ w: typeof m.w === 'number' && m.w > 0 ? m.w : d.w,
1074
+ h: typeof m.h === 'number' && m.h > 0 ? m.h : d.h,
1075
+ };
1076
+ });
1077
+ }, [seeds]);
1078
+
1079
+ // Artboards live in state (not a useMemo) so a drag commit can update
1080
+ // positions in-place without waiting for an iframe reload to re-read meta.
1081
+ // Phase 4.2 originally used useMemo([seeds]) — dragging would PATCH the
1082
+ // server but the local React state stayed frozen at mount-time. Users had
1083
+ // to switch canvases (forcing a reload) to see the new position.
1084
+ const [artboards, setArtboards] = useState<ArtboardRect[]>(initialArtboards);
1085
+
1086
+ // Re-seed when JSX children change (HMR after canvas TSX edit). The seed
1087
+ // signature is identity-stable across renders that don't change the JSX,
1088
+ // so this won't clobber drag-commit state during normal interaction.
1089
+ useEffect(() => {
1090
+ setArtboards(initialArtboards());
1091
+ }, [initialArtboards]);
1092
+
1093
+ // Stable refs so the controller's callbacks always see the latest values.
1094
+ const artboardsRef = useRef(artboards);
1095
+ artboardsRef.current = artboards;
1096
+
1097
+ const computeFitForArtboards = useCallback((): ViewportState => {
1098
+ const host = hostRef.current;
1099
+ if (!host) return { x: 0, y: 0, zoom: 1 };
1100
+ return computeFit(artboardsRef.current, host);
1101
+ }, []);
1102
+
1103
+ const getInitial = useCallback((): ViewportState | null => {
1104
+ const meta = readCanvasMeta();
1105
+ const v = meta?.viewport;
1106
+ if (v && Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.zoom)) {
1107
+ return { x: v.x, y: v.y, zoom: v.zoom };
1108
+ }
1109
+ const host = hostRef.current;
1110
+ if (!host) return null;
1111
+ return computeFit(artboardsRef.current, host);
1112
+ }, []);
1113
+
1114
+ const onSettle = useCallback((v: ViewportState) => {
1115
+ patchCanvasMeta({ viewport: v });
1116
+ }, []);
1117
+
1118
+ const toolModeCtx = useToolModeOptional();
1119
+ const toolRef = useRef(toolModeCtx?.tool ?? 'move');
1120
+ toolRef.current = toolModeCtx?.tool ?? 'move';
1121
+ const isPanDragActive = useCallback(() => toolRef.current === 'hand', []);
1122
+
1123
+ const controller = useViewportController({
1124
+ hostRef,
1125
+ worldRef,
1126
+ computeFit: computeFitForArtboards,
1127
+ getInitial,
1128
+ onSettle,
1129
+ jumpTargets: artboards,
1130
+ isPanDragActive,
1131
+ });
1132
+
1133
+ const rectById = useMemo(() => {
1134
+ const m = new Map<string, ArtboardRect>();
1135
+ for (const r of artboards) m.set(r.id, r);
1136
+ return m;
1137
+ }, [artboards]);
1138
+
1139
+ const rectFor = useCallback((id: string) => rectById.get(id) ?? null, [rectById]);
1140
+
1141
+ // Active artboard — the one whose center sits closest to the viewport
1142
+ // center after pan settles. Recomputed on every viewport publish (~50 ms).
1143
+ const activeArtboardId = useMemo<string | null>(() => {
1144
+ if (artboards.length === 0) return null;
1145
+ const host = hostRef.current;
1146
+ if (!host) return artboards[0]?.id ?? null;
1147
+ const vp = controller.viewport;
1148
+ const cx = (host.clientWidth / 2 - vp.x) / vp.zoom;
1149
+ const cy = (host.clientHeight / 2 - vp.y) / vp.zoom;
1150
+ let bestId: string | null = null;
1151
+ let bestDist = Number.POSITIVE_INFINITY;
1152
+ for (const r of artboards) {
1153
+ const ax = r.x + r.w / 2;
1154
+ const ay = r.y + r.h / 2;
1155
+ const d = (ax - cx) ** 2 + (ay - cy) ** 2;
1156
+ if (d < bestDist) {
1157
+ bestDist = d;
1158
+ bestId = r.id;
1159
+ }
1160
+ }
1161
+ return bestId;
1162
+ }, [artboards, controller.viewport]);
1163
+
1164
+ // The world's transform is owned by useViewportController (writes straight
1165
+ // to `worldRef.current.style.transform`). Rendering the transform from
1166
+ // React state instead would race: between React's commit and the
1167
+ // controller's next synchronous write, the world would snap back to a
1168
+ // stale published value. We start hidden and the controller's
1169
+ // useLayoutEffect writes the initial transform before first paint.
1170
+ const worldStyle: CSSProperties = { visibility: 'hidden' };
1171
+
1172
+ const ctxValue = useMemo<WorldContextValue>(
1173
+ () => ({
1174
+ rectFor,
1175
+ artboards,
1176
+ viewport: controller.viewport,
1177
+ activeArtboardId,
1178
+ hostRef,
1179
+ worldRef,
1180
+ }),
1181
+ [rectFor, artboards, controller.viewport, activeArtboardId]
1182
+ );
1183
+
1184
+ const showMiniMap = controls?.minimap !== false;
1185
+ const showToolbar = controls?.toolbar !== false;
1186
+
1187
+ // Drag-state bus (Phase 4.2). Single source of truth: only one artboard
1188
+ // drag is active at a time. DCArtboards write here when their local drag
1189
+ // hook is non-idle; SnapGuideOverlay (in canvas-shell) reads guides.
1190
+ const [dragCurrent, setDragCurrent] = useState<DragState>({ kind: 'idle' });
1191
+
1192
+ const commitArtboardPositions = useCallback((moved: { id: string; x: number; y: number }[]) => {
1193
+ const movedById = new Map(moved.map((m) => [m.id, m]));
1194
+ const next = artboardsRef.current.map((r) => {
1195
+ const m = movedById.get(r.id);
1196
+ if (m) return { ...r, x: m.x, y: m.y };
1197
+ return r;
1198
+ });
1199
+ // Optimistic local update — DOM reflects the new position the moment
1200
+ // the drag drops, no iframe reload required. The PATCH below catches
1201
+ // the server up; if it fails we already logged it via `patchCanvasMeta`.
1202
+ setArtboards(next);
1203
+ patchCanvasMeta({ layout: { artboards: next } });
1204
+ }, []);
1205
+
1206
+ const dragBus = useMemo<DragStateBus>(
1207
+ () => ({
1208
+ current: dragCurrent,
1209
+ setCurrent: setDragCurrent,
1210
+ commitPositions: commitArtboardPositions,
1211
+ }),
1212
+ [dragCurrent, commitArtboardPositions]
1213
+ );
1214
+
1215
+ const inner = (
1216
+ <div className="dc-canvas" ref={hostRef}>
1217
+ <div className="dc-world" ref={worldRef} style={worldStyle}>
1218
+ {children}
1219
+ </div>
1220
+ {showMiniMap ? <DCMiniMap /> : null}
1221
+ {/* DCZoomToolbar is intentionally not rendered here. The Phase 5.1
1222
+ ToolPalette absorbs its 4 actions into the unified canvas chrome.
1223
+ The component stays exported for back-compat with any consumer that
1224
+ still imports it directly. */}
1225
+ </div>
1226
+ );
1227
+
1228
+ return (
1229
+ <WorldContext.Provider value={ctxValue}>
1230
+ <ControllerContext.Provider value={controller}>
1231
+ <DragStateContext.Provider value={dragBus}>
1232
+ <CanvasShell hostRef={hostRef}>{inner}</CanvasShell>
1233
+ </DragStateContext.Provider>
1234
+ </ControllerContext.Provider>
1235
+ </WorldContext.Provider>
1236
+ );
1237
+ }
1238
+ DesignCanvasInner.displayName = 'DesignCanvasInner';
1239
+
1240
+ export function DCSection({
1241
+ id,
1242
+ title,
1243
+ subtitle,
1244
+ children,
1245
+ }: {
1246
+ id: string;
1247
+ title: string;
1248
+ subtitle?: string;
1249
+ children: ReactNode;
1250
+ }) {
1251
+ const ctx = useWorldContext();
1252
+ if (ctx) {
1253
+ // Inside DesignCanvas: DCSection is purely metadata. Its title + subtitle
1254
+ // are stashed as data-* on a `display: contents` wrapper so inspector
1255
+ // selectors still resolve, but the wrapper imposes no layout — DCArtboard
1256
+ // children take their own world-coord positions.
1257
+ return (
1258
+ <div
1259
+ className="dc-section dc-section-collapsed"
1260
+ data-dc-section={id}
1261
+ data-dc-section-title={title}
1262
+ data-dc-section-subtitle={subtitle ?? ''}
1263
+ >
1264
+ {children}
1265
+ </div>
1266
+ );
1267
+ }
1268
+ return (
1269
+ <section className="dc-section" data-dc-section={id}>
1270
+ <header>
1271
+ <h2>{title}</h2>
1272
+ {subtitle ? <p className="sku">{subtitle}</p> : null}
1273
+ </header>
1274
+ <div className="dc-section-body">{children}</div>
1275
+ </section>
1276
+ );
1277
+ }
1278
+ DCSection.displayName = 'DCSection';
1279
+
1280
+ /**
1281
+ * Bordered artboard with a SKU-strip header. Inside DesignCanvas its world
1282
+ * position comes from meta.layout (or the synthesized default grid); the
1283
+ * given `width` + `height` props are honored only as a fallback when the
1284
+ * layout has no matching id. Outside DesignCanvas (DS specimens, legacy
1285
+ * uses) it renders a plain fixed-size block.
1286
+ */
1287
+ export function DCArtboard({
1288
+ id,
1289
+ label,
1290
+ width,
1291
+ height,
1292
+ children,
1293
+ }: {
1294
+ id: string;
1295
+ label: string;
1296
+ width: number;
1297
+ height: number;
1298
+ children: ReactNode;
1299
+ }) {
1300
+ const ctx = useWorldContext();
1301
+ const controller = useViewportControllerContext();
1302
+ const toolMode = useToolModeOptional();
1303
+ const selSet = useSelectionSetOptional();
1304
+ const dragBus = useDragStateContext();
1305
+ const rect = ctx ? ctx.rectFor(id) : null;
1306
+
1307
+ // Drag hook — always called (hook rules). Inert outside DesignCanvas
1308
+ // (allRects empty, enabled=false), so specimens / legacy uses get a plain
1309
+ // fixed-size block as before.
1310
+ const dragHook = useArtboardDrag({
1311
+ artboardId: id,
1312
+ selected: selSet?.selected ?? [],
1313
+ rectFor: (rid) => (ctx ? ctx.rectFor(rid) : null),
1314
+ allRects: ctx?.artboards ?? [],
1315
+ viewport: ctx?.viewport ?? null,
1316
+ enabled: !!ctx && (toolMode?.tool ?? 'move') === 'move',
1317
+ onCommit: (moved) => {
1318
+ if (dragBus) dragBus.commitPositions(moved);
1319
+ },
1320
+ });
1321
+
1322
+ // Publish this artboard's drag state to the bus. Only push when non-idle;
1323
+ // when our local state returns to idle AFTER having been non-idle, push
1324
+ // one final idle update so the bus clears.
1325
+ const wasNonIdleRef = useRef(false);
1326
+ useEffect(() => {
1327
+ if (!dragBus) return;
1328
+ const s = dragHook.dragState;
1329
+ if (s.kind !== 'idle') {
1330
+ dragBus.setCurrent(s);
1331
+ wasNonIdleRef.current = true;
1332
+ } else if (wasNonIdleRef.current) {
1333
+ dragBus.setCurrent({ kind: 'idle' });
1334
+ wasNonIdleRef.current = false;
1335
+ }
1336
+ }, [dragHook.dragState, dragBus]);
1337
+
1338
+ if (!ctx || !rect) {
1339
+ return (
1340
+ <article className="dc-artboard" data-dc-screen={id} style={{ width, height }}>
1341
+ <header className="dc-artboard-label sku">{label}</header>
1342
+ <div className="dc-artboard-body">{children}</div>
1343
+ </article>
1344
+ );
1345
+ }
1346
+ const isActive = ctx.activeArtboardId === id;
1347
+ const onFocus = () => {
1348
+ if (controller) controller.jumpTo(rect);
1349
+ };
1350
+
1351
+ // Am I involved in the current drag (as leader or follower)?
1352
+ const busDrag = dragBus?.current;
1353
+ const isLeader = busDrag?.kind === 'dragging' && busDrag.leaderId === id;
1354
+ const followerOffset =
1355
+ busDrag?.kind === 'dragging' ? busDrag.followers.find((f) => f.id === id) : undefined;
1356
+ const isFollower = !!followerOffset;
1357
+ const isInDrag = isLeader || isFollower;
1358
+
1359
+ // Ghost position (world coords).
1360
+ let ghostX = 0;
1361
+ let ghostY = 0;
1362
+ if (busDrag?.kind === 'dragging') {
1363
+ if (isLeader) {
1364
+ ghostX = busDrag.leaderRect.x;
1365
+ ghostY = busDrag.leaderRect.y;
1366
+ } else if (isFollower && followerOffset) {
1367
+ ghostX = busDrag.leaderRect.x + followerOffset.offsetX;
1368
+ ghostY = busDrag.leaderRect.y + followerOffset.offsetY;
1369
+ }
1370
+ }
1371
+
1372
+ const handleProps = dragHook.bindHandle();
1373
+
1374
+ return (
1375
+ <>
1376
+ <article
1377
+ className={`dc-artboard dc-positioned${isInDrag ? ' dc-dragging' : ''}`}
1378
+ data-dc-screen={id}
1379
+ aria-current={isActive ? 'true' : undefined}
1380
+ style={{ left: rect.x, top: rect.y, width: rect.w, height: rect.h }}
1381
+ {...handleProps}
1382
+ >
1383
+ <button
1384
+ type="button"
1385
+ className="dc-artboard-label sku"
1386
+ onClick={onFocus}
1387
+ aria-label={`Focus artboard ${label}`}
1388
+ >
1389
+ {label}
1390
+ </button>
1391
+ <div className="dc-artboard-body">{children}</div>
1392
+ </article>
1393
+ {isInDrag ? (
1394
+ <div
1395
+ className="dc-artboard-ghost"
1396
+ aria-hidden="true"
1397
+ style={{ left: ghostX, top: ghostY, width: rect.w, height: rect.h }}
1398
+ >
1399
+ <div className="dc-artboard-ghost-label">{label}</div>
1400
+ </div>
1401
+ ) : null}
1402
+ </>
1403
+ );
1404
+ }
1405
+ DCArtboard.displayName = 'DCArtboard';
1406
+
1407
+ // ─────────────────────────────────────────────────────────────────────────────
1408
+ // SnapGuideOverlay (Phase 4.2) — renders 1 px guide lines while a drag is in
1409
+ // flight. Mounted by canvas-shell as a chrome layer outside `.dc-world`, so
1410
+ // the lines are in screen coords (no CSS-zoom subpixel weirdness). Guides
1411
+ // come from `dragBus.current.snap.guides`; world→screen projection uses the
1412
+ // live viewport (`v.x + worldCoord * v.zoom` — same convention as `writeTransform`).
1413
+
1414
+ export function SnapGuideOverlay() {
1415
+ const dragBus = useDragStateContext();
1416
+ const world = useWorldContext();
1417
+ if (!dragBus || !world) return null;
1418
+ const s = dragBus.current;
1419
+ if (s.kind !== 'dragging') return null;
1420
+ const vp = world.viewport;
1421
+ if (!vp) return null;
1422
+ return (
1423
+ <>
1424
+ {s.snap.guides.map((g, i) => {
1425
+ if (g.axis === 'x') {
1426
+ const sx = vp.x + g.pos * vp.zoom;
1427
+ const sFrom = vp.y + g.from * vp.zoom;
1428
+ const sTo = vp.y + g.to * vp.zoom;
1429
+ return (
1430
+ <div
1431
+ // biome-ignore lint/suspicious/noArrayIndexKey: guides are positional
1432
+ key={`x-${i}`}
1433
+ className="dc-snap-guide"
1434
+ style={{
1435
+ position: 'fixed',
1436
+ pointerEvents: 'none',
1437
+ background: 'var(--accent, #d63b1f)',
1438
+ left: sx,
1439
+ top: sFrom,
1440
+ width: 1,
1441
+ height: Math.max(1, sTo - sFrom),
1442
+ zIndex: 6,
1443
+ }}
1444
+ aria-hidden="true"
1445
+ />
1446
+ );
1447
+ }
1448
+ const sy = vp.y + g.pos * vp.zoom;
1449
+ const sFrom = vp.x + g.from * vp.zoom;
1450
+ const sTo = vp.x + g.to * vp.zoom;
1451
+ return (
1452
+ <div
1453
+ // biome-ignore lint/suspicious/noArrayIndexKey: guides are positional
1454
+ key={`y-${i}`}
1455
+ className="dc-snap-guide"
1456
+ style={{
1457
+ position: 'fixed',
1458
+ pointerEvents: 'none',
1459
+ background: 'var(--accent, #d63b1f)',
1460
+ left: sFrom,
1461
+ top: sy,
1462
+ width: Math.max(1, sTo - sFrom),
1463
+ height: 1,
1464
+ zIndex: 6,
1465
+ }}
1466
+ aria-hidden="true"
1467
+ />
1468
+ );
1469
+ })}
1470
+ </>
1471
+ );
1472
+ }
1473
+ SnapGuideOverlay.displayName = 'SnapGuideOverlay';
1474
+
1475
+ export function DCPostIt({ children }: { children: ReactNode }) {
1476
+ return <aside className="dc-postit">{children}</aside>;
1477
+ }
1478
+
1479
+ // ─────────────────────────────────────────────────────────────────────────────
1480
+ // Floating overlays (Phase 4 T3) — outside `.dc-world`, so they stay fixed
1481
+ // to the canvas iframe chrome while the world pans/zooms underneath. Mounted
1482
+ // by DesignCanvas; consumers opt out per-overlay via `<DesignCanvas controls>`.
1483
+ // Styling lives inline so the engine drops into ANY DS without requiring
1484
+ // `.dc-mm` / `.dc-zoom-tb` rules in `_components.css`. CV-01 references the
1485
+ // same vocabulary; if a DS wants to restyle, it can target `.dc-mm` /
1486
+ // `.dc-zoom-tb` directly.
1487
+
1488
+ const OVERLAY_CSS = `
1489
+ .dc-mm {
1490
+ position: absolute;
1491
+ right: 16px;
1492
+ bottom: 16px;
1493
+ width: 196px;
1494
+ height: 132px;
1495
+ background: var(--bg-1, rgba(255,255,255,0.98));
1496
+ border: 1px solid var(--u-border-2, rgba(0,0,0,0.08));
1497
+ border-radius: 8px;
1498
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1499
+ font-size: 10px;
1500
+ color: rgba(40,30,20,0.7);
1501
+ z-index: 6;
1502
+ user-select: none;
1503
+ box-shadow: 0 6px 24px rgba(0,0,0,0.08);
1504
+ overflow: hidden;
1505
+ }
1506
+ .dc-mm-hd {
1507
+ padding: 5px 8px 4px;
1508
+ border-bottom: 1px solid rgba(0,0,0,0.08);
1509
+ letter-spacing: 0.05em;
1510
+ text-transform: uppercase;
1511
+ font-size: 9px;
1512
+ }
1513
+ .dc-mm-body {
1514
+ position: relative;
1515
+ width: 100%;
1516
+ height: calc(100% - 22px);
1517
+ overflow: hidden;
1518
+ cursor: pointer;
1519
+ }
1520
+ .dc-mm-rect {
1521
+ position: absolute;
1522
+ background: rgba(0,0,0,0.06);
1523
+ border: 1px solid rgba(0,0,0,0.18);
1524
+ }
1525
+ .dc-mm-vp {
1526
+ position: absolute;
1527
+ border: 2px solid #d63b1f;
1528
+ pointer-events: none;
1529
+ }
1530
+ .dc-zoom-tb {
1531
+ position: absolute;
1532
+ left: 50%;
1533
+ bottom: 16px;
1534
+ transform: translateX(-50%);
1535
+ display: flex;
1536
+ align-items: stretch;
1537
+ background: rgba(255,255,255,0.94);
1538
+ border: 1px solid rgba(0,0,0,0.12);
1539
+ border-radius: 6px;
1540
+ overflow: hidden;
1541
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1542
+ font-size: 11px;
1543
+ color: rgba(40,30,20,0.85);
1544
+ z-index: 6;
1545
+ box-shadow: 0 4px 16px rgba(0,0,0,0.06);
1546
+ }
1547
+ .dc-zoom-tb button {
1548
+ appearance: none;
1549
+ background: transparent;
1550
+ border: 0;
1551
+ border-right: 1px solid rgba(0,0,0,0.08);
1552
+ padding: 7px 12px;
1553
+ font: inherit;
1554
+ color: inherit;
1555
+ cursor: pointer;
1556
+ min-width: 36px;
1557
+ text-align: center;
1558
+ }
1559
+ .dc-zoom-tb button:last-child { border-right: 0; }
1560
+ .dc-zoom-tb button:hover { background: rgba(0,0,0,0.04); }
1561
+ .dc-zoom-tb button:focus-visible { outline: 2px solid #d63b1f; outline-offset: -2px; }
1562
+ .dc-zoom-tb-pct { font-variant-numeric: tabular-nums; min-width: 52px; }
1563
+ `.trim();
1564
+
1565
+ function ensureOverlayStyles(): void {
1566
+ if (typeof document === 'undefined') return;
1567
+ if (document.getElementById('dc-overlay-css')) return;
1568
+ const s = document.createElement('style');
1569
+ s.id = 'dc-overlay-css';
1570
+ s.textContent = OVERLAY_CSS;
1571
+ document.head.appendChild(s);
1572
+ }
1573
+
1574
+ interface MiniMapGeometry {
1575
+ scale: number;
1576
+ offsetX: number;
1577
+ offsetY: number;
1578
+ bbox: { x: number; y: number; w: number; h: number };
1579
+ }
1580
+
1581
+ function computeMiniMapGeometry(
1582
+ artboards: ArtboardRect[],
1583
+ mapW: number,
1584
+ mapH: number,
1585
+ pad = 6
1586
+ ): MiniMapGeometry {
1587
+ if (artboards.length === 0) {
1588
+ return { scale: 1, offsetX: 0, offsetY: 0, bbox: { x: 0, y: 0, w: 0, h: 0 } };
1589
+ }
1590
+ let xMin = Number.POSITIVE_INFINITY;
1591
+ let yMin = Number.POSITIVE_INFINITY;
1592
+ let xMax = Number.NEGATIVE_INFINITY;
1593
+ let yMax = Number.NEGATIVE_INFINITY;
1594
+ for (const r of artboards) {
1595
+ if (r.x < xMin) xMin = r.x;
1596
+ if (r.y < yMin) yMin = r.y;
1597
+ if (r.x + r.w > xMax) xMax = r.x + r.w;
1598
+ if (r.y + r.h > yMax) yMax = r.y + r.h;
1599
+ }
1600
+ const bw = Math.max(1, xMax - xMin);
1601
+ const bh = Math.max(1, yMax - yMin);
1602
+ const scale = Math.min((mapW - pad * 2) / bw, (mapH - pad * 2) / bh);
1603
+ const offsetX = pad + (mapW - pad * 2 - bw * scale) / 2 - xMin * scale;
1604
+ const offsetY = pad + (mapH - pad * 2 - bh * scale) / 2 - yMin * scale;
1605
+ return { scale, offsetX, offsetY, bbox: { x: xMin, y: yMin, w: bw, h: bh } };
1606
+ }
1607
+
1608
+ /**
1609
+ * Bottom-right floating world map. Renders every DCArtboard rect scaled-to-fit
1610
+ * plus a red viewport indicator. Click-drag inside the map pans the main view;
1611
+ * click outside the viewport rect recenters on that point. Decorative for
1612
+ * accessibility — SR users navigate via DCArtboard label buttons (T4).
1613
+ */
1614
+ export function DCMiniMap() {
1615
+ ensureOverlayStyles();
1616
+ const world = useWorldContext();
1617
+ const controller = useViewportControllerContext();
1618
+ const bodyRef = useRef<HTMLDivElement | null>(null);
1619
+ // 132 - 22 (header) = 110 body height; width matches the chrome.
1620
+ const MAP_W = 196;
1621
+ const MAP_BODY_H = 110;
1622
+ const dragRef = useRef<{ active: boolean; pointerId: number }>({
1623
+ active: false,
1624
+ pointerId: -1,
1625
+ });
1626
+
1627
+ if (!world || !controller) return null;
1628
+
1629
+ const geometry = computeMiniMapGeometry(world.artboards, MAP_W, MAP_BODY_H);
1630
+ const host = world.hostRef.current;
1631
+ const vp = controller.viewport;
1632
+
1633
+ // Visible-area rect in world coords, then projected into map coords.
1634
+ let vpRect: { left: number; top: number; w: number; h: number } | null = null;
1635
+ if (host && Number.isFinite(vp.zoom) && vp.zoom > 0) {
1636
+ const wLeft = -vp.x / vp.zoom;
1637
+ const wTop = -vp.y / vp.zoom;
1638
+ const wW = host.clientWidth / vp.zoom;
1639
+ const wH = host.clientHeight / vp.zoom;
1640
+ vpRect = {
1641
+ left: wLeft * geometry.scale + geometry.offsetX,
1642
+ top: wTop * geometry.scale + geometry.offsetY,
1643
+ w: wW * geometry.scale,
1644
+ h: wH * geometry.scale,
1645
+ };
1646
+ }
1647
+
1648
+ function mapToWorld(mx: number, my: number): { x: number; y: number } {
1649
+ return {
1650
+ x: (mx - geometry.offsetX) / geometry.scale,
1651
+ y: (my - geometry.offsetY) / geometry.scale,
1652
+ };
1653
+ }
1654
+
1655
+ function centerOnWorld(wx: number, wy: number) {
1656
+ const h = world?.hostRef.current;
1657
+ const c = controller;
1658
+ if (!h || !c) return;
1659
+ const cur = c.viewport;
1660
+ c.setViewport({
1661
+ x: h.clientWidth / 2 - wx * cur.zoom,
1662
+ y: h.clientHeight / 2 - wy * cur.zoom,
1663
+ zoom: cur.zoom,
1664
+ });
1665
+ }
1666
+
1667
+ const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
1668
+ const body = bodyRef.current;
1669
+ if (!body) return;
1670
+ const r = body.getBoundingClientRect();
1671
+ const mx = e.clientX - r.left;
1672
+ const my = e.clientY - r.top;
1673
+ const w = mapToWorld(mx, my);
1674
+ centerOnWorld(w.x, w.y);
1675
+ try {
1676
+ body.setPointerCapture(e.pointerId);
1677
+ } catch {
1678
+ /* ignore */
1679
+ }
1680
+ dragRef.current.active = true;
1681
+ dragRef.current.pointerId = e.pointerId;
1682
+ };
1683
+ const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
1684
+ if (!dragRef.current.active || e.pointerId !== dragRef.current.pointerId) return;
1685
+ const body = bodyRef.current;
1686
+ if (!body) return;
1687
+ const r = body.getBoundingClientRect();
1688
+ const w = mapToWorld(e.clientX - r.left, e.clientY - r.top);
1689
+ centerOnWorld(w.x, w.y);
1690
+ };
1691
+ const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
1692
+ if (!dragRef.current.active) return;
1693
+ dragRef.current.active = false;
1694
+ try {
1695
+ bodyRef.current?.releasePointerCapture(e.pointerId);
1696
+ } catch {
1697
+ /* ignore */
1698
+ }
1699
+ };
1700
+
1701
+ return (
1702
+ <div className="dc-mm" aria-hidden="true">
1703
+ <div className="dc-mm-hd">
1704
+ WORLD MAP · {world.artboards.length}/{world.artboards.length}
1705
+ </div>
1706
+ <div
1707
+ className="dc-mm-body"
1708
+ ref={bodyRef}
1709
+ onPointerDown={onPointerDown}
1710
+ onPointerMove={onPointerMove}
1711
+ onPointerUp={endDrag}
1712
+ onPointerCancel={endDrag}
1713
+ >
1714
+ {world.artboards.map((r) => (
1715
+ <div
1716
+ key={r.id}
1717
+ className="dc-mm-rect"
1718
+ style={{
1719
+ left: r.x * geometry.scale + geometry.offsetX,
1720
+ top: r.y * geometry.scale + geometry.offsetY,
1721
+ width: r.w * geometry.scale,
1722
+ height: r.h * geometry.scale,
1723
+ }}
1724
+ />
1725
+ ))}
1726
+ {vpRect ? (
1727
+ <div
1728
+ className="dc-mm-vp"
1729
+ style={{ left: vpRect.left, top: vpRect.top, width: vpRect.w, height: vpRect.h }}
1730
+ />
1731
+ ) : null}
1732
+ </div>
1733
+ </div>
1734
+ );
1735
+ }
1736
+ DCMiniMap.displayName = 'DCMiniMap';
1737
+
1738
+ /**
1739
+ * Bottom-center floating toolbar — zoom out · current % · zoom in · fit · 1:1.
1740
+ * Clicking the % indicator resets to 100 %.
1741
+ */
1742
+ export function DCZoomToolbar() {
1743
+ ensureOverlayStyles();
1744
+ const controller = useViewportControllerContext();
1745
+ if (!controller) return null;
1746
+ const pct = Math.round(controller.viewport.zoom * 100);
1747
+ return (
1748
+ <div className="dc-zoom-tb" role="toolbar" aria-label="Zoom">
1749
+ <button type="button" onClick={controller.zoomOut} aria-label="Zoom out">
1750
+
1751
+ </button>
1752
+ <button
1753
+ type="button"
1754
+ className="dc-zoom-tb-pct"
1755
+ onClick={controller.reset}
1756
+ aria-label={`Zoom ${pct}%, click to reset to 100%`}
1757
+ >
1758
+ {pct}%
1759
+ </button>
1760
+ <button type="button" onClick={controller.zoomIn} aria-label="Zoom in">
1761
+ +
1762
+ </button>
1763
+ <button type="button" onClick={controller.fit} aria-label="Fit to screen">
1764
+ [ ]
1765
+ </button>
1766
+ <button type="button" onClick={controller.reset} aria-label="Actual size">
1767
+ 1:1
1768
+ </button>
1769
+ </div>
1770
+ );
1771
+ }
1772
+ DCZoomToolbar.displayName = 'DCZoomToolbar';
1773
+
1774
+ // ─────────────────────────────────────────────────────────────────────────────
1775
+ // Specimen helpers
1776
+
1777
+ /** SKU + breadcrumb trail + optional ThemeToggle. Maps to `.specimen-hd`. */
1778
+ export function SpecimenHeader({
1779
+ sku,
1780
+ crumbs,
1781
+ showThemeToggle = true,
1782
+ }: {
1783
+ sku: string;
1784
+ crumbs: string[];
1785
+ showThemeToggle?: boolean;
1786
+ }) {
1787
+ return (
1788
+ <header className="specimen-hd">
1789
+ <span className="sku">{sku}</span>
1790
+ <span className="crumbs">
1791
+ {crumbs.map((c, i) => (
1792
+ // biome-ignore lint/suspicious/noArrayIndexKey: crumbs may repeat; index disambiguates static breadcrumb labels.
1793
+ <span key={`${c}-${i}`}>{c}</span>
1794
+ ))}
1795
+ </span>
1796
+ {showThemeToggle ? <ThemeToggle /> : null}
1797
+ </header>
1798
+ );
1799
+ }
1800
+
1801
+ /** `<dl class="specimen-meta">` ladder. */
1802
+ export function SpecimenMeta({
1803
+ entries,
1804
+ }: {
1805
+ entries: Array<{ label: string; value: ReactNode }>;
1806
+ }) {
1807
+ return (
1808
+ <dl className="specimen-meta">
1809
+ {entries.map(({ label, value }) => (
1810
+ <div key={label}>
1811
+ <dt>{label}</dt>
1812
+ <dd>{value}</dd>
1813
+ </div>
1814
+ ))}
1815
+ </dl>
1816
+ );
1817
+ }
1818
+
1819
+ /** <kbd> chrome — keyboard hint. */
1820
+ export function KbdHint({ children }: { children: ReactNode }) {
1821
+ return <kbd>{children}</kbd>;
1822
+ }
1823
+
1824
+ /** Inline `var(--name)` value visualiser — small chip + token name. */
1825
+ export function TokenChip({
1826
+ name,
1827
+ swatch,
1828
+ }: {
1829
+ name: string;
1830
+ swatch?: boolean;
1831
+ }) {
1832
+ return (
1833
+ <span className="token-chip" data-token={name}>
1834
+ {swatch ? (
1835
+ <span className="token-chip-swatch" style={{ background: `var(${name})` }} />
1836
+ ) : null}
1837
+ <code>{name}</code>
1838
+ </span>
1839
+ );
1840
+ }
1841
+
1842
+ /** Color swatch — square + token label + optional caption. */
1843
+ export function ColorSwatch({
1844
+ token,
1845
+ caption,
1846
+ height = 96,
1847
+ }: {
1848
+ token: string;
1849
+ caption?: ReactNode;
1850
+ height?: number;
1851
+ }) {
1852
+ return (
1853
+ <div className="swatch">
1854
+ <div className="chip" style={{ background: `var(${token})`, height }} />
1855
+ <div className="meta">
1856
+ <strong>{token}</strong>
1857
+ {caption ? <span className="oklch">{caption}</span> : null}
1858
+ </div>
1859
+ </div>
1860
+ );
1861
+ }
1862
+
1863
+ /** Single row of a type-ladder specimen — label + sample at given token. */
1864
+ export function TypeScaleRow({
1865
+ token,
1866
+ label,
1867
+ sample,
1868
+ }: {
1869
+ token: string;
1870
+ label: string;
1871
+ sample?: string;
1872
+ }) {
1873
+ return (
1874
+ <div className="type-row" data-token={token}>
1875
+ <span className="sku">{label}</span>
1876
+ <span className="type-sample" style={{ fontSize: `var(${token})` }}>
1877
+ {sample ?? 'The quick brown fox jumps over the lazy dog'}
1878
+ </span>
1879
+ </div>
1880
+ );
1881
+ }
1882
+
1883
+ /** Light/dark toggle. Writes `data-theme` on `<html>` and persists to memory. */
1884
+ export function ThemeToggle() {
1885
+ const { theme, setTheme } = useTheme();
1886
+ return (
1887
+ <span className="theme-toggle" role="tablist" aria-label="Theme">
1888
+ <button
1889
+ type="button"
1890
+ data-theme="light"
1891
+ aria-pressed={theme === 'light'}
1892
+ onClick={() => setTheme('light')}
1893
+ >
1894
+ LIGHT
1895
+ </button>
1896
+ <button
1897
+ type="button"
1898
+ data-theme="dark"
1899
+ aria-pressed={theme === 'dark'}
1900
+ onClick={() => setTheme('dark')}
1901
+ >
1902
+ DARK
1903
+ </button>
1904
+ </span>
1905
+ );
1906
+ }
1907
+
1908
+ // ─────────────────────────────────────────────────────────────────────────────
1909
+ // Hooks
1910
+
1911
+ /**
1912
+ * Read resolved CSS custom property values from `<html>`. Returns the full set
1913
+ * when prefix is omitted; otherwise filters to vars beginning with `--<prefix>`.
1914
+ * Re-resolves on `data-theme` mutation.
1915
+ */
1916
+ export function useTokens(prefix?: string): Record<string, string> {
1917
+ const [tokens, setTokens] = useState<Record<string, string>>({});
1918
+ useEffect(() => {
1919
+ if (typeof window === 'undefined') return;
1920
+ function read() {
1921
+ const root = document.documentElement;
1922
+ const cs = getComputedStyle(root);
1923
+ const out: Record<string, string> = {};
1924
+ const len = cs.length;
1925
+ for (let i = 0; i < len; i++) {
1926
+ const name = cs.item(i);
1927
+ if (!name.startsWith('--')) continue;
1928
+ if (prefix && !name.startsWith(`--${prefix}`)) continue;
1929
+ out[name] = cs.getPropertyValue(name).trim();
1930
+ }
1931
+ setTokens(out);
1932
+ }
1933
+ read();
1934
+ const mo = new MutationObserver(read);
1935
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
1936
+ return () => mo.disconnect();
1937
+ }, [prefix]);
1938
+ return tokens;
1939
+ }
1940
+
1941
+ /**
1942
+ * Current theme + setter. Mirrors the `data-theme` attribute on `<html>`.
1943
+ * Defaults to whatever attribute is already set (or "light"). No persistence
1944
+ * to localStorage — canvases are ephemeral; specimens reset per-load.
1945
+ */
1946
+ export function useTheme(): { theme: string; setTheme: (t: string) => void } {
1947
+ const [theme, setThemeState] = useState<string>(() => {
1948
+ if (typeof document === 'undefined') return 'light';
1949
+ return document.documentElement.dataset.theme ?? 'light';
1950
+ });
1951
+ const setTheme = useCallback((t: string) => {
1952
+ if (typeof document !== 'undefined') {
1953
+ document.documentElement.dataset.theme = t;
1954
+ }
1955
+ setThemeState(t);
1956
+ }, []);
1957
+ useLayoutEffect(() => {
1958
+ if (typeof document === 'undefined') return;
1959
+ const obs = new MutationObserver(() => {
1960
+ const t = document.documentElement.dataset.theme ?? 'light';
1961
+ setThemeState(t);
1962
+ });
1963
+ obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
1964
+ return () => obs.disconnect();
1965
+ }, []);
1966
+ return useMemo(() => ({ theme, setTheme }), [theme, setTheme]);
1967
+ }
1968
+
1969
+ /**
1970
+ * ResizeObserver wrapper. Pass a ref to any element (typically the active
1971
+ * artboard); returns its current `{ width, height }` in CSS pixels.
1972
+ */
1973
+ export function useArtboardBounds(ref: RefObject<HTMLElement | null>): {
1974
+ width: number;
1975
+ height: number;
1976
+ } {
1977
+ const [bounds, setBounds] = useState({ width: 0, height: 0 });
1978
+ useEffect(() => {
1979
+ const el = ref.current;
1980
+ if (!el || typeof ResizeObserver === 'undefined') return;
1981
+ const ro = new ResizeObserver((entries) => {
1982
+ const e = entries[0];
1983
+ if (!e) return;
1984
+ const r = e.contentRect;
1985
+ setBounds({ width: r.width, height: r.height });
1986
+ });
1987
+ ro.observe(el);
1988
+ return () => ro.disconnect();
1989
+ }, [ref]);
1990
+ return bounds;
1991
+ }
1992
+
1993
+ // Re-export `useRef` so `useArtboardBounds` consumers can keep a single
1994
+ // import line from `@maude/canvas-lib`.
1995
+ export { useRef };