@1agh/maude 0.19.0 → 0.20.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 (66) hide show
  1. package/package.json +9 -9
  2. package/plugins/design/dev-server/annotations-context-toolbar.tsx +126 -44
  3. package/plugins/design/dev-server/annotations-layer.tsx +343 -112
  4. package/plugins/design/dev-server/api.ts +70 -17
  5. package/plugins/design/dev-server/artboard-marquee.tsx +174 -0
  6. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  7. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  8. package/plugins/design/dev-server/canvas-lib.tsx +691 -119
  9. package/plugins/design/dev-server/canvas-shell.tsx +818 -42
  10. package/plugins/design/dev-server/client/app.jsx +173 -52
  11. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  12. package/plugins/design/dev-server/client/styles/1-tokens.css +15 -8
  13. package/plugins/design/dev-server/client/styles/4-components.css +32 -2
  14. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  15. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  16. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  17. package/plugins/design/dev-server/comments-overlay.tsx +12 -4
  18. package/plugins/design/dev-server/config.schema.json +31 -5
  19. package/plugins/design/dev-server/context-menu.tsx +3 -3
  20. package/plugins/design/dev-server/context.ts +37 -3
  21. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  22. package/plugins/design/dev-server/dist/client.bundle.js +212 -103
  23. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  24. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  25. package/plugins/design/dev-server/dist/styles.css +38 -2
  26. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  27. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  28. package/plugins/design/dev-server/export-dialog.tsx +3 -3
  29. package/plugins/design/dev-server/handoff.ts +24 -0
  30. package/plugins/design/dev-server/http.ts +41 -2
  31. package/plugins/design/dev-server/input-router.tsx +74 -10
  32. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  33. package/plugins/design/dev-server/runtime-bundle.ts +7 -0
  34. package/plugins/design/dev-server/server.mjs +84 -18
  35. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  36. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  37. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  38. package/plugins/design/dev-server/test/context-resolve-tokens.test.ts +97 -0
  39. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  40. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  41. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  42. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  43. package/plugins/design/dev-server/test/snap-distance-pill.test.ts +68 -0
  44. package/plugins/design/dev-server/test/system-endpoint.test.ts +144 -0
  45. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  46. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  47. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  48. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  49. package/plugins/design/dev-server/tool-palette.tsx +71 -15
  50. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  51. package/plugins/design/dev-server/undo-stack.ts +240 -0
  52. package/plugins/design/dev-server/use-annotation-resize.tsx +295 -0
  53. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  54. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  55. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  56. package/plugins/design/dev-server/use-snap-guides.tsx +25 -1
  57. package/plugins/design/dev-server/use-tool-mode.tsx +31 -2
  58. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  59. package/plugins/design/templates/_shell.html +17 -6
  60. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  61. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  62. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  63. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  64. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  65. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  66. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @file commands/move-artboards-command.ts — undo entry for artboard moves
