@1agh/maude 0.19.1 → 0.21.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.
- package/README.md +7 -0
- package/cli/bin/maude.mjs +5 -1
- package/cli/commands/design-link.test.mjs +207 -0
- package/cli/commands/design.mjs +42 -12
- package/cli/commands/doctor.mjs +350 -0
- package/cli/commands/doctor.test.mjs +185 -0
- package/cli/commands/help.mjs +24 -0
- package/cli/commands/hub.mjs +231 -0
- package/cli/commands/hub.test.mjs +87 -0
- package/cli/lib/config-lint.mjs +141 -0
- package/cli/lib/config-lint.test.mjs +117 -0
- package/cli/lib/design-link.mjs +216 -0
- package/cli/lib/hubs-config.mjs +123 -0
- package/cli/lib/hubs-config.test.mjs +100 -0
- package/cli/lib/preflight.mjs +232 -0
- package/cli/lib/stack-detect.mjs +344 -0
- package/cli/lib/stack-detect.test.mjs +121 -0
- package/package.json +17 -9
- package/plugins/design/dependencies.json +147 -0
- package/plugins/design/dependencies.schema.json +107 -0
- package/plugins/design/dev-server/ai-banner.tsx +188 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +115 -41
- package/plugins/design/dev-server/annotations-layer.tsx +256 -107
- package/plugins/design/dev-server/api.ts +17 -1
- package/plugins/design/dev-server/artboard-marquee.tsx +10 -6
- package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
- package/plugins/design/dev-server/bin/preflight.sh +32 -0
- package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
- package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
- package/plugins/design/dev-server/canvas-lib.tsx +534 -32
- package/plugins/design/dev-server/canvas-shell.tsx +479 -29
- package/plugins/design/dev-server/client/app.jsx +72 -0
- package/plugins/design/dev-server/client/hmr.mjs +28 -0
- package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
- package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
- package/plugins/design/dev-server/collab/index.ts +47 -0
- package/plugins/design/dev-server/collab/persistence.ts +123 -0
- package/plugins/design/dev-server/collab/protocol.ts +108 -0
- package/plugins/design/dev-server/collab/registry.ts +110 -0
- package/plugins/design/dev-server/collab/room.ts +215 -0
- package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
- package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
- package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
- package/plugins/design/dev-server/comments-overlay.tsx +41 -4
- package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
- package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
- package/plugins/design/dev-server/dist/client.bundle.js +75 -3
- package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
- package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
- package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
- package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
- package/plugins/design/dev-server/export-dialog.tsx +1 -1
- package/plugins/design/dev-server/handoff.ts +24 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
- package/plugins/design/dev-server/http.ts +91 -1
- package/plugins/design/dev-server/input-router.tsx +52 -2
- package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
- package/plugins/design/dev-server/participants-chrome.tsx +261 -0
- package/plugins/design/dev-server/runtime-bundle.ts +15 -0
- package/plugins/design/dev-server/server.ts +78 -11
- package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
- package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
- package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
- package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
- package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
- package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
- package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
- package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
- package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
- package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
- package/plugins/design/dev-server/test/input-router.test.ts +87 -1
- package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
- package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
- package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
- package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
- package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
- package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
- package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
- package/plugins/design/dev-server/tool-palette.tsx +7 -7
- package/plugins/design/dev-server/undo-hud.tsx +95 -0
- package/plugins/design/dev-server/undo-stack.ts +240 -0
- package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
- package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
- package/plugins/design/dev-server/use-collab.tsx +478 -0
- package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
- package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
- package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
- package/plugins/design/dev-server/ws.ts +123 -7
- package/plugins/design/templates/_shell.html +51 -6
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
- package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
- package/plugins/flow/.claude-plugin/config.schema.json +12 -0
- package/plugins/flow/dependencies.json +143 -0
- package/plugins/flow/dependencies.schema.json +107 -0
- /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file equal-spacing-detector.ts — T27 (Wave 3)
|
|
3
|
+
* @scope plugins/design/dev-server/equal-spacing-detector.ts
|
|
4
|
+
* @purpose Pure detector for Figma "Smart Selection" pink-dot affordance
|
|
5
|
+
* (Rasmus Andersson 2018). Given 3+ rects on a single axis,
|
|
6
|
+
* returns the equal gap + the screen-coord midpoints between
|
|
7
|
+
* adjacent pairs IF all pairwise gaps are within `tolerancePx`
|
|
8
|
+
* of each other. Returns `null` when the rects are not equally
|
|
9
|
+
* distributed.
|
|
10
|
+
*
|
|
11
|
+
* Pure / DOM-free / framework-free — same convention as
|
|
12
|
+
* `computeSnap` in `use-snap-guides.tsx`. The overlay layer
|
|
13
|
+
* consumes the result and paints the pink dots.
|
|
14
|
+
*
|
|
15
|
+
* Coordinate space is the caller's choice — pass screen-space
|
|
16
|
+
* rects for live overlay rendering, or world-space rects for
|
|
17
|
+
* distribute-command verification. The math is uniform either
|
|
18
|
+
* way; only the unit of `tolerancePx` differs.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Rect } from './use-snap-guides.tsx';
|
|
22
|
+
|
|
23
|
+
export type SpacingAxis = 'x' | 'y';
|
|
24
|
+
|
|
25
|
+
export interface EqualSpacingResult {
|
|
26
|
+
axis: SpacingAxis;
|
|
27
|
+
/** The gap measured between adjacent rects (post-sort by leading edge).
|
|
28
|
+
* Caller renders this in the distance pill above each pink dot. */
|
|
29
|
+
gapPx: number;
|
|
30
|
+
/** Midpoint coords between consecutive rects. Length = rects.length - 1.
|
|
31
|
+
* Each entry is in caller's coord space; renderers anchor pink dots here. */
|
|
32
|
+
midpoints: Array<{ x: number; y: number }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface DetectOptions {
|
|
36
|
+
/** How close gaps must be to count as "equal", in caller's unit (default 1). */
|
|
37
|
+
tolerancePx?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Detect equal spacing along one axis. Returns `null` when:
|
|
42
|
+
* - fewer than 3 rects (2 rects trivially have "equal" spacing — undefined).
|
|
43
|
+
* - any pairwise gap differs from the median by more than `tolerancePx`.
|
|
44
|
+
* - rects overlap on the spacing axis (gap < 0 anywhere).
|
|
45
|
+
*
|
|
46
|
+
* The midpoint y (for axis='x') or x (for axis='y') is the average of the
|
|
47
|
+
* adjacent rects' center on the perpendicular axis — this places the pink
|
|
48
|
+
* dot vertically centered between the two siblings, which is where the
|
|
49
|
+
* distance pill anchors above.
|
|
50
|
+
*/
|
|
51
|
+
export function detectEqualSpacing(
|
|
52
|
+
rects: Rect[],
|
|
53
|
+
axis: SpacingAxis,
|
|
54
|
+
opts: DetectOptions = {}
|
|
55
|
+
): EqualSpacingResult | null {
|
|
56
|
+
if (rects.length < 3) return null;
|
|
57
|
+
const tol = opts.tolerancePx ?? 1;
|
|
58
|
+
|
|
59
|
+
// Sort by leading edge on the spacing axis.
|
|
60
|
+
const sorted = [...rects].sort((a, b) => (axis === 'x' ? a.x - b.x : a.y - b.y));
|
|
61
|
+
|
|
62
|
+
// Compute pairwise gaps + midpoints.
|
|
63
|
+
const gaps: number[] = [];
|
|
64
|
+
const midpoints: Array<{ x: number; y: number }> = [];
|
|
65
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
66
|
+
const prev = sorted[i - 1];
|
|
67
|
+
const cur = sorted[i];
|
|
68
|
+
if (!prev || !cur) return null;
|
|
69
|
+
if (axis === 'x') {
|
|
70
|
+
const gap = cur.x - (prev.x + prev.w);
|
|
71
|
+
if (gap < 0) return null; // overlap; not a distributed set
|
|
72
|
+
gaps.push(gap);
|
|
73
|
+
midpoints.push({
|
|
74
|
+
x: prev.x + prev.w + gap / 2,
|
|
75
|
+
y: (prev.y + prev.h / 2 + (cur.y + cur.h / 2)) / 2,
|
|
76
|
+
});
|
|
77
|
+
} else {
|
|
78
|
+
const gap = cur.y - (prev.y + prev.h);
|
|
79
|
+
if (gap < 0) return null;
|
|
80
|
+
gaps.push(gap);
|
|
81
|
+
midpoints.push({
|
|
82
|
+
x: (prev.x + prev.w / 2 + (cur.x + cur.w / 2)) / 2,
|
|
83
|
+
y: prev.y + prev.h + gap / 2,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// All gaps must be within tolerance of each other. Use the median as anchor
|
|
89
|
+
// to be robust against a single outlier — though if any gap is outside the
|
|
90
|
+
// band we return null, so median vs mean is academic here.
|
|
91
|
+
const sortedGaps = [...gaps].sort((a, b) => a - b);
|
|
92
|
+
const median = sortedGaps[Math.floor(sortedGaps.length / 2)] ?? 0;
|
|
93
|
+
for (const g of gaps) {
|
|
94
|
+
if (Math.abs(g - median) > tol) return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { axis, gapPx: median, midpoints };
|
|
98
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file equal-spacing-handles.tsx — T27 (Wave 3)
|
|
3
|
+
* @scope plugins/design/dev-server/equal-spacing-handles.tsx
|
|
4
|
+
* @purpose Figma Smart Selection pink-dot affordance. When 3+ elements are
|
|
5
|
+
* selected AND their bounding rects are equally distributed on an
|
|
6
|
+
* axis, render small pink dots at the midpoints between adjacent
|
|
7
|
+
* pairs, each with a gap-distance pill above. Hover-gated:
|
|
8
|
+
* dots are visible only while the cursor is within the union
|
|
9
|
+
* bbox + 40 px padding — discoverable but not noisy.
|
|
10
|
+
*
|
|
11
|
+
* Drag-to-adjust the gap is a Wave 3.x follow-up; for v1 we
|
|
12
|
+
* paint the affordance only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useEffect, useRef, useState } from 'react';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
type EqualSpacingResult,
|
|
19
|
+
type SpacingAxis,
|
|
20
|
+
detectEqualSpacing,
|
|
21
|
+
} from './equal-spacing-detector.ts';
|
|
22
|
+
import { useSelectionSet } from './use-selection-set.tsx';
|
|
23
|
+
|
|
24
|
+
const HOVER_PADDING_PX = 40;
|
|
25
|
+
/** Equal-spacing tolerance in screen pixels — distribute leaves 0–1 px
|
|
26
|
+
* rounding error; 2 px is comfortably above noise without flagging
|
|
27
|
+
* intentionally-uneven sets. */
|
|
28
|
+
const EQUAL_TOLERANCE_PX = 2;
|
|
29
|
+
|
|
30
|
+
const STYLES = `
|
|
31
|
+
.dc-cv-eq-spacing-layer {
|
|
32
|
+
position: fixed;
|
|
33
|
+
inset: 0;
|
|
34
|
+
pointer-events: none;
|
|
35
|
+
z-index: 5;
|
|
36
|
+
}
|
|
37
|
+
.dc-cv-eq-dot {
|
|
38
|
+
position: absolute;
|
|
39
|
+
width: 6px;
|
|
40
|
+
height: 6px;
|
|
41
|
+
border-radius: 50%;
|
|
42
|
+
background: #FF24BD;
|
|
43
|
+
transform: translate(-50%, -50%);
|
|
44
|
+
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.85);
|
|
45
|
+
opacity: 0;
|
|
46
|
+
transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
47
|
+
}
|
|
48
|
+
.dc-cv-eq-pill {
|
|
49
|
+
position: absolute;
|
|
50
|
+
transform: translate(-50%, calc(-100% - 8px));
|
|
51
|
+
font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace);
|
|
52
|
+
font-size: 10px;
|
|
53
|
+
padding: 2px 5px;
|
|
54
|
+
background: #FF24BD;
|
|
55
|
+
color: #ffffff;
|
|
56
|
+
border-radius: 2px;
|
|
57
|
+
letter-spacing: 0.02em;
|
|
58
|
+
white-space: nowrap;
|
|
59
|
+
opacity: 0;
|
|
60
|
+
transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
61
|
+
}
|
|
62
|
+
.dc-cv-eq-spacing-layer[data-visible="true"] .dc-cv-eq-dot,
|
|
63
|
+
.dc-cv-eq-spacing-layer[data-visible="true"] .dc-cv-eq-pill {
|
|
64
|
+
opacity: 1;
|
|
65
|
+
}
|
|
66
|
+
@media (prefers-reduced-motion: reduce) {
|
|
67
|
+
.dc-cv-eq-dot, .dc-cv-eq-pill { transition-duration: 1ms; }
|
|
68
|
+
}
|
|
69
|
+
`.trim();
|
|
70
|
+
|
|
71
|
+
function ensureStyles(): void {
|
|
72
|
+
if (typeof document === 'undefined') return;
|
|
73
|
+
if (document.getElementById('dc-cv-eq-spacing-css')) return;
|
|
74
|
+
const s = document.createElement('style');
|
|
75
|
+
s.id = 'dc-cv-eq-spacing-css';
|
|
76
|
+
s.textContent = STYLES;
|
|
77
|
+
document.head.appendChild(s);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function cssEscape(s: string): string {
|
|
81
|
+
// CSS.escape is available everywhere we care; manual fallback handles the
|
|
82
|
+
// jsdom test path where it may be missing.
|
|
83
|
+
// biome-ignore lint/suspicious/noExplicitAny: feature detect
|
|
84
|
+
const ce = (typeof CSS !== 'undefined' ? (CSS as any).escape : null) as
|
|
85
|
+
| ((v: string) => string)
|
|
86
|
+
| null;
|
|
87
|
+
if (ce) return ce(s);
|
|
88
|
+
return s.replace(/(["\\\]])/g, '\\$1');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function safeQuery(selector: string): Element | null {
|
|
92
|
+
try {
|
|
93
|
+
return document.querySelector(selector);
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface ScreenRect {
|
|
100
|
+
x: number;
|
|
101
|
+
y: number;
|
|
102
|
+
w: number;
|
|
103
|
+
h: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveSelectionRects(sels: Array<{ id?: string; selector: string }>): ScreenRect[] {
|
|
107
|
+
const rects: ScreenRect[] = [];
|
|
108
|
+
for (const s of sels) {
|
|
109
|
+
const el = s.id
|
|
110
|
+
? document.querySelector(`[data-cd-id="${cssEscape(s.id)}"]`)
|
|
111
|
+
: safeQuery(s.selector);
|
|
112
|
+
if (!el) continue;
|
|
113
|
+
const b = (el as HTMLElement).getBoundingClientRect();
|
|
114
|
+
if (b.width === 0 && b.height === 0) continue;
|
|
115
|
+
rects.push({ x: b.left, y: b.top, w: b.width, h: b.height });
|
|
116
|
+
}
|
|
117
|
+
return rects;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function unionBbox(rects: ScreenRect[]): ScreenRect | null {
|
|
121
|
+
if (rects.length === 0) return null;
|
|
122
|
+
let left = Number.POSITIVE_INFINITY;
|
|
123
|
+
let top = Number.POSITIVE_INFINITY;
|
|
124
|
+
let right = Number.NEGATIVE_INFINITY;
|
|
125
|
+
let bottom = Number.NEGATIVE_INFINITY;
|
|
126
|
+
for (const r of rects) {
|
|
127
|
+
if (r.x < left) left = r.x;
|
|
128
|
+
if (r.y < top) top = r.y;
|
|
129
|
+
if (r.x + r.w > right) right = r.x + r.w;
|
|
130
|
+
if (r.y + r.h > bottom) bottom = r.y + r.h;
|
|
131
|
+
}
|
|
132
|
+
return { x: left, y: top, w: right - left, h: bottom - top };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function pointInPaddedBox(px: number, py: number, b: ScreenRect, pad: number): boolean {
|
|
136
|
+
return px >= b.x - pad && px <= b.x + b.w + pad && py >= b.y - pad && py <= b.y + b.h + pad;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function EqualSpacingHandles() {
|
|
140
|
+
ensureStyles();
|
|
141
|
+
const { selected } = useSelectionSet();
|
|
142
|
+
const layerRef = useRef<HTMLDivElement | null>(null);
|
|
143
|
+
const rafRef = useRef<number | null>(null);
|
|
144
|
+
const [hovered, setHovered] = useState(false);
|
|
145
|
+
|
|
146
|
+
// Track cursor position to gate dot visibility. We hide handles whenever
|
|
147
|
+
// the cursor leaves the (union bbox + padding) zone — discoverable on
|
|
148
|
+
// approach, invisible while the user is doing unrelated work elsewhere.
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (typeof document === 'undefined') return;
|
|
151
|
+
const onMove = (e: PointerEvent) => {
|
|
152
|
+
const layer = layerRef.current;
|
|
153
|
+
if (!layer) return;
|
|
154
|
+
const dotsZone = layer.getBoundingClientRect();
|
|
155
|
+
// Layer is inset:0 in screen-fixed coords. We compare against the
|
|
156
|
+
// currently painted union bbox stored on a data attr each rAF tick.
|
|
157
|
+
const ubStr = layer.getAttribute('data-union-bbox');
|
|
158
|
+
if (!ubStr) {
|
|
159
|
+
setHovered(false);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// Avoid TS-touchy split — manual parse.
|
|
163
|
+
const parts = ubStr.split(',');
|
|
164
|
+
const x = Number(parts[0]);
|
|
165
|
+
const y = Number(parts[1]);
|
|
166
|
+
const w = Number(parts[2]);
|
|
167
|
+
const h = Number(parts[3]);
|
|
168
|
+
if (Number.isNaN(x) || Number.isNaN(y)) return;
|
|
169
|
+
const inZone = pointInPaddedBox(e.clientX, e.clientY, { x, y, w, h }, HOVER_PADDING_PX);
|
|
170
|
+
if (inZone !== hovered) setHovered(inZone);
|
|
171
|
+
// Touch `dotsZone` to keep the ref-read in the dependency graph.
|
|
172
|
+
void dotsZone;
|
|
173
|
+
};
|
|
174
|
+
document.addEventListener('pointermove', onMove, { passive: true });
|
|
175
|
+
return () => document.removeEventListener('pointermove', onMove);
|
|
176
|
+
}, [hovered]);
|
|
177
|
+
|
|
178
|
+
// Resolve current selection → DOM rects → equal-spacing detector → paint.
|
|
179
|
+
// rAF loop so the affordance follows zoom + pan smoothly without React
|
|
180
|
+
// re-renders per frame.
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (selected.length < 3) {
|
|
183
|
+
// Clear layer when selection drops below the affordance threshold.
|
|
184
|
+
const layer = layerRef.current;
|
|
185
|
+
if (layer) {
|
|
186
|
+
while (layer.firstChild) layer.removeChild(layer.firstChild);
|
|
187
|
+
layer.removeAttribute('data-union-bbox');
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
let alive = true;
|
|
192
|
+
|
|
193
|
+
const tick = () => {
|
|
194
|
+
if (!alive) return;
|
|
195
|
+
rafRef.current = null;
|
|
196
|
+
const layer = layerRef.current;
|
|
197
|
+
if (!layer) return;
|
|
198
|
+
const rects = resolveSelectionRects(selected);
|
|
199
|
+
if (rects.length < 3) {
|
|
200
|
+
while (layer.firstChild) layer.removeChild(layer.firstChild);
|
|
201
|
+
layer.removeAttribute('data-union-bbox');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// Detect on both axes; prefer the axis with the smaller relative spread
|
|
205
|
+
// (the "more confidently distributed" axis). Ties = horizontal wins.
|
|
206
|
+
const detX = detectEqualSpacing(rects, 'x', { tolerancePx: EQUAL_TOLERANCE_PX });
|
|
207
|
+
const detY = detectEqualSpacing(rects, 'y', { tolerancePx: EQUAL_TOLERANCE_PX });
|
|
208
|
+
const result = pickDetection(detX, detY);
|
|
209
|
+
if (!result) {
|
|
210
|
+
while (layer.firstChild) layer.removeChild(layer.firstChild);
|
|
211
|
+
layer.removeAttribute('data-union-bbox');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const ub = unionBbox(rects);
|
|
216
|
+
if (ub) layer.setAttribute('data-union-bbox', `${ub.x},${ub.y},${ub.w},${ub.h}`);
|
|
217
|
+
|
|
218
|
+
paintHandles(layer, result);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const onFrame = () => {
|
|
222
|
+
if (rafRef.current != null) return;
|
|
223
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// Initial paint + continuous follow on pan/zoom/scroll.
|
|
227
|
+
onFrame();
|
|
228
|
+
const recheck = () => onFrame();
|
|
229
|
+
window.addEventListener('scroll', recheck, { passive: true, capture: true });
|
|
230
|
+
window.addEventListener('resize', recheck, { passive: true });
|
|
231
|
+
const observer = new MutationObserver(() => onFrame());
|
|
232
|
+
observer.observe(document.body, { attributes: true, subtree: true, childList: true });
|
|
233
|
+
|
|
234
|
+
return () => {
|
|
235
|
+
alive = false;
|
|
236
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
237
|
+
window.removeEventListener('scroll', recheck, { capture: true } as EventListenerOptions);
|
|
238
|
+
window.removeEventListener('resize', recheck);
|
|
239
|
+
observer.disconnect();
|
|
240
|
+
};
|
|
241
|
+
}, [selected]);
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<div
|
|
245
|
+
ref={layerRef}
|
|
246
|
+
className="dc-cv-eq-spacing-layer"
|
|
247
|
+
data-visible={hovered ? 'true' : 'false'}
|
|
248
|
+
aria-hidden="true"
|
|
249
|
+
/>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function pickDetection(
|
|
254
|
+
detX: EqualSpacingResult | null,
|
|
255
|
+
detY: EqualSpacingResult | null
|
|
256
|
+
): EqualSpacingResult | null {
|
|
257
|
+
if (detX && detY) return detX.midpoints.length >= detY.midpoints.length ? detX : detY;
|
|
258
|
+
return detX ?? detY;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function paintHandles(layer: HTMLDivElement, result: EqualSpacingResult): void {
|
|
262
|
+
// Match child count to 2× midpoints (each midpoint = one dot + one pill).
|
|
263
|
+
const want = result.midpoints.length * 2;
|
|
264
|
+
while (layer.children.length < want) {
|
|
265
|
+
const isDot = layer.children.length % 2 === 0;
|
|
266
|
+
const node = document.createElement('div');
|
|
267
|
+
node.className = isDot ? 'dc-cv-eq-dot' : 'dc-cv-eq-pill';
|
|
268
|
+
if (!isDot) node.textContent = '';
|
|
269
|
+
layer.appendChild(node);
|
|
270
|
+
}
|
|
271
|
+
while (layer.children.length > want) {
|
|
272
|
+
layer.removeChild(layer.lastChild as Node);
|
|
273
|
+
}
|
|
274
|
+
const label = `${Math.round(result.gapPx)}`;
|
|
275
|
+
for (let i = 0; i < result.midpoints.length; i++) {
|
|
276
|
+
const m = result.midpoints[i];
|
|
277
|
+
if (!m) continue;
|
|
278
|
+
const dot = layer.children[i * 2] as HTMLDivElement;
|
|
279
|
+
const pill = layer.children[i * 2 + 1] as HTMLDivElement;
|
|
280
|
+
dot.style.left = `${m.x}px`;
|
|
281
|
+
dot.style.top = `${m.y}px`;
|
|
282
|
+
pill.style.left = `${m.x}px`;
|
|
283
|
+
pill.style.top = `${m.y}px`;
|
|
284
|
+
pill.textContent = label;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Re-export the picker for tests.
|
|
289
|
+
export { pickDetection };
|
|
@@ -139,7 +139,7 @@ const DIALOG_CSS = `
|
|
|
139
139
|
.dc-export-dialog .dc-ed-recent button:hover { background: rgba(0,0,0,0.04); border-color: rgba(0,0,0,0.06); }
|
|
140
140
|
.dc-export-dialog footer { padding: 12px 20px; border-top: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08)); display: flex; justify-content: flex-end; gap: 8px; }
|
|
141
141
|
.dc-export-dialog footer button { padding: 8px 14px; border-radius: 0; border: 1px solid var(--u-fg-0, rgba(0,0,0,0.12)); background: var(--u-bg-1, var(--bg-0, #fafafa)); font: inherit; font-size: 12px; cursor: pointer; color: inherit; }
|
|
142
|
-
.dc-export-dialog footer button.dc-ed-primary { background: var(--accent, #1a1a1a); color: var(--accent-fg, #fff); border-color: transparent; }
|
|
142
|
+
.dc-export-dialog footer button.dc-ed-primary { background: var(--maude-hud-accent, #1a1a1a); color: var(--maude-hud-accent-fg, #fff); border-color: transparent; }
|
|
143
143
|
.dc-export-dialog footer button:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
144
144
|
.dc-export-dialog .dc-ed-status { padding: 8px 20px; font-size: 12px; color: var(--fg-1, rgba(40,30,20,0.65)); border-top: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08)); }
|
|
145
145
|
.dc-export-dialog .dc-ed-status.is-error { color: var(--status-error, #c0392b); }
|
|
@@ -554,6 +554,16 @@ export async function emitRegistryItem(opts: EmitOptions): Promise<RegistryItem>
|
|
|
554
554
|
// The trick: replace `DesignCanvas`, `DCArtboard`, `DCSection` in the
|
|
555
555
|
// libMap with their static-frame variants before BFS. The static variants
|
|
556
556
|
// have empty deps, so the transitive walk never reaches the engine code.
|
|
557
|
+
//
|
|
558
|
+
// Phase 3.7 / DDR-049 — motion helpers (MotionDemo, MotionTrack,
|
|
559
|
+
// TokenPlayback, ReducedMotionToggle, useMotionTokens, easingFromToken)
|
|
560
|
+
// depend on aliased imports from motion/react (_motionImpl,
|
|
561
|
+
// _useReducedMotion, _MotionAnimatePresence). When any of those land in the
|
|
562
|
+
// inlined output, splice the matching motion/react import line at the file
|
|
563
|
+
// head AND force-add "motion" to the registry-item's dependencies. The
|
|
564
|
+
// consumer's npm install + Next.js bundler resolves motion → bunx shadcn
|
|
565
|
+
// add lands an animated component with zero manual wiring.
|
|
566
|
+
let motionUsed = false;
|
|
557
567
|
if (opts.designRoot) {
|
|
558
568
|
const libPath = canvasLibPath(opts.designRoot);
|
|
559
569
|
const libFile = Bun.file(libPath);
|
|
@@ -563,6 +573,19 @@ export async function emitRegistryItem(opts: EmitOptions): Promise<RegistryItem>
|
|
|
563
573
|
applyHandoffStaticOverrides(libMap);
|
|
564
574
|
const inlined = inlineUsedExports(tsx, libMap);
|
|
565
575
|
tsx = inlined.content;
|
|
576
|
+
// Detect motion-helper usage from the inlined surface (the body refs
|
|
577
|
+
// _motionImpl / _useReducedMotion / _MotionAnimatePresence). We probe
|
|
578
|
+
// the post-inline source so we don't false-positive on a canvas that
|
|
579
|
+
// imports a non-motion helper sharing a name prefix.
|
|
580
|
+
motionUsed =
|
|
581
|
+
/\b_motionImpl\b/.test(tsx) ||
|
|
582
|
+
/\b_useReducedMotion\b/.test(tsx) ||
|
|
583
|
+
/\b_MotionAnimatePresence\b/.test(tsx);
|
|
584
|
+
if (motionUsed) {
|
|
585
|
+
const motionImport =
|
|
586
|
+
"import { motion as _motionImpl, useReducedMotion as _useReducedMotion, AnimatePresence as _MotionAnimatePresence } from 'motion/react';\n";
|
|
587
|
+
tsx = `${motionImport}${tsx}`;
|
|
588
|
+
}
|
|
566
589
|
}
|
|
567
590
|
}
|
|
568
591
|
|
|
@@ -578,6 +601,7 @@ export async function emitRegistryItem(opts: EmitOptions): Promise<RegistryItem>
|
|
|
578
601
|
const depSet = new Set(depsFiltered);
|
|
579
602
|
depSet.add('react');
|
|
580
603
|
depSet.add('react-dom');
|
|
604
|
+
if (motionUsed) depSet.add('motion');
|
|
581
605
|
const finalDeps = [...depSet].sort();
|
|
582
606
|
|
|
583
607
|
// Compute slug for `name` field — kebab-case of the file stem.
|
|
@@ -25,10 +25,13 @@ const DEBOUNCE_MS = 50;
|
|
|
25
25
|
|
|
26
26
|
export interface HmrMessage {
|
|
27
27
|
type: 'canvas-hmr';
|
|
28
|
-
mode: 'css' | 'module' | 'hard';
|
|
28
|
+
mode: 'css' | 'module' | 'hard' | 'meta';
|
|
29
29
|
/**
|
|
30
30
|
* Canvas-relative path of the file that changed, slash-normalised. Absent
|
|
31
31
|
* when mode === 'hard' (the change is global — every canvas reloads).
|
|
32
|
+
*
|
|
33
|
+
* For mode === 'meta' this is the `<base>.meta.json` path; iframe peels off
|
|
34
|
+
* the `.meta.json` suffix to compare against its own `canvasRel`.
|
|
32
35
|
*/
|
|
33
36
|
file?: string;
|
|
34
37
|
/** Cache-bust token — etag-like. Caller appends to <link> href. */
|
|
@@ -70,6 +73,13 @@ export function createHmrBroadcaster(
|
|
|
70
73
|
if (ext === '.css') {
|
|
71
74
|
return { type: 'canvas-hmr', mode: 'css', file: rel, version, scope: 'canvas' };
|
|
72
75
|
}
|
|
76
|
+
// Phase 8 — canvas-meta sidecar (`<base>.meta.json`) carries the
|
|
77
|
+
// artboard layout / viewport. Emit a `meta` mode so foreign tabs can
|
|
78
|
+
// re-fetch + re-apply the layout WITHOUT a full reload (which would
|
|
79
|
+
// lose React state like tool mode, undo stack, scroll position).
|
|
80
|
+
if (rel.endsWith('.meta.json')) {
|
|
81
|
+
return { type: 'canvas-hmr', mode: 'meta', file: rel, version, scope: 'canvas' };
|
|
82
|
+
}
|
|
73
83
|
if (ext === '.tsx' || ext === '.jsx' || ext === '.ts' || ext === '.js') {
|
|
74
84
|
return { type: 'canvas-hmr', mode: 'module', file: rel, version, scope: 'canvas' };
|
|
75
85
|
}
|
|
@@ -80,7 +90,10 @@ export function createHmrBroadcaster(
|
|
|
80
90
|
// Coalesce: hard > module > css. If a hard reload is queued, ignore any
|
|
81
91
|
// softer follow-up within the debounce window.
|
|
82
92
|
if (pendingMsg) {
|
|
83
|
-
|
|
93
|
+
// meta is the lightest signal — it doesn't trigger a reload, just
|
|
94
|
+
// re-fetches the sidecar. CSS still ranks above it so a same-window
|
|
95
|
+
// CSS write wins over a meta echo.
|
|
96
|
+
const rank: Record<HmrMessage['mode'], number> = { meta: 0, css: 1, module: 2, hard: 3 };
|
|
84
97
|
if (rank[msg.mode] < rank[pendingMsg.mode]) {
|
|
85
98
|
// Keep the existing (harder) message; just refresh the timer.
|
|
86
99
|
} else {
|
|
@@ -11,6 +11,7 @@ import type { Api } from './api.ts';
|
|
|
11
11
|
import { buildCanvasModule } from './canvas-build.ts';
|
|
12
12
|
import { canvasLibPath } from './canvas-lib-resolver.ts';
|
|
13
13
|
import { TranspileError } from './canvas-pipeline.ts';
|
|
14
|
+
import type { AiActivity } from './collab/ai-activity.ts';
|
|
14
15
|
import type { Context } from './context.ts';
|
|
15
16
|
import { isFormat, isScope, runExport } from './exporters/index.ts';
|
|
16
17
|
import type { ActiveJsonShape } from './exporters/scope.ts';
|
|
@@ -157,7 +158,7 @@ export interface Http {
|
|
|
157
158
|
fetch(req: Request): Promise<Response>;
|
|
158
159
|
}
|
|
159
160
|
|
|
160
|
-
export function createHttp(ctx: Context, api: Api, inspect: Inspect): Http {
|
|
161
|
+
export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActivity): Http {
|
|
161
162
|
// Cache invalidation — when canvas-lib changes, every cached canvas bundle
|
|
162
163
|
// is stale because canvas-lib is inlined into each one via the resolver
|
|
163
164
|
// plugin. Drop the whole cache so the next request rebuilds with the fresh
|
|
@@ -192,6 +193,33 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect): Http {
|
|
|
192
193
|
}
|
|
193
194
|
void libWatcher;
|
|
194
195
|
|
|
196
|
+
// G7v2 — canvas-lib.tsx transitively imports many dev-server siblings
|
|
197
|
+
// (canvas-shell, contextual-toolbar, equal-spacing-handles, ...). Editing
|
|
198
|
+
// any of them invalidates the bundled canvas output. Without watching them
|
|
199
|
+
// the mtime-keyed `canvasCache` keeps serving the stale bundle and the
|
|
200
|
+
// user sees pre-edit behaviour even after a hard iframe reload.
|
|
201
|
+
//
|
|
202
|
+
// Recursive watch over DEV_SERVER_ROOT, filtered to .tsx — server-only .ts
|
|
203
|
+
// (api / http / context / etc.) doesn't reach the canvas. Test files
|
|
204
|
+
// (`test/`) and built output (`dist/`, `client/`) also skipped.
|
|
205
|
+
let devSrcWatcher: ReturnType<typeof watch> | null = null;
|
|
206
|
+
try {
|
|
207
|
+
devSrcWatcher = watch(DEV_SERVER_ROOT, { recursive: true }, (_evt, filename) => {
|
|
208
|
+
if (!filename) return;
|
|
209
|
+
if (!filename.endsWith('.tsx')) return;
|
|
210
|
+
if (filename.startsWith('test/') || filename.startsWith('test\\')) return;
|
|
211
|
+
if (filename.startsWith('dist/') || filename.startsWith('client/')) return;
|
|
212
|
+
canvasCache.clear();
|
|
213
|
+
ctx.bus.emit('fs:any', `_lib/${filename}`);
|
|
214
|
+
});
|
|
215
|
+
} catch (err) {
|
|
216
|
+
console.warn(
|
|
217
|
+
'[dev-server-src] failed to watch source tree:',
|
|
218
|
+
err instanceof Error ? err.message : err
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
void devSrcWatcher;
|
|
222
|
+
|
|
195
223
|
async function readJson<T = unknown>(req: Request, max = 256 * 1024): Promise<T | null> {
|
|
196
224
|
try {
|
|
197
225
|
const text = await req.text();
|
|
@@ -279,6 +307,68 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect): Http {
|
|
|
279
307
|
return Response.json({ committers }, { headers: { 'Cache-Control': 'no-store' } });
|
|
280
308
|
},
|
|
281
309
|
|
|
310
|
+
'/_api/git-user': async (req: Request) => {
|
|
311
|
+
// Phase 8 — local `git config user.name` for the collab Awareness peer
|
|
312
|
+
// identity. Color-hash derives from this; falls back to anonymous-<pid>
|
|
313
|
+
// client-side when empty.
|
|
314
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
315
|
+
const name = await api.gitCurrentUser();
|
|
316
|
+
return Response.json({ name }, { headers: { 'Cache-Control': 'no-store' } });
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
'/_api/ai': async (req: Request) => {
|
|
320
|
+
// Phase 8 Task 4 — read-only snapshot of the current AI activity map.
|
|
321
|
+
// GET → { entries: [{ file, author, startedAt, lastHeartbeat }, …] }
|
|
322
|
+
// Clients use this on mount to backfill the banner state without
|
|
323
|
+
// waiting for the next bus event.
|
|
324
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
325
|
+
return Response.json({ entries: ai.list() }, { headers: { 'Cache-Control': 'no-store' } });
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
'/_api/ai/start': async (req: Request) => {
|
|
329
|
+
// Phase 8 Task 4 — `/design:edit` (or any external slash command driving
|
|
330
|
+
// Claude work) POSTs here when work begins. body = { file, author }.
|
|
331
|
+
// Replaces any prior entry for the file.
|
|
332
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
333
|
+
const body = await readJson<{ file?: string; author?: string }>(req);
|
|
334
|
+
if (!body || typeof body.file !== 'string' || !body.file.trim()) {
|
|
335
|
+
return new Response('body.file required', { status: 400 });
|
|
336
|
+
}
|
|
337
|
+
const author =
|
|
338
|
+
typeof body.author === 'string' && body.author.trim()
|
|
339
|
+
? body.author.trim().slice(0, 120)
|
|
340
|
+
: 'Claude';
|
|
341
|
+
const entry = ai.start(body.file.trim(), author);
|
|
342
|
+
return Response.json(entry, { headers: { 'Cache-Control': 'no-store' } });
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
'/_api/ai/heartbeat': async (req: Request) => {
|
|
346
|
+
// Refresh the lastHeartbeat. Returns 404 if no entry — slash command
|
|
347
|
+
// can treat that as "the server bounced; re-issue /start".
|
|
348
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
349
|
+
const body = await readJson<{ file?: string }>(req);
|
|
350
|
+
if (!body || typeof body.file !== 'string' || !body.file.trim()) {
|
|
351
|
+
return new Response('body.file required', { status: 400 });
|
|
352
|
+
}
|
|
353
|
+
const entry = ai.heartbeat(body.file.trim());
|
|
354
|
+
if (!entry) return new Response('no active entry', { status: 404 });
|
|
355
|
+
return Response.json(entry, { headers: { 'Cache-Control': 'no-store' } });
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
'/_api/ai/end': async (req: Request) => {
|
|
359
|
+
// Explicit completion (normal or error). Banner clears immediately.
|
|
360
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
361
|
+
const body = await readJson<{ file?: string }>(req);
|
|
362
|
+
if (!body || typeof body.file !== 'string' || !body.file.trim()) {
|
|
363
|
+
return new Response('body.file required', { status: 400 });
|
|
364
|
+
}
|
|
365
|
+
const cleared = ai.end(body.file.trim());
|
|
366
|
+
return Response.json(
|
|
367
|
+
{ cleared },
|
|
368
|
+
{ status: cleared ? 200 : 404, headers: { 'Cache-Control': 'no-store' } }
|
|
369
|
+
);
|
|
370
|
+
},
|
|
371
|
+
|
|
282
372
|
'/_api/annotations': async (req: Request) => {
|
|
283
373
|
// Phase 5 — `<designRoot>/<slug>.annotations.svg` read / overwrite.
|
|
284
374
|
// GET ?file=<repo-relative-canvas-path> → SVG text (empty if absent)
|