@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,445 @@
1
+ /**
2
+ * @file use-artboard-drag.tsx — Phase 4.2 artboard drag controller
3
+ * @scope plugins/design/dev-server/use-artboard-drag.tsx
4
+ * @purpose Owns the pointerdown → pointermove → pointerup state machine
5
+ * for dragging artboards on the infinite canvas. The pure
6
+ * `dragReducer` + `commitFromState` are unit-testable without a
7
+ * DOM. The `useArtboardDrag` hook attaches the listeners + wires
8
+ * commit on settle.
9
+ *
10
+ * Why a separate listener stack (not the input-router)?
11
+ * `input-router.classify()` is per-event-stateless — drag is a multi-event
12
+ * state machine (down → move ×N → up). Owning its own listeners mirrors
13
+ * `useViewportController`. The two stacks coexist:
14
+ *
15
+ * - router owns: hover, Cmd-select, right-click, V/H/C/Esc.
16
+ * - viewport-controller owns: wheel, middle-mouse pan, Space pan, Cmd+0/1.
17
+ * - this hook owns: pointerdown over artboard chrome (label + border) +
18
+ * its own pointermove/up while a drag is in flight.
19
+ *
20
+ * Click-vs-drag classifier: a 4 px screen-pixel threshold separates a click
21
+ * (delta < 4 px → label `onClick` fires, pan-to-focus) from a drag (delta
22
+ * ≥ 4 px → preventDefault on the synthetic click, commit positions on up).
23
+ *
24
+ * Multi-select: when `artboardId` (the drag leader) is in `selected`, every
25
+ * other artboard whose id matches a selection moves rigidly with the leader
26
+ * (relative offsets captured at drag-start).
27
+ */
28
+
29
+ import {
30
+ type HTMLAttributes,
31
+ type PointerEvent as ReactPointerEvent,
32
+ useCallback,
33
+ useEffect,
34
+ useMemo,
35
+ useRef,
36
+ useState,
37
+ } from 'react';
38
+
39
+ import type { Selection } from './use-selection-set.tsx';
40
+ import { type Rect, type SnapResult, computeSnap } from './use-snap-guides.tsx';
41
+
42
+ // ─────────────────────────────────────────────────────────────────────────────
43
+ // Constants
44
+
45
+ /** Screen-pixel distance the cursor must travel before pending → dragging. */
46
+ export const DRAG_THRESHOLD_PX = 4;
47
+
48
+ /** Default grid + tolerance (world units). Documented in DDR-028. */
49
+ export const DEFAULT_GRID_SIZE = 40;
50
+ export const DEFAULT_SNAP_TOLERANCE = 8;
51
+
52
+ /** How long the one-shot click suppressor stays armed after a drag's pointerup.
53
+ * Long enough to catch the synthetic `click` that follows pointerup; short
54
+ * enough that a legitimate later click on the same element still fires. */
55
+ export const CLICK_SUPPRESS_TIMEOUT_MS = 300;
56
+
57
+ // ─────────────────────────────────────────────────────────────────────────────
58
+ // Types
59
+
60
+ export interface DragTargetSnapshot {
61
+ id: string;
62
+ /** Offset from the leader's starting top-left, in world units. */
63
+ offsetX: number;
64
+ offsetY: number;
65
+ /** Captured starting rect — used for ghost rendering. */
66
+ startRect: Rect;
67
+ }
68
+
69
+ export type DragState =
70
+ | { kind: 'idle' }
71
+ | {
72
+ kind: 'pending';
73
+ startClientX: number;
74
+ startClientY: number;
75
+ leaderId: string;
76
+ leaderStart: Rect;
77
+ followers: DragTargetSnapshot[];
78
+ others: Rect[];
79
+ }
80
+ | {
81
+ kind: 'dragging';
82
+ startClientX: number;
83
+ startClientY: number;
84
+ leaderId: string;
85
+ leaderStart: Rect;
86
+ followers: DragTargetSnapshot[];
87
+ others: Rect[];
88
+ cursorClientX: number;
89
+ cursorClientY: number;
90
+ /** Snapped leader rect — what the ghost renders at. */
91
+ leaderRect: Rect;
92
+ snap: SnapResult;
93
+ alt: boolean;
94
+ };
95
+
96
+ export type DragEvent =
97
+ | {
98
+ type: 'down';
99
+ clientX: number;
100
+ clientY: number;
101
+ leaderId: string;
102
+ leaderStart: Rect;
103
+ followers: DragTargetSnapshot[];
104
+ others: Rect[];
105
+ }
106
+ | {
107
+ type: 'move';
108
+ clientX: number;
109
+ clientY: number;
110
+ alt: boolean;
111
+ zoom: number;
112
+ gridSize: number;
113
+ tolerance: number;
114
+ }
115
+ | { type: 'up' }
116
+ | { type: 'cancel' };
117
+
118
+ // ─────────────────────────────────────────────────────────────────────────────
119
+ // Pure reducer — unit-tested without a DOM. The hook below is a thin shell
120
+ // that wires DOM events into this function.
121
+
122
+ export function dragReducer(state: DragState, ev: DragEvent): DragState {
123
+ switch (ev.type) {
124
+ case 'down':
125
+ return {
126
+ kind: 'pending',
127
+ startClientX: ev.clientX,
128
+ startClientY: ev.clientY,
129
+ leaderId: ev.leaderId,
130
+ leaderStart: ev.leaderStart,
131
+ followers: ev.followers,
132
+ others: ev.others,
133
+ };
134
+ case 'move': {
135
+ if (state.kind === 'idle') return state;
136
+ const dxClient = ev.clientX - state.startClientX;
137
+ const dyClient = ev.clientY - state.startClientY;
138
+ if (state.kind === 'pending') {
139
+ if (Math.abs(dxClient) < DRAG_THRESHOLD_PX && Math.abs(dyClient) < DRAG_THRESHOLD_PX) {
140
+ return state;
141
+ }
142
+ }
143
+ const z = ev.zoom > 0 ? ev.zoom : 1;
144
+ const proposed: Rect = {
145
+ x: state.leaderStart.x + dxClient / z,
146
+ y: state.leaderStart.y + dyClient / z,
147
+ w: state.leaderStart.w,
148
+ h: state.leaderStart.h,
149
+ };
150
+ const snap = computeSnap(proposed, state.others, {
151
+ gridSize: ev.gridSize,
152
+ tolerance: ev.tolerance,
153
+ disabled: ev.alt,
154
+ });
155
+ return {
156
+ kind: 'dragging',
157
+ startClientX: state.startClientX,
158
+ startClientY: state.startClientY,
159
+ leaderId: state.leaderId,
160
+ leaderStart: state.leaderStart,
161
+ followers: state.followers,
162
+ others: state.others,
163
+ cursorClientX: ev.clientX,
164
+ cursorClientY: ev.clientY,
165
+ leaderRect: { x: snap.x, y: snap.y, w: proposed.w, h: proposed.h },
166
+ snap,
167
+ alt: ev.alt,
168
+ };
169
+ }
170
+ case 'up':
171
+ case 'cancel':
172
+ return { kind: 'idle' };
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Translate a dragging state into the commit payload (leader + every follower
178
+ * at their final snapped positions). Returns null when no drag is in flight.
179
+ */
180
+ export function commitFromState(state: DragState): { id: string; x: number; y: number }[] | null {
181
+ if (state.kind !== 'dragging') return null;
182
+ const out: { id: string; x: number; y: number }[] = [
183
+ { id: state.leaderId, x: state.leaderRect.x, y: state.leaderRect.y },
184
+ ];
185
+ for (const f of state.followers) {
186
+ out.push({
187
+ id: f.id,
188
+ x: state.leaderRect.x + f.offsetX,
189
+ y: state.leaderRect.y + f.offsetY,
190
+ });
191
+ }
192
+ return out;
193
+ }
194
+
195
+ /**
196
+ * Compute follower snapshots given the leader's starting rect and the live
197
+ * selection set. A follower is any selected artboard whose id appears in
198
+ * `allRects` and isn't the leader itself. If the leader is not in
199
+ * `selectedIds`, followers = [] (the leader drags alone).
200
+ */
201
+ export function computeFollowers(
202
+ leaderId: string,
203
+ leaderStart: Rect,
204
+ selectedIds: Set<string>,
205
+ allRects: Rect[]
206
+ ): DragTargetSnapshot[] {
207
+ if (!selectedIds.has(leaderId)) return [];
208
+ const out: DragTargetSnapshot[] = [];
209
+ for (const r of allRects) {
210
+ if (!r.id || r.id === leaderId) continue;
211
+ if (!selectedIds.has(r.id)) continue;
212
+ out.push({
213
+ id: r.id,
214
+ offsetX: r.x - leaderStart.x,
215
+ offsetY: r.y - leaderStart.y,
216
+ startRect: r,
217
+ });
218
+ }
219
+ return out;
220
+ }
221
+
222
+ /**
223
+ * Project a `Selection[]` into the set of artboard ids it implies for
224
+ * multi-drag. Only `Selection.artboardId` is used — `Selection.id` is the
225
+ * `data-cd-id` of an arbitrary inner element and would pollute the set
226
+ * with child cd-ids that never match an artboard's `data-dc-screen`.
227
+ */
228
+ export function selectionsToArtboardIds(selected: Selection[]): Set<string> {
229
+ const out = new Set<string>();
230
+ for (const s of selected) {
231
+ if (s.artboardId) out.add(s.artboardId);
232
+ }
233
+ return out;
234
+ }
235
+
236
+ /**
237
+ * Build the `others` rect array — every rect that isn't the leader or a
238
+ * follower (those are moving rigidly with the leader and shouldn't snap to
239
+ * themselves).
240
+ */
241
+ export function computeOthers(
242
+ leaderId: string,
243
+ followerIds: Set<string>,
244
+ allRects: Rect[]
245
+ ): Rect[] {
246
+ const out: Rect[] = [];
247
+ for (const r of allRects) {
248
+ if (!r.id) continue;
249
+ if (r.id === leaderId) continue;
250
+ if (followerIds.has(r.id)) continue;
251
+ out.push(r);
252
+ }
253
+ return out;
254
+ }
255
+
256
+ // ─────────────────────────────────────────────────────────────────────────────
257
+ // Hook
258
+
259
+ export interface UseArtboardDragOptions {
260
+ /** This DCArtboard's id. */
261
+ artboardId: string;
262
+ /** Live multi-selection (from useSelectionSet). */
263
+ selected: Selection[];
264
+ /** Look up an artboard's current rect. */
265
+ rectFor: (id: string) => Rect | null;
266
+ /** Every artboard's rect in render order. */
267
+ allRects: Rect[];
268
+ /** Live viewport — `null` before first layout. */
269
+ viewport: { zoom: number } | null;
270
+ /** False short-circuits all event handling (e.g. activeTool !== "move"). */
271
+ enabled: boolean;
272
+ /** Called on drop with final positions for leader + followers. */
273
+ onCommit: (next: { id: string; x: number; y: number }[]) => void;
274
+ /** Snap config — defaults via DDR-028. */
275
+ gridSize?: number;
276
+ tolerance?: number;
277
+ }
278
+
279
+ export interface UseArtboardDragHandle {
280
+ /** Spread onto the artboard chrome (label + outer border). */
281
+ bindHandle: () => HTMLAttributes<HTMLElement>;
282
+ /** Current state — consumers read this to render ghosts + the cursor swap. */
283
+ dragState: DragState;
284
+ }
285
+
286
+ export function useArtboardDrag(opts: UseArtboardDragOptions): UseArtboardDragHandle {
287
+ const {
288
+ artboardId,
289
+ selected,
290
+ rectFor,
291
+ allRects,
292
+ viewport,
293
+ enabled,
294
+ onCommit,
295
+ gridSize = DEFAULT_GRID_SIZE,
296
+ tolerance = DEFAULT_SNAP_TOLERANCE,
297
+ } = opts;
298
+
299
+ const [dragState, setDragState] = useState<DragState>({ kind: 'idle' });
300
+
301
+ // Keep stable refs for callbacks that close over fast-changing inputs.
302
+ const stateRef = useRef<DragState>(dragState);
303
+ stateRef.current = dragState;
304
+ const zoomRef = useRef<number>(viewport?.zoom ?? 1);
305
+ zoomRef.current = viewport?.zoom ?? 1;
306
+ const onCommitRef = useRef(onCommit);
307
+ onCommitRef.current = onCommit;
308
+ const enabledRef = useRef(enabled);
309
+ enabledRef.current = enabled;
310
+ const gridSizeRef = useRef(gridSize);
311
+ gridSizeRef.current = gridSize;
312
+ const toleranceRef = useRef(tolerance);
313
+ toleranceRef.current = tolerance;
314
+ const allRectsRef = useRef(allRects);
315
+ allRectsRef.current = allRects;
316
+ const rectForRef = useRef(rectFor);
317
+ rectForRef.current = rectFor;
318
+
319
+ const selectedIdsRef = useRef<Set<string>>(new Set());
320
+ selectedIdsRef.current = useMemo(() => selectionsToArtboardIds(selected), [selected]);
321
+
322
+ // When `enabled` flips to false mid-drag, cancel (no commit). Mirrors the
323
+ // tool-mode flip scenario in the plan.
324
+ useEffect(() => {
325
+ if (!enabled && stateRef.current.kind !== 'idle') {
326
+ setDragState({ kind: 'idle' });
327
+ }
328
+ }, [enabled]);
329
+
330
+ // Suppress the synthetic click that fires after a drag's pointerup so the
331
+ // label's `onClick` (pan-to-focus) doesn't run. Bind one-shot at the
332
+ // pending→dragging transition; unbinds itself on the next click event.
333
+ const armClickSuppressor = useCallback(() => {
334
+ if (typeof document === 'undefined') return;
335
+ const handler = (e: MouseEvent) => {
336
+ e.stopPropagation();
337
+ e.preventDefault();
338
+ document.removeEventListener('click', handler, true);
339
+ };
340
+ document.addEventListener('click', handler, true);
341
+ // Auto-disarm after a tick if no click fires (defensive).
342
+ setTimeout(() => {
343
+ document.removeEventListener('click', handler, true);
344
+ }, CLICK_SUPPRESS_TIMEOUT_MS);
345
+ }, []);
346
+
347
+ const onPointerDown = useCallback(
348
+ (e: ReactPointerEvent<HTMLElement>) => {
349
+ if (!enabledRef.current) return;
350
+ if (e.button !== 0) return; // only left-button
351
+ // Don't claim modifier-held clicks — Cmd / Ctrl belongs to selection.
352
+ if (e.metaKey || e.ctrlKey) return;
353
+ // Drag handle is the chrome (label strip + outer border) only. Pointer
354
+ // events that originate inside `.dc-artboard-body` stay click-through
355
+ // so Cmd-select continues to work on inner content.
356
+ const target = e.target as Element | null;
357
+ if (target && typeof target.closest === 'function' && target.closest('.dc-artboard-body')) {
358
+ return;
359
+ }
360
+ const leaderStart = rectForRef.current(artboardId);
361
+ if (!leaderStart) return;
362
+ // Don't `setPointerCapture` on the chrome — that re-targets the
363
+ // subsequent `click` event to the captured ancestor (the article)
364
+ // instead of the label `<button>`, which would silently swallow the
365
+ // Phase 4 pan-to-focus `onClick={onFocus}` handler. The global
366
+ // window-level pointermove/up listeners (capture: true) below pick
367
+ // up every event regardless, so capture buys us nothing here.
368
+ const selSet = selectedIdsRef.current;
369
+ const followers = computeFollowers(artboardId, leaderStart, selSet, allRectsRef.current);
370
+ const followerIds = new Set(followers.map((f) => f.id));
371
+ const others = computeOthers(artboardId, followerIds, allRectsRef.current);
372
+ setDragState(
373
+ dragReducer(
374
+ { kind: 'idle' },
375
+ {
376
+ type: 'down',
377
+ clientX: e.clientX,
378
+ clientY: e.clientY,
379
+ leaderId: artboardId,
380
+ leaderStart,
381
+ followers,
382
+ others,
383
+ }
384
+ )
385
+ );
386
+ },
387
+ [artboardId]
388
+ );
389
+
390
+ // Global pointermove / pointerup while a drag is in flight. Bound at the
391
+ // window level so a fast drag past any element still feeds the reducer.
392
+ useEffect(() => {
393
+ if (dragState.kind === 'idle') return;
394
+ if (typeof window === 'undefined') return;
395
+
396
+ const onMove = (ev: PointerEvent) => {
397
+ const next = dragReducer(stateRef.current, {
398
+ type: 'move',
399
+ clientX: ev.clientX,
400
+ clientY: ev.clientY,
401
+ alt: !!ev.altKey,
402
+ zoom: zoomRef.current,
403
+ gridSize: gridSizeRef.current,
404
+ tolerance: toleranceRef.current,
405
+ });
406
+ // Detect the pending → dragging transition to arm the click suppressor.
407
+ if (stateRef.current.kind === 'pending' && next.kind === 'dragging') {
408
+ armClickSuppressor();
409
+ }
410
+ setDragState(next);
411
+ };
412
+
413
+ const onUp = () => {
414
+ const finalState = stateRef.current;
415
+ const commit = commitFromState(finalState);
416
+ setDragState({ kind: 'idle' });
417
+ if (commit) {
418
+ try {
419
+ onCommitRef.current(commit);
420
+ } catch (err) {
421
+ console.warn('[use-artboard-drag] commit failed:', err);
422
+ }
423
+ }
424
+ };
425
+
426
+ window.addEventListener('pointermove', onMove, { capture: true });
427
+ window.addEventListener('pointerup', onUp, { capture: true });
428
+ window.addEventListener('pointercancel', onUp, { capture: true });
429
+
430
+ return () => {
431
+ window.removeEventListener('pointermove', onMove, { capture: true } as EventListenerOptions);
432
+ window.removeEventListener('pointerup', onUp, { capture: true } as EventListenerOptions);
433
+ window.removeEventListener('pointercancel', onUp, { capture: true } as EventListenerOptions);
434
+ };
435
+ }, [dragState.kind, armClickSuppressor]);
436
+
437
+ const bindHandle = useCallback(
438
+ (): HTMLAttributes<HTMLElement> => ({
439
+ onPointerDown,
440
+ }),
441
+ [onPointerDown]
442
+ );
443
+
444
+ return { bindHandle, dragState };
445
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * @file use-selection-set.tsx — Phase 4.1 multi-selection store
3
+ * @scope plugins/design/dev-server/use-selection-set.tsx
4
+ * @purpose Multi-element selection state for canvas-shell. The canvas
5
+ * input router calls `replace()` / `add()` / `clear()`;
6
+ * the provider debounces and posts up to the dev-server shell
7
+ * through the existing `__design_selected` window.parent channel
8
+ * so `_active.json` reflects the current selection set.
9
+ *
10
+ * Schema migration. `_active.json#selected` historically holds
11
+ * selected: SelectedElement | null
12
+ * Phase 4.1 widens to
13
+ * selected: SelectedElement | SelectedElement[] | null
14
+ * Writer: emits a single object when N === 1 (back-compat with downstream
15
+ * tools that still read the legacy shape — `/design:edit`, handoff). Emits an
16
+ * array when N > 1. Reader (this hook on rehydrate) accepts all three.
17
+ */
18
+
19
+ import {
20
+ type ReactNode,
21
+ createContext,
22
+ useCallback,
23
+ useContext,
24
+ useEffect,
25
+ useMemo,
26
+ useRef,
27
+ useState,
28
+ } from 'react';
29
+
30
+ // ─────────────────────────────────────────────────────────────────────────────
31
+ // Types
32
+
33
+ /**
34
+ * Minimal Selection shape that travels through the parent postMessage channel.
35
+ * Mirrors `SelectedElement` from inspect.ts but the canvas router computes it
36
+ * client-side and the inspector overlay's enrichment fields (html excerpt,
37
+ * dom_path, classes...) are filled in by the router right before the message
38
+ * is posted.
39
+ */
40
+ export interface Selection {
41
+ /** Canvas file path — designRel-prefixed (e.g. `.design/ui/Foo.tsx`). */
42
+ file?: string;
43
+ /** Stable `data-cd-id` anchor when present. v2-grade only. */
44
+ id?: string;
45
+ /** CSS-selector fallback path (always present). */
46
+ selector: string;
47
+ /** Artboard host (`data-dc-screen`) — for scoping multi-edits in future. */
48
+ artboardId?: string | null;
49
+ /** Snapshot fields filled by the router from `resolveHoverTarget`. */
50
+ tag?: string;
51
+ classes?: string;
52
+ text?: string;
53
+ dom_path?: string[];
54
+ bounds?: { x: number; y: number; w: number; h: number } | null;
55
+ html?: string;
56
+ }
57
+
58
+ interface SelectionSetValue {
59
+ selected: Selection[];
60
+ replace: (s: Selection | Selection[]) => void;
61
+ add: (s: Selection | Selection[]) => void;
62
+ remove: (s: Selection) => void;
63
+ toggle: (s: Selection) => void;
64
+ clear: () => void;
65
+ }
66
+
67
+ const SelectionSetContext = createContext<SelectionSetValue | null>(null);
68
+
69
+ // ─────────────────────────────────────────────────────────────────────────────
70
+ // Identity. Prefer `id` (data-cd-id stable anchor); fall back to selector.
71
+
72
+ function selectionKey(s: Selection): string {
73
+ return s.id ? `id:${s.id}` : `sel:${s.selector}`;
74
+ }
75
+
76
+ function dedupe(list: Selection[]): Selection[] {
77
+ const out: Selection[] = [];
78
+ const seen = new Set<string>();
79
+ for (const s of list) {
80
+ const k = selectionKey(s);
81
+ if (seen.has(k)) continue;
82
+ seen.add(k);
83
+ out.push(s);
84
+ }
85
+ return out;
86
+ }
87
+
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+ // Provider
90
+
91
+ const POST_DEBOUNCE_MS = 50; // mirrors canvas-lib's SETTLE/PUBLISH cadence
92
+
93
+ export function SelectionSetProvider({
94
+ children,
95
+ /** Override the postMessage destination (used in tests). */
96
+ postTarget,
97
+ }: {
98
+ children: ReactNode;
99
+ postTarget?: { postMessage: (msg: unknown, targetOrigin: string) => void } | null;
100
+ }) {
101
+ const [selected, setSelected] = useState<Selection[]>([]);
102
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
103
+
104
+ const post = useCallback(
105
+ (next: Selection[]) => {
106
+ if (timerRef.current) clearTimeout(timerRef.current);
107
+ timerRef.current = setTimeout(() => {
108
+ timerRef.current = null;
109
+ const target = postTarget ?? (typeof window !== 'undefined' ? window.parent : null);
110
+ if (!target) return;
111
+ // Wire shape: single object for N=1 (back-compat), array for N>1, null for empty.
112
+ const payload: Selection | Selection[] | null =
113
+ next.length === 0 ? null : next.length === 1 ? (next[0] ?? null) : next;
114
+ try {
115
+ target.postMessage({ dgn: 'select-set', selection: payload }, '*');
116
+ } catch {
117
+ /* iframe likely cross-origin or detached */
118
+ }
119
+ }, POST_DEBOUNCE_MS);
120
+ },
121
+ [postTarget]
122
+ );
123
+
124
+ // Cleanup the debounce timer on unmount.
125
+ useEffect(
126
+ () => () => {
127
+ if (timerRef.current) clearTimeout(timerRef.current);
128
+ },
129
+ []
130
+ );
131
+
132
+ const replace = useCallback(
133
+ (s: Selection | Selection[]) => {
134
+ const next = dedupe(Array.isArray(s) ? s : [s]);
135
+ setSelected(next);
136
+ post(next);
137
+ },
138
+ [post]
139
+ );
140
+
141
+ const add = useCallback(
142
+ (s: Selection | Selection[]) => {
143
+ const incoming = Array.isArray(s) ? s : [s];
144
+ setSelected((prev) => {
145
+ const next = dedupe([...prev, ...incoming]);
146
+ post(next);
147
+ return next;
148
+ });
149
+ },
150
+ [post]
151
+ );
152
+
153
+ const remove = useCallback(
154
+ (s: Selection) => {
155
+ const k = selectionKey(s);
156
+ setSelected((prev) => {
157
+ const next = prev.filter((x) => selectionKey(x) !== k);
158
+ post(next);
159
+ return next;
160
+ });
161
+ },
162
+ [post]
163
+ );
164
+
165
+ const toggle = useCallback(
166
+ (s: Selection) => {
167
+ const k = selectionKey(s);
168
+ setSelected((prev) => {
169
+ const next = prev.some((x) => selectionKey(x) === k)
170
+ ? prev.filter((x) => selectionKey(x) !== k)
171
+ : [...prev, s];
172
+ post(next);
173
+ return next;
174
+ });
175
+ },
176
+ [post]
177
+ );
178
+
179
+ const clear = useCallback(() => {
180
+ setSelected([]);
181
+ post([]);
182
+ }, [post]);
183
+
184
+ const value = useMemo<SelectionSetValue>(
185
+ () => ({ selected, replace, add, remove, toggle, clear }),
186
+ [selected, replace, add, remove, toggle, clear]
187
+ );
188
+
189
+ return <SelectionSetContext.Provider value={value}>{children}</SelectionSetContext.Provider>;
190
+ }
191
+
192
+ // ─────────────────────────────────────────────────────────────────────────────
193
+ // Hooks
194
+
195
+ export function useSelectionSet(): SelectionSetValue {
196
+ const ctx = useContext(SelectionSetContext);
197
+ if (!ctx) {
198
+ throw new Error('useSelectionSet must be used inside <SelectionSetProvider>');
199
+ }
200
+ return ctx;
201
+ }
202
+
203
+ export function useSelectionSetOptional(): SelectionSetValue | null {
204
+ return useContext(SelectionSetContext);
205
+ }
206
+
207
+ // ─────────────────────────────────────────────────────────────────────────────
208
+ // Wire-shape helpers — exported for tests and inspect.ts back-compat reader.
209
+
210
+ /** Convert any inbound shape to an array. */
211
+ export function normalizeSelectedRead(
212
+ raw: Selection | Selection[] | null | undefined
213
+ ): Selection[] {
214
+ if (raw == null) return [];
215
+ if (Array.isArray(raw)) return dedupe(raw);
216
+ return [raw];
217
+ }
218
+
219
+ /** Convert internal array back to the wire shape (writer). */
220
+ export function denormalizeSelectedWrite(list: Selection[]): Selection | Selection[] | null {
221
+ if (list.length === 0) return null;
222
+ if (list.length === 1) return list[0] ?? null;
223
+ return list;
224
+ }