3
+ * @scope plugins/design/dev-server/commands/move-artboards-command.ts
4
+ * @purpose Reversible record of an artboard-layout PATCH. Pairs the full
5
+ * `before` and `after` layout snapshots with an injected
6
+ * `patchFn` (in production, `applyArtboardLayout`; in tests, a
7
+ * spy). The command is rebuilt per iframe mount from a
8
+ * `CommandRecord` so the stack survives canvas switches
9
+ * (DDR-050 rev 2).
10
+ *
11
+ * Why full snapshots, not a sparse diff. The server-side endpoint
12
+ * (`PATCH /_api/canvas-meta`, see api.ts:523–528) shallow-merges patch.layout
13
+ * over the existing meta. A sparse `{ artboards: [movedOnly] }` would drop
14
+ * every unchanged rect on its head. Storing the full array is simpler and
15
+ * resilient — the external-edit invalidation (DDR-050 rule 6) clears the
16
+ * stack whenever the file changes outside our PATCH so we never restore a
17
+ * stale layout.
18
+ */
19
+
20
+ import type { CommandRecord, CommandSinks, EditCommand } from '../undo-stack.ts';
21
+ import { registerCommand } from '../undo-stack.ts';
22
+
23
+ export interface ArtboardLayoutEntry {
24
+ id: string;
25
+ x: number;
26
+ y: number;
27
+ /** Width — JSX-authoritative; carried through for shape-completeness. */
28
+ w?: number;
29
+ h?: number;
30
+ }
31
+
32
+ /**
33
+ * Signature compatible with canvas-lib's `applyArtboardLayout(layout)`.
34
+ * Tests pass a spy; production wiring passes the real React-state-+-PATCH
35
+ * applier.
36
+ */
37
+ export type LayoutPatchFn = (layout: ArtboardLayoutEntry[]) => void | Promise<void>;
38
+
39
+ export interface MoveArtboardsPayload {
40
+ before: readonly ArtboardLayoutEntry[];
41
+ after: readonly ArtboardLayoutEntry[];
42
+ }
43
+
44
+ /** Convenience constant — keeps spelling consistent across files. */
45
+ export const MOVE_ARTBOARDS_KIND = 'move-artboards';
46
+
47
+ export interface MoveArtboardsCommandInit {
48
+ before: readonly ArtboardLayoutEntry[];
49
+ after: readonly ArtboardLayoutEntry[];
50
+ patchFn: LayoutPatchFn;
51
+ /** Optional label override (equal-spacing wraps with its own copy). */
52
+ label?: string;
53
+ /** Telemetry kind. Defaults to `MOVE_ARTBOARDS_KIND`. */
54
+ kind?: string;
55
+ }
56
+
57
+ export function createMoveArtboardsCommand(init: MoveArtboardsCommandInit): EditCommand {
58
+ const { before, after, patchFn } = init;
59
+ // Snapshot once — mutating the source arrays later cannot poison the command.
60
+ const beforeSnapshot = before.map(cloneEntry);
61
+ const afterSnapshot = after.map(cloneEntry);
62
+ const movedCount = countMoved(beforeSnapshot, afterSnapshot);
63
+ const label = init.label ?? `move ${movedCount} artboard${movedCount === 1 ? '' : 's'}`;
64
+ const kind = init.kind ?? MOVE_ARTBOARDS_KIND;
65
+
66
+ return {
67
+ kind,
68
+ label,
69
+ async do() {
70
+ await patchFn(afterSnapshot.map(cloneEntry));
71
+ },
72
+ async undo() {
73
+ await patchFn(beforeSnapshot.map(cloneEntry));
74
+ },
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Build a persistable record from the same inputs. Use this together with
80
+ * the EditCommand so the runtime side-effect AND the persisted shape share
81
+ * one snapshot.
82
+ */
83
+ export function buildMoveArtboardsRecord(opts: {
84
+ before: readonly ArtboardLayoutEntry[];
85
+ after: readonly ArtboardLayoutEntry[];
86
+ label?: string;
87
+ }): CommandRecord<MoveArtboardsPayload> {
88
+ const before = opts.before.map(cloneEntry);
89
+ const after = opts.after.map(cloneEntry);
90
+ const moved = countMoved(before, after);
91
+ const label = opts.label ?? `move ${moved} artboard${moved === 1 ? '' : 's'}`;
92
+ return { kind: MOVE_ARTBOARDS_KIND, label, payload: { before, after } };
93
+ }
94
+
95
+ /**
96
+ * Diff helper — returns `null` when `before` and `after` describe the same
97
+ * layout (call sites use this to skip pushing a no-op drag onto the stack).
98
+ * Compares positions only; size diffs are ignored (size is JSX-authoritative
99
+ * per DDR-027, position is the only mutable channel).
100
+ */
101
+ export function diffLayoutPositions(
102
+ before: readonly ArtboardLayoutEntry[],
103
+ after: readonly ArtboardLayoutEntry[]
104
+ ): { changed: number } | null {
105
+ if (before.length !== after.length) return { changed: Math.max(before.length, after.length) };
106
+ const byId = new Map<string, ArtboardLayoutEntry>();
107
+ for (const r of before) byId.set(r.id, r);
108
+ let changed = 0;
109
+ for (const r of after) {
110
+ const prev = byId.get(r.id);
111
+ if (!prev || prev.x !== r.x || prev.y !== r.y) changed++;
112
+ }
113
+ if (changed === 0) return null;
114
+ return { changed };
115
+ }
116
+
117
+ // ─────────────────────────────────────────────────────────────────────────────
118
+ // Registry — rebuild EditCommand from a persisted CommandRecord + current
119
+ // iframe's sinks. Runs once on module load (top-level side-effect intentional).
120
+
121
+ registerCommand<MoveArtboardsPayload>(MOVE_ARTBOARDS_KIND, (record, sinks) => {
122
+ const patchFn = sinks.layoutPatchFn as LayoutPatchFn | undefined;
123
+ if (!patchFn) return null;
124
+ return createMoveArtboardsCommand({
125
+ before: record.payload.before,
126
+ after: record.payload.after,
127
+ patchFn,
128
+ label: record.label,
129
+ });
130
+ });
131
+
132
+ // ─────────────────────────────────────────────────────────────────────────────
133
+ // Internals
134
+
135
+ function cloneEntry(r: ArtboardLayoutEntry): ArtboardLayoutEntry {
136
+ const out: ArtboardLayoutEntry = { id: r.id, x: r.x, y: r.y };
137
+ if (typeof r.w === 'number') out.w = r.w;
138
+ if (typeof r.h === 'number') out.h = r.h;
139
+ return out;
140
+ }
141
+
142
+ function countMoved(
143
+ before: readonly ArtboardLayoutEntry[],
144
+ after: readonly ArtboardLayoutEntry[]
145
+ ): number {
146
+ const byId = new Map<string, ArtboardLayoutEntry>();
147
+ for (const r of before) byId.set(r.id, r);
148
+ let n = 0;
149
+ for (const r of after) {
150
+ const prev = byId.get(r.id);
151
+ if (!prev) {
152
+ n++;
153
+ continue;
154
+ }
155
+ if (prev.x !== r.x || prev.y !== r.y) n++;
156
+ }
157
+ return n;
158
+ }
@@ -1143,14 +1143,22 @@ function computeThreadAnchor(comment: OverlayComment): { x: number; y: number }
1143
1143
  }
1144
1144
 
1145
1145
  function computeAnchor(state: ComposerState): { x: number; y: number } {
1146
- // Try the selected element first its live screen rect gives the most
1147
- // natural anchor (composer sits flush under the element the user clicked).
1146
+ // G4 anchor to the cursor click point first. Earlier versions anchored to
1147
+ // the selected element's bottom-left, which landed the composer flush in
1148
+ // the corner regardless of where the user clicked — surprising for the
1149
+ // common case of "I clicked the middle of an element, expecting the
1150
+ // composer to appear near my cursor". The element-rect path remains as a
1151
+ // fallback for entry points that don't carry a cursor (e.g. opening the
1152
+ // composer from a contextual toolbar button — those should set clientX/Y
1153
+ // to a sensible anchor before dispatching).
1154
+ if (state.clientX || state.clientY) {
1155
+ return { x: state.clientX, y: state.clientY + 8 };
1156
+ }
1148
1157
  if (state.selection.selector) {
1149
1158
  const rect = screenRectFor(state.selection.selector);
1150
1159
  if (rect) {
1151
1160
  return { x: rect.x, y: rect.y + rect.h + 8 };
1152
1161
  }
1153
1162
  }
1154
- // Fall back to the raw click point — composer drops 8px below the cursor.
1155
- return { x: state.clientX, y: state.clientY + 8 };
1163
+ return { x: 16, y: 16 };
1156
1164
  }
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "tokensCssRel": {
54
54
  "type": "string",
55
- "description": "Path to the design system CSS, relative to designRoot. New canvases <link> to this file.",
55
+ "description": "Project-wide tokens CSS path, relative to designRoot. Used as the fallback when 'designSystems' is unset OR when an entry omits its own 'tokensCssRel'. New canvases <link> to this file by default. Per DDR-048, per-DS entries auto-resolve to '<designSystems[i].path>/colors_and_type.css' when their own field is missing.",
56
56
  "default": "system/colors_and_type.css"
57
57
  },
58
58
  "teamAccentDefault": {
@@ -130,7 +130,7 @@
130
130
  },
131
131
  "designSystems": {
132
132
  "type": "array",
133
- "description": "Design systems available in this project. Single-DS projects have one entry; multi-DS projects (marketing vs. admin vs. mobile) list each here. Each canvas's .meta.json declares which DS it uses via the 'designSystem' field.",
133
+ "description": "Design systems available in this project. Single-DS projects have one entry; multi-DS projects (marketing vs. admin vs. mobile) list each here. Each canvas's .meta.json declares which DS it uses via the 'designSystem' field. The System view's DS picker iterates this array.",
134
134
  "items": {
135
135
  "type": "object",
136
136
  "required": ["name", "path"],
@@ -138,13 +138,39 @@
138
138
  "name": {
139
139
  "type": "string",
140
140
  "pattern": "^[a-z][a-z0-9-]*$",
141
- "description": "Kebab-case slug. Used to match canvas .meta.json.designSystem references."
141
+ "description": "Kebab-case slug. Used to match canvas .meta.json.designSystem references and as the ?ds=<name> query param on /_system-data."
142
142
  },
143
143
  "path": {
144
144
  "type": "string",
145
- "description": "Folder under designRoot (e.g. 'system/marketing')."
145
+ "description": "Folder under designRoot (e.g. 'system/marketing'). The DS-scoped System view reads tokens, previews, and ui_kits from inside this folder."
146
146
  },
147
- "description": { "type": "string" }
147
+ "description": { "type": "string" },
148
+ "tokensCssRel": {
149
+ "type": "string",
150
+ "description": "Per-DS tokens CSS path, relative to designRoot. When omitted, auto-resolves to '<path>/colors_and_type.css' (DDR-048). Set explicitly when the DS's tokens live under a non-standard filename."
151
+ },
152
+ "rootClass": {
153
+ "type": "string",
154
+ "description": "Optional root class applied to canvases in this DS (overrides the project-wide rootClass for this DS's previews + new canvases)."
155
+ },
156
+ "themeDefault": {
157
+ "type": "string",
158
+ "enum": ["dark", "light"],
159
+ "description": "Per-DS theme default. Overrides the project-wide themeDefault when rendering this DS's specimens."
160
+ },
161
+ "themes": {
162
+ "type": "array",
163
+ "items": { "type": "string" },
164
+ "description": "Optional list of theme names this DS supports (used by the System view's theme toggle when more than one theme is declared)."
165
+ },
166
+ "newCanvasDir": {
167
+ "type": "string",
168
+ "description": "Override of the project-wide newCanvasDir for canvases scaffolded inside this DS."
169
+ },
170
+ "newComponentDir": {
171
+ "type": "string",
172
+ "description": "Override of the project-wide newComponentDir for components scaffolded inside this DS."
173
+ }
148
174
  },
149
175
  "additionalProperties": false
150
176
  },
@@ -158,10 +158,10 @@ const MENU_CSS = `
158
158
  .dc-context-menu {
159
159
  position: fixed;
160
160
  z-index: 7;
161
- background: var(--u-bg-2, var(--bg-1, #fff));
161
+ background: var(--u-bg-0, var(--bg-0, #fff));
162
162
  border: 1px solid var(--u-fg-0, #1c1917);
163
- border-radius: 0;
164
- box-shadow: 4px 4px 0 var(--u-fg-0, #1c1917);
163
+ border-radius: 8px;
164
+ box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
165
165
  padding: 4px;
166
166
  min-width: 220px;
167
167
  font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
@@ -31,6 +31,7 @@ export interface DevServerConfig {
31
31
  designRoot: string;
32
32
  canvasGroups: CanvasGroup[];
33
33
  designSystems?: DesignSystemEntry[];
34
+ defaultDesignSystem?: string;
34
35
  rootClass: string;
35
36
  themeDefault: 'dark' | 'light';
36
37
  tokensCssRel: string;
@@ -124,18 +125,51 @@ function loadConfig(repoRoot: string): DevServerConfig {
124
125
  try {
125
126
  raw = readFileSync(configPath, 'utf8');
126
127
  } catch {
127
- return { ...DEFAULT_CONFIG, _source: 'defaults' };
128
+ return normalizeConfig({ ...DEFAULT_CONFIG, _source: 'defaults' });
128
129
  }
129
130
  try {
130
131
  const parsed = JSON.parse(raw);
131
- return { ...DEFAULT_CONFIG, ...parsed, _source: '.design/config.json' };
132
+ return normalizeConfig({ ...DEFAULT_CONFIG, ...parsed, _source: '.design/config.json' });
132
133
  } catch (e) {
133
134
  const msg = e instanceof Error ? e.message : String(e);
134
135
  console.error(` warn: ${configPath} is not valid JSON: ${msg}. Using defaults.`);
135
- return { ...DEFAULT_CONFIG, _source: 'defaults (config invalid)' };
136
+ return normalizeConfig({ ...DEFAULT_CONFIG, _source: 'defaults (config invalid)' });
136
137
  }
137
138
  }
138
139
 
140
+ /**
141
+ * Fill in per-DS `tokensCssRel` defaults so the system view can read each DS's
142
+ * tokens without forcing every config author to spell out the path. When an
143
+ * entry omits `tokensCssRel`, derive it from `<entry.path>/colors_and_type.css`
144
+ * — the scaffold layout `/design:setup-ds` produces. Also strips leading /
145
+ * trailing slashes from `entry.path` so downstream `path.posix.join` calls
146
+ * don't produce double-slash artifacts.
147
+ *
148
+ * The top-level `cfg.tokensCssRel` is preserved untouched as the
149
+ * project-wide fallback for legacy single-DS configs that don't declare
150
+ * `designSystems[]` at all.
151
+ *
152
+ * DDR-048: the system view renders user tokens only; this normalization is the
153
+ * load-bearing step that makes per-DS rendering possible.
154
+ */
155
+ export function normalizeDesignSystems<T extends DevServerConfig>(cfg: T): T {
156
+ if (!cfg.designSystems?.length) return cfg;
157
+ const designSystems = cfg.designSystems.map((entry) => {
158
+ const p = entry.path.replace(/^\/+|\/+$/g, '');
159
+ return {
160
+ ...entry,
161
+ path: p,
162
+ tokensCssRel:
163
+ entry.tokensCssRel?.replace(/^\/+/, '') ?? path.posix.join(p, 'colors_and_type.css'),
164
+ };
165
+ });
166
+ return { ...cfg, designSystems };
167
+ }
168
+
169
+ function normalizeConfig(cfg: DevServerConfig): DevServerConfig {
170
+ return normalizeDesignSystems(cfg);
171
+ }
172
+
139
173
  export function createContext(): Context {
140
174
  const repoRoot = resolveRepoRoot();
141
175
 
@@ -0,0 +1,241 @@
1
+ /**
2
+ * @file contextual-toolbar.tsx — T30 (Wave 3)
3
+ * @scope plugins/design/dev-server/contextual-toolbar.tsx
4
+ * @purpose Selection-anchored floating chrome for ELEMENT selections
5
+ * (cd-id entries in the selection set). Mirrors the
6
+ * MultiArtboardToolbar pattern but scopes to user content inside
7
+ * artboards. Actions surface the existing right-click handlers
8
+ * one click closer:
9
+ *
10
+ * • Copy CSS path
11
+ * • Copy data-cd-id
12
+ * • Add comment
13
+ *
14
+ * Hides when:
15
+ * - no selection,
16
+ * - selection contains only artboards (MultiArtboardToolbar
17
+ * covers that),
18
+ * - selection contains only annotations (their own toolbar
19
+ * fires from `annotations-context-toolbar.tsx`).
20
+ *
21
+ * Anchored 14 px above the element selection union bbox; flips
22
+ * below when bbox top is < 60 px from the viewport edge — same
23
+ * contract as MultiArtboardToolbar so the two never collide.
24
+ *
25
+ * Selection-change tweens are NOT animated here in v1; the
26
+ * instant reposition is acceptable given the toolbar fades in
27
+ * only when stickily relevant. A future polish wave can add
28
+ * the 180 ms position tween per the plan note.
29
+ */
30
+
31
+ import { useEffect, useRef } from 'react';
32
+
33
+ import { useSelectionSet } from './use-selection-set.tsx';
34
+
35
+ const CTX_TOOLBAR_CSS = `
36
+ .dc-elem-ctx-tb {
37
+ position: fixed;
38
+ pointer-events: auto;
39
+ z-index: 6;
40
+ display: none;
41
+ align-items: stretch;
42
+ gap: 2px;
43
+ padding: 4px;
44
+ background: var(--u-bg-0, var(--bg-0, #ffffff));
45
+ border: 1px solid var(--u-fg-0, #1c1917);
46
+ border-radius: 8px;
47
+ box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
48
+ font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
49
+ font-size: 11px;
50
+ letter-spacing: 0.02em;
51
+ color: var(--u-fg-0, #1a1a1a);
52
+ user-select: none;
53
+ opacity: 0;
54
+ transition: opacity 100ms cubic-bezier(0.4, 0, 0.2, 1);
55
+ }
56
+ .dc-elem-ctx-tb[data-on="true"] {
57
+ opacity: 1;
58
+ }
59
+ .dc-elem-ctx-tb button {
60
+ appearance: none;
61
+ background: transparent;
62
+ border: 0;
63
+ border-radius: 6px;
64
+ padding: 4px 10px;
65
+ font: inherit;
66
+ cursor: pointer;
67
+ color: inherit;
68
+ transition: background-color 80ms linear;
69
+ }
70
+ .dc-elem-ctx-tb button:hover {
71
+ background: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
72
+ }
73
+ .dc-elem-ctx-tb .dc-elem-ctx-count {
74
+ padding: 4px 8px 4px 10px;
75
+ color: var(--fg-1, rgba(40,30,20,0.7));
76
+ border-right: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08));
77
+ margin-right: 2px;
78
+ font-variant-numeric: tabular-nums;
79
+ }
80
+ @media (prefers-reduced-motion: reduce) {
81
+ .dc-elem-ctx-tb, .dc-elem-ctx-tb button { transition: none; }
82
+ }
83
+ `.trim();
84
+
85
+ function ensureStyles(): void {
86
+ if (typeof document === 'undefined') return;
87
+ if (document.getElementById('dc-elem-ctx-tb-css')) return;
88
+ const s = document.createElement('style');
89
+ s.id = 'dc-elem-ctx-tb-css';
90
+ s.textContent = CTX_TOOLBAR_CSS;
91
+ document.head.appendChild(s);
92
+ }
93
+
94
+ function copyText(text: string): void {
95
+ if (typeof navigator === 'undefined' || !navigator.clipboard) return;
96
+ void navigator.clipboard.writeText(text).catch(() => {
97
+ /* clipboard blocked */
98
+ });
99
+ }
100
+
101
+ function openComposerForSelection(
102
+ sel: { id?: string; selector: string },
103
+ anchorEl: Element | null
104
+ ): void {
105
+ if (typeof document === 'undefined') return;
106
+ // Anchor near the top-right of the targeted element so the composer drops
107
+ // into a clear area next to it. Matches the comment-tool click affordance
108
+ // post-G4 (cursor-anchored composer).
109
+ let clientX = 0;
110
+ let clientY = 0;
111
+ if (anchorEl) {
112
+ const r = (anchorEl as HTMLElement).getBoundingClientRect();
113
+ clientX = r.right - 8;
114
+ clientY = r.top + r.height / 2;
115
+ }
116
+ try {
117
+ document.dispatchEvent(
118
+ new CustomEvent('cm:open-composer', {
119
+ detail: { selection: sel, clientX, clientY },
120
+ })
121
+ );
122
+ } catch {
123
+ /* CustomEvent unsupported — fall through */
124
+ }
125
+ // Back-compat parent post for legacy mocks.
126
+ if (typeof window !== 'undefined') {
127
+ try {
128
+ window.parent.postMessage({ dgn: 'comment-compose', selection: sel }, '*');
129
+ } catch {
130
+ /* parent detached */
131
+ }
132
+ }
133
+ }
134
+
135
+ export function ContextualToolbar() {
136
+ ensureStyles();
137
+ const { selected } = useSelectionSet();
138
+ const ref = useRef<HTMLDivElement | null>(null);
139
+ const rafRef = useRef<number | null>(null);
140
+
141
+ // Element selections = entries with `id` (data-cd-id) set. Pure artboard
142
+ // selections (artboardId-only, no id) and empty sets fall through.
143
+ const elementSelections = selected.filter((s) => !!s.id);
144
+ const visible = elementSelections.length > 0;
145
+
146
+ useEffect(() => {
147
+ const div = ref.current;
148
+ if (!div) return;
149
+ if (!visible) {
150
+ div.style.display = 'none';
151
+ div.setAttribute('data-on', 'false');
152
+ return;
153
+ }
154
+ const tick = () => {
155
+ rafRef.current = null;
156
+ let xMin = Number.POSITIVE_INFINITY;
157
+ let yMin = Number.POSITIVE_INFINITY;
158
+ let xMax = Number.NEGATIVE_INFINITY;
159
+ let yMax = Number.NEGATIVE_INFINITY;
160
+ let any = false;
161
+ for (const sel of elementSelections) {
162
+ const node = sel.id
163
+ ? document.querySelector(`[data-cd-id="${sel.id}"]`)
164
+ : document.querySelector(sel.selector);
165
+ if (!node) continue;
166
+ const r = (node as HTMLElement).getBoundingClientRect();
167
+ if (r.width === 0 && r.height === 0) continue;
168
+ any = true;
169
+ if (r.left < xMin) xMin = r.left;
170
+ if (r.top < yMin) yMin = r.top;
171
+ if (r.right > xMax) xMax = r.right;
172
+ if (r.bottom > yMax) yMax = r.bottom;
173
+ }
174
+ if (!any) {
175
+ div.style.display = 'none';
176
+ div.setAttribute('data-on', 'false');
177
+ rafRef.current = requestAnimationFrame(tick);
178
+ return;
179
+ }
180
+ div.style.display = 'flex';
181
+ const tw = div.offsetWidth || 0;
182
+ const centerX = (xMin + xMax) / 2;
183
+ const top = yMin;
184
+ const gap = 14;
185
+ let anchorY = top - div.offsetHeight - gap;
186
+ if (anchorY < 60) anchorY = yMax + gap;
187
+ div.style.left = `${Math.round(centerX - tw / 2)}px`;
188
+ div.style.top = `${Math.round(anchorY)}px`;
189
+ div.setAttribute('data-on', 'true');
190
+ rafRef.current = requestAnimationFrame(tick);
191
+ };
192
+ rafRef.current = requestAnimationFrame(tick);
193
+ return () => {
194
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
195
+ };
196
+ }, [visible, elementSelections]);
197
+
198
+ if (!visible) {
199
+ return <div ref={ref} className="dc-elem-ctx-tb" aria-hidden="true" />;
200
+ }
201
+
202
+ // For multi-selection, Copy actions operate on the FIRST element — same
203
+ // convention as the right-click menu (single-target). Future polish: a
204
+ // mini-menu that lets the user pick which selection's CSS to copy.
205
+ const primary = elementSelections[0];
206
+ const count = elementSelections.length;
207
+
208
+ return (
209
+ <div ref={ref} className="dc-elem-ctx-tb" role="toolbar" aria-label="Element actions">
210
+ <span className="dc-elem-ctx-count">{count === 1 ? '1 element' : `${count} elements`}</span>
211
+ <button
212
+ type="button"
213
+ title="Copy CSS selector"
214
+ onClick={() => primary && copyText(primary.selector)}
215
+ >
216
+ Copy CSS
217
+ </button>
218
+ <button
219
+ type="button"
220
+ title="Copy data-cd-id"
221
+ disabled={!primary?.id}
222
+ onClick={() => primary?.id && copyText(primary.id)}
223
+ >
224
+ Copy ID
225
+ </button>
226
+ <button
227
+ type="button"
228
+ title="Add comment on this element"
229
+ onClick={() => {
230
+ if (!primary) return;
231
+ const node = primary.id
232
+ ? document.querySelector(`[data-cd-id="${primary.id}"]`)
233
+ : document.querySelector(primary.selector);
234
+ openComposerForSelection(primary, node);
235
+ }}
236
+ >
237
+ Comment
238
+ </button>
239
+ </div>
240
+ );
241
+ }