@1agh/maude 0.22.0 → 0.23.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/cli/commands/design-link.test.mjs +53 -1
- package/cli/commands/hub.test.mjs +10 -9
- package/cli/lib/design-link.mjs +154 -7
- package/cli/lib/hubs-config.mjs +42 -4
- package/package.json +9 -9
- package/plugins/design/dev-server/bin/check-runtime-bundles.sh +125 -0
- package/plugins/design/dev-server/bin/runtime-health.sh +47 -6
- package/plugins/design/dev-server/build.ts +87 -7
- package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
- package/plugins/design/dev-server/canvas-lib.tsx +22 -6
- package/plugins/design/dev-server/canvas-shell.tsx +25 -226
- package/plugins/design/dev-server/client/app.jsx +37 -15
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/registry.ts +51 -0
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context.ts +7 -0
- package/plugins/design/dev-server/dist/client.bundle.js +21 -12
- package/plugins/design/dev-server/dist/comment-mount.js +1801 -0
- package/plugins/design/dev-server/dist/runtime/.min-sizes.json +16 -0
- package/plugins/design/dev-server/dist/runtime/lib0_decoding.js +2 -6
- package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +2 -6
- package/plugins/design/dev-server/dist/runtime/motion.js +4787 -8
- package/plugins/design/dev-server/dist/runtime/motion_react.js +9654 -7
- package/plugins/design/dev-server/dist/runtime/pixi-js.js +5865 -5469
- package/plugins/design/dev-server/dist/runtime/react-dom.js +4 -22
- package/plugins/design/dev-server/dist/runtime/react-dom_client.js +5 -23
- package/plugins/design/dev-server/dist/runtime/react.js +4 -22
- package/plugins/design/dev-server/dist/runtime/react_jsx-dev-runtime.js +4 -22
- package/plugins/design/dev-server/dist/runtime/react_jsx-runtime.js +4 -22
- package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +2 -6
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +2 -6
- package/plugins/design/dev-server/dist/runtime/yjs.js +2 -6
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/input-router.tsx +99 -61
- package/plugins/design/dev-server/server.ts +18 -0
- package/plugins/design/dev-server/sync/agent.ts +323 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +169 -0
- package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
- package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
- package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
- package/plugins/design/dev-server/sync/index.ts +474 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
- package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
- package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
- package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
- package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
- package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
- package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +285 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/use-collab.tsx +157 -13
- package/plugins/design/dev-server/use-selection-set.tsx +12 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +12 -0
- package/plugins/design/templates/_shell.html +15 -5
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -0
|
@@ -114,6 +114,58 @@ async function buildClient(): Promise<{ outBytes: number; outPath: string }> {
|
|
|
114
114
|
return { outBytes: out.size, outPath };
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// ---------- (a.5) Comment mount layer (shell-owned comments) ----------
|
|
118
|
+
//
|
|
119
|
+
// canvas-comment-mount.tsx → dist/comment-mount.js. Loaded by _shell.html via
|
|
120
|
+
// a `<script type="module">`; its `mountCanvas(...)` wraps any canvas default
|
|
121
|
+
// export in the lite comment provider tree. React + the other canvas-runtime
|
|
122
|
+
// packages are EXTERNAL (bare specifiers resolved by _shell.html's importmap)
|
|
123
|
+
// so this bundle shares the single React/yjs singletons with the canvas
|
|
124
|
+
// module — inlining a second React would break hooks ("invalid hook call").
|
|
125
|
+
|
|
126
|
+
const COMMENT_MOUNT_EXTERNALS = [
|
|
127
|
+
'react',
|
|
128
|
+
'react-dom',
|
|
129
|
+
'react-dom/client',
|
|
130
|
+
'react/jsx-runtime',
|
|
131
|
+
'react/jsx-dev-runtime',
|
|
132
|
+
'yjs',
|
|
133
|
+
'y-protocols/sync',
|
|
134
|
+
'y-protocols/awareness',
|
|
135
|
+
'lib0/decoding',
|
|
136
|
+
'lib0/encoding',
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
async function buildCommentMount(): Promise<{ outBytes: number; outPath: string }> {
|
|
140
|
+
ensureDist();
|
|
141
|
+
const outPath = join(DIST, 'comment-mount.js');
|
|
142
|
+
const result = await Bun.build({
|
|
143
|
+
entrypoints: [join(ROOT, 'canvas-comment-mount.tsx')],
|
|
144
|
+
outdir: DIST,
|
|
145
|
+
target: 'browser',
|
|
146
|
+
format: 'esm',
|
|
147
|
+
naming: 'comment-mount.js',
|
|
148
|
+
external: COMMENT_MOUNT_EXTERNALS,
|
|
149
|
+
minify: MODE === 'release',
|
|
150
|
+
sourcemap: MODE === 'dev' ? 'inline' : 'none',
|
|
151
|
+
define: {
|
|
152
|
+
// ALWAYS production React — like canvas-build.ts. React is external and
|
|
153
|
+
// resolves through the importmap to the dev-server's PRODUCTION runtime
|
|
154
|
+
// bundles, where `react/jsx-dev-runtime`'s `jsxDEV` is a no-op (undefined
|
|
155
|
+
// at call time). Emitting dev jsx (`jsxDEV`) against a production runtime
|
|
156
|
+
// throws "jsxDEV is not a function". Both halves must agree on the JSX
|
|
157
|
+
// flavour — production jsx (`react/jsx-runtime`) is the contract.
|
|
158
|
+
'process.env.NODE_ENV': '"production"',
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
if (!result.success) {
|
|
162
|
+
const messages = result.logs.map((l) => l.message ?? String(l)).join('\n');
|
|
163
|
+
throw new Error(`Comment-mount build failed:\n${messages}`);
|
|
164
|
+
}
|
|
165
|
+
const out = Bun.file(outPath);
|
|
166
|
+
return { outBytes: out.size, outPath };
|
|
167
|
+
}
|
|
168
|
+
|
|
117
169
|
// ---------- (b) CSS bundle (Lightning CSS) ----------
|
|
118
170
|
|
|
119
171
|
async function buildCss(): Promise<{ outBytes: number; outPath: string }> {
|
|
@@ -325,6 +377,7 @@ async function buildServerBinary(target: PlatformTarget): Promise<{ outPath: str
|
|
|
325
377
|
|
|
326
378
|
async function watch() {
|
|
327
379
|
await buildClient();
|
|
380
|
+
await buildCommentMount();
|
|
328
381
|
await buildCss();
|
|
329
382
|
console.log('[build:watch] initial build complete; watching client/ + server source...');
|
|
330
383
|
|
|
@@ -396,20 +449,40 @@ async function main() {
|
|
|
396
449
|
`[build] client.bundle.js ${client.outBytes.toLocaleString()} B (${(t1 - t0).toFixed(0)} ms)`
|
|
397
450
|
);
|
|
398
451
|
|
|
452
|
+
const commentMount = await buildCommentMount();
|
|
453
|
+
const t1b = performance.now();
|
|
454
|
+
console.log(
|
|
455
|
+
`[build] comment-mount.js ${commentMount.outBytes.toLocaleString()} B (${(t1b - t1).toFixed(0)} ms)`
|
|
456
|
+
);
|
|
457
|
+
|
|
399
458
|
const css = await buildCss();
|
|
400
459
|
const t2 = performance.now();
|
|
401
460
|
console.log(
|
|
402
|
-
`[build] styles.css ${css.outBytes.toLocaleString()} B (${(t2 -
|
|
461
|
+
`[build] styles.css ${css.outBytes.toLocaleString()} B (${(t2 - t1b).toFixed(0)} ms)`
|
|
403
462
|
);
|
|
404
463
|
|
|
405
464
|
// Pre-built runtime bundles — ship to disk so /_canvas-runtime/* never
|
|
406
465
|
// needs a runtime Bun.build (which would need disk node_modules/react,
|
|
407
466
|
// absent in compiled binaries + npm installs). Phase 19.1 / v0.18.1.
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
467
|
+
//
|
|
468
|
+
// MAUDE_SKIP_RUNTIME_BUILD=1 — skip the regen and trust the committed
|
|
469
|
+
// dist/runtime/*.js. Set in CI publish-main so the npm tarball ships
|
|
470
|
+
// exactly what was committed in git (platform-agnostic). v0.22.0 shipped
|
|
471
|
+
// a broken Ubuntu-CI motion_react.js because the on-disk authoritative
|
|
472
|
+
// bundle got overwritten by `pnpm build`; this flag prevents that class
|
|
473
|
+
// of regression. The check-runtime-bundles.sh step still validates the
|
|
474
|
+
// on-disk artifacts against .min-sizes.json after the build.
|
|
475
|
+
if (process.env.MAUDE_SKIP_RUNTIME_BUILD === '1') {
|
|
476
|
+
console.log(
|
|
477
|
+
'[build] dist/runtime/*.js SKIPPED (MAUDE_SKIP_RUNTIME_BUILD=1 — using committed pre-built)'
|
|
478
|
+
);
|
|
479
|
+
} else {
|
|
480
|
+
const runtime = await buildRuntimeBundles();
|
|
481
|
+
const t2b = performance.now();
|
|
482
|
+
console.log(
|
|
483
|
+
`[build] dist/runtime/*.js ${runtime.bytes.toLocaleString()} B in ${runtime.count} files (${(t2b - t2).toFixed(0)} ms)`
|
|
484
|
+
);
|
|
485
|
+
}
|
|
413
486
|
|
|
414
487
|
if (MODE === 'release') {
|
|
415
488
|
const targets: PlatformTarget[] = FLAG_TARGET
|
|
@@ -430,4 +503,11 @@ if (import.meta.main) {
|
|
|
430
503
|
await main();
|
|
431
504
|
}
|
|
432
505
|
|
|
433
|
-
export {
|
|
506
|
+
export {
|
|
507
|
+
buildClient,
|
|
508
|
+
buildCommentMount,
|
|
509
|
+
buildCss,
|
|
510
|
+
buildServerBinary,
|
|
511
|
+
PLATFORM_MATRIX,
|
|
512
|
+
type PlatformTarget,
|
|
513
|
+
};
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file canvas-comment-mount.tsx — shell-owned comment layer + mountCanvas
|
|
3
|
+
* @scope plugins/design/dev-server/canvas-comment-mount.tsx
|
|
4
|
+
* @purpose The canvas mount harness (`_shell.html`) calls `mountCanvas`
|
|
5
|
+
* instead of rendering the canvas default-export raw. mountCanvas
|
|
6
|
+
* wraps ANY default export — a `DesignCanvas` UI canvas OR a bare
|
|
7
|
+
* DS specimen — in a LITE comment provider tree so the in-place
|
|
8
|
+
* comment tool works on every mounted surface.
|
|
9
|
+
*
|
|
10
|
+
* Why a single shell-owned layer (DDR — see plan §"Key decision"):
|
|
11
|
+
* - Comments used to be mounted only by `DesignCanvas` (ToolProvider +
|
|
12
|
+
* SelectionSetProvider + CommentsOverlay + the onDropComment router
|
|
13
|
+
* branch). Bare specimens (`system/<ds>/preview/*.tsx`) never render
|
|
14
|
+
* DesignCanvas, so they had no comment tool at all.
|
|
15
|
+
* - Hoisting the comment subsystem here makes it universal. `DesignCanvas`
|
|
16
|
+
* becomes a CONSUMER of the shell-provided ToolProvider / SelectionSet /
|
|
17
|
+
* CommentsOverlay (via MaybeToolProvider / MaybeSelectionSetProvider and
|
|
18
|
+
* by dropping its own <CommentsOverlay/>).
|
|
19
|
+
*
|
|
20
|
+
* Coexistence with the UI-canvas router: this layer's input router is an
|
|
21
|
+
* ANCESTOR capture-listener over the canvas. On a UI canvas, `CanvasShell`
|
|
22
|
+
* still runs its OWN router (hover / select / context-menu / undo). To avoid
|
|
23
|
+
* swallowing those gestures, this router passes a narrow `claimableActions`
|
|
24
|
+
* allowlist — `drop-comment` / `tool` / `escape` / `hover`. `hover` never
|
|
25
|
+
* preventDefaults so the inner router's halo is unaffected; everything else
|
|
26
|
+
* (select / context-menu / undo) propagates untouched to the inner router.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
type ComponentType,
|
|
31
|
+
type ReactNode,
|
|
32
|
+
createElement,
|
|
33
|
+
useEffect,
|
|
34
|
+
useMemo,
|
|
35
|
+
useRef,
|
|
36
|
+
useState,
|
|
37
|
+
} from 'react';
|
|
38
|
+
import { createRoot } from 'react-dom/client';
|
|
39
|
+
|
|
40
|
+
import { CommentsOverlay } from './comments-overlay.tsx';
|
|
41
|
+
import { deriveFile, hoverTargetToSelection } from './dom-selection.ts';
|
|
42
|
+
import { type RouterAction, resolveHoverTarget, useInputRouter } from './input-router.tsx';
|
|
43
|
+
import {
|
|
44
|
+
MaybeSelectionSetProvider,
|
|
45
|
+
type Selection,
|
|
46
|
+
useSelectionSet,
|
|
47
|
+
} from './use-selection-set.tsx';
|
|
48
|
+
import { MaybeToolProvider, useToolMode } from './use-tool-mode.tsx';
|
|
49
|
+
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
// Cursor CSS for the generic mount host. canvas-lib's HALO_CSS keys comment-
|
|
52
|
+
// mode cursors on `.dc-canvas[data-active-tool]`, which is absent in a bare
|
|
53
|
+
// specimen. We mirror the comment-tool cursor keyed on the mount host's
|
|
54
|
+
// `data-active-tool` attribute so the affordance ships even with no canvas-lib
|
|
55
|
+
// CSS present. The host is `display: contents` (so specimen layout is byte-
|
|
56
|
+
// identical) — a `display:contents` box can't paint a cursor, so the rule
|
|
57
|
+
// targets descendants of the host, not the host box itself.
|
|
58
|
+
|
|
59
|
+
const MC_CURSOR_CSS = `
|
|
60
|
+
[data-mc-host][data-active-tool="comment"] *,
|
|
61
|
+
body[data-active-tool="comment"] * {
|
|
62
|
+
cursor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path d='M3 3 L14 3 L14 11 L8 11 L4 14 L4 11 L3 11 Z' fill='white' stroke='%23111' stroke-width='1' stroke-linejoin='round'/></svg>") 4 4, crosshair !important;
|
|
63
|
+
}
|
|
64
|
+
`.trim();
|
|
65
|
+
|
|
66
|
+
function ensureMountCursorStyles(): void {
|
|
67
|
+
if (typeof document === 'undefined') return;
|
|
68
|
+
if (document.getElementById('mc-cursor-css')) return;
|
|
69
|
+
const s = document.createElement('style');
|
|
70
|
+
s.id = 'mc-cursor-css';
|
|
71
|
+
s.textContent = MC_CURSOR_CSS;
|
|
72
|
+
document.head.appendChild(s);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
76
|
+
// CommentHost — owns the lite comment subsystem: the input router (comment-
|
|
77
|
+
// scoped), the single CommentsOverlay, the comment-mode cursor attribute, and
|
|
78
|
+
// the parent `dgn:'tool-set'` listener (so the outer menubar comment toggle
|
|
79
|
+
// reaches the iframe).
|
|
80
|
+
|
|
81
|
+
// Only these action kinds are claimed by the mount-layer router; the rest
|
|
82
|
+
// propagate to a UI canvas's own router. `tool` + `escape` are also handled by
|
|
83
|
+
// the inner router on UI canvases — idempotent (same shared provider). `hover`
|
|
84
|
+
// is dispatched too (it never preventDefaults, so the inner router's own hover
|
|
85
|
+
// halo on a UI canvas is unaffected) — we only PAINT the mount-layer preview
|
|
86
|
+
// halo on a bare specimen, where there is no inner CanvasShell halo.
|
|
87
|
+
const COMMENT_CLAIMS: ReadonlySet<RouterAction['kind']> = new Set<RouterAction['kind']>([
|
|
88
|
+
'drop-comment',
|
|
89
|
+
'tool',
|
|
90
|
+
'escape',
|
|
91
|
+
'hover',
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
// True when no DesignCanvas/CanvasShell is mounted on this surface — i.e. a
|
|
95
|
+
// bare DS specimen. On a UI canvas (`.dc-canvas` present) the inner shell owns
|
|
96
|
+
// hover-halo painting + `resolveHoverTarget` element anchoring, so the lite
|
|
97
|
+
// layer defers to it.
|
|
98
|
+
function isBareSpecimen(): boolean {
|
|
99
|
+
return typeof document !== 'undefined' && !document.querySelector('.dc-canvas');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Deepest non-chrome element under a point — the comment anchor for a bare
|
|
103
|
+
// specimen (specimens aren't stamped with `data-cd-id` and have no
|
|
104
|
+
// `.dc-artboard-body`, so `resolveHoverTarget` returns null for them).
|
|
105
|
+
function pickSpecimenEl(clientX: number, clientY: number): HTMLElement | null {
|
|
106
|
+
if (typeof document === 'undefined') return null;
|
|
107
|
+
const hit = document.elementFromPoint(clientX, clientY) as HTMLElement | null;
|
|
108
|
+
if (!hit) return null;
|
|
109
|
+
// Never anchor to comment chrome or the document root.
|
|
110
|
+
if (hit.closest('.cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, [data-mc-hover-halo]')) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const tag = hit.tagName;
|
|
114
|
+
if (tag === 'HTML' || tag === 'BODY') return null;
|
|
115
|
+
return hit;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function CommentHost({ children, file }: { children: ReactNode; file: string | undefined }) {
|
|
119
|
+
ensureMountCursorStyles();
|
|
120
|
+
const { tool, setTool } = useToolMode();
|
|
121
|
+
const selSet = useSelectionSet();
|
|
122
|
+
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
123
|
+
// Hover-preview halo target (bare specimens only — see isBareSpecimen).
|
|
124
|
+
const [hoverEl, setHoverEl] = useState<HTMLElement | null>(null);
|
|
125
|
+
|
|
126
|
+
// Latest tool for the router (read at event time, not captured).
|
|
127
|
+
const toolRef = useRef(tool);
|
|
128
|
+
toolRef.current = tool;
|
|
129
|
+
const getActiveTool = useMemo(() => () => toolRef.current, []);
|
|
130
|
+
|
|
131
|
+
// Drop the preview halo whenever we leave comment mode.
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (tool !== 'comment') setHoverEl(null);
|
|
134
|
+
}, [tool]);
|
|
135
|
+
|
|
136
|
+
// Reflect the active tool onto the host (and body, since the host is
|
|
137
|
+
// display:contents and can't carry a paintable cursor). Comment-mode CSS
|
|
138
|
+
// keys off `[data-active-tool="comment"]`.
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const host = hostRef.current;
|
|
141
|
+
if (host) host.setAttribute('data-active-tool', tool);
|
|
142
|
+
if (typeof document !== 'undefined' && document.body) {
|
|
143
|
+
document.body.setAttribute('data-active-tool', tool);
|
|
144
|
+
}
|
|
145
|
+
return () => {
|
|
146
|
+
host?.removeAttribute('data-active-tool');
|
|
147
|
+
};
|
|
148
|
+
}, [tool]);
|
|
149
|
+
|
|
150
|
+
// Parent `dgn:'tool-set'` — the outer dev-server menubar posts this when the
|
|
151
|
+
// user toggles the comment tool. Mirrors canvas-shell's listener so the
|
|
152
|
+
// toggle reaches bare specimens too (which have no inner shell listener).
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (typeof window === 'undefined') return;
|
|
155
|
+
const onMessage = (e: MessageEvent) => {
|
|
156
|
+
const m = e.data as { dgn?: string; tool?: string } | null;
|
|
157
|
+
if (!m || typeof m !== 'object' || m.dgn !== 'tool-set') return;
|
|
158
|
+
if (typeof m.tool === 'string') setTool(m.tool as never);
|
|
159
|
+
};
|
|
160
|
+
window.addEventListener('message', onMessage);
|
|
161
|
+
return () => window.removeEventListener('message', onMessage);
|
|
162
|
+
}, [setTool]);
|
|
163
|
+
|
|
164
|
+
useInputRouter({
|
|
165
|
+
hostRef,
|
|
166
|
+
getActiveTool,
|
|
167
|
+
claimableActions: COMMENT_CLAIMS,
|
|
168
|
+
callbacks: {
|
|
169
|
+
onHover: ({ clientX, clientY }) => {
|
|
170
|
+
// Paint a preview halo only on bare specimens; a UI canvas's own
|
|
171
|
+
// CanvasShell HoverHalo owns the comment-mode preview there.
|
|
172
|
+
if (toolRef.current !== 'comment' || !isBareSpecimen()) {
|
|
173
|
+
setHoverEl(null);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const el = pickSpecimenEl(clientX, clientY);
|
|
177
|
+
setHoverEl((prev) => (prev === el ? prev : el));
|
|
178
|
+
},
|
|
179
|
+
onTool: ({ tool: t }) => setTool(t),
|
|
180
|
+
onEscape: () => {
|
|
181
|
+
if (toolRef.current !== 'move') setTool('move');
|
|
182
|
+
setHoverEl(null);
|
|
183
|
+
selSet.clear();
|
|
184
|
+
if (typeof window !== 'undefined') {
|
|
185
|
+
try {
|
|
186
|
+
window.parent.postMessage({ dgn: 'force-clear' }, '*');
|
|
187
|
+
} catch {
|
|
188
|
+
/* parent detached */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
onDropComment: ({ clientX, clientY }) => dropComment(clientX, clientY, selSet, file),
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// `display: contents` keeps the specimen's own flex/grid layout byte-
|
|
197
|
+
// identical — the host box contributes nothing to layout. The fixed-position
|
|
198
|
+
// CommentsOverlay renders fine as a child regardless.
|
|
199
|
+
return (
|
|
200
|
+
<div data-mc-host ref={hostRef} style={{ display: 'contents' }}>
|
|
201
|
+
{children}
|
|
202
|
+
{hoverEl ? <MountHoverHalo el={hoverEl} /> : null}
|
|
203
|
+
<CommentsOverlay />
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
209
|
+
// MountHoverHalo — fixed-position outline tracking the hovered element's screen
|
|
210
|
+
// bounds via rAF. Inline-styled (no dependency on canvas-lib's HALO_CSS, which
|
|
211
|
+
// a bare specimen doesn't load). Mirrors canvas-shell's HoverHalo visually.
|
|
212
|
+
|
|
213
|
+
function MountHoverHalo({ el }: { el: HTMLElement }) {
|
|
214
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
215
|
+
const targetRef = useRef<HTMLElement>(el);
|
|
216
|
+
targetRef.current = el;
|
|
217
|
+
const rafRef = useRef<number | null>(null);
|
|
218
|
+
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
const tick = () => {
|
|
221
|
+
rafRef.current = null;
|
|
222
|
+
const div = ref.current;
|
|
223
|
+
const t = targetRef.current;
|
|
224
|
+
if (div && t?.isConnected) {
|
|
225
|
+
const r = t.getBoundingClientRect();
|
|
226
|
+
if (r.width === 0 && r.height === 0) {
|
|
227
|
+
div.style.display = 'none';
|
|
228
|
+
} else {
|
|
229
|
+
div.style.display = 'block';
|
|
230
|
+
div.style.left = `${Math.round(r.left)}px`;
|
|
231
|
+
div.style.top = `${Math.round(r.top)}px`;
|
|
232
|
+
div.style.width = `${Math.round(r.width)}px`;
|
|
233
|
+
div.style.height = `${Math.round(r.height)}px`;
|
|
234
|
+
}
|
|
235
|
+
} else if (div) {
|
|
236
|
+
div.style.display = 'none';
|
|
237
|
+
}
|
|
238
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
239
|
+
};
|
|
240
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
241
|
+
return () => {
|
|
242
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
243
|
+
};
|
|
244
|
+
}, []);
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<div
|
|
248
|
+
ref={ref}
|
|
249
|
+
aria-hidden="true"
|
|
250
|
+
data-mc-hover-halo=""
|
|
251
|
+
style={{
|
|
252
|
+
position: 'fixed',
|
|
253
|
+
display: 'none',
|
|
254
|
+
pointerEvents: 'none',
|
|
255
|
+
zIndex: 2147483646,
|
|
256
|
+
border: '2px solid var(--maude-hud-accent, #d63b1f)',
|
|
257
|
+
borderRadius: '3px',
|
|
258
|
+
boxSizing: 'border-box',
|
|
259
|
+
}}
|
|
260
|
+
/>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
265
|
+
// Comment drop — generic path lifted from canvas-shell's onDropComment. Works
|
|
266
|
+
// with or without artboards: deep/shallow resolveHoverTarget → elementsFromPoint
|
|
267
|
+
// data-cd-id climb → floating fallback. Dispatches `cm:open-composer` for the
|
|
268
|
+
// in-iframe overlay + posts `comment-compose` to the parent for legacy mocks.
|
|
269
|
+
|
|
270
|
+
function dropComment(
|
|
271
|
+
clientX: number,
|
|
272
|
+
clientY: number,
|
|
273
|
+
selSet: { replace: (s: Selection) => void },
|
|
274
|
+
file: string | undefined
|
|
275
|
+
): void {
|
|
276
|
+
if (typeof document === 'undefined') return;
|
|
277
|
+
let target = resolveHoverTarget(document, clientX, clientY, { deep: true });
|
|
278
|
+
if (!target) target = resolveHoverTarget(document, clientX, clientY, { deep: false });
|
|
279
|
+
// UI-canvas recovery — when both passes bail on a `pointer-events: none`
|
|
280
|
+
// decoration, enumerate the stack and climb the first `data-cd-id` ancestor
|
|
281
|
+
// inside an artboard body. Skipped on bare specimens, which instead anchor to
|
|
282
|
+
// the exact hovered element below (so the pin matches the hover preview halo).
|
|
283
|
+
if (!target && !isBareSpecimen() && typeof document.elementsFromPoint === 'function') {
|
|
284
|
+
const stack = document.elementsFromPoint(clientX, clientY);
|
|
285
|
+
for (const candidate of stack) {
|
|
286
|
+
const stamped = (candidate as Element).closest?.('[data-cd-id]') as HTMLElement | null;
|
|
287
|
+
if (!stamped) continue;
|
|
288
|
+
if (!stamped.closest('.dc-artboard-body')) continue;
|
|
289
|
+
const artboardEl = stamped.closest('[data-dc-screen]');
|
|
290
|
+
target = {
|
|
291
|
+
el: stamped,
|
|
292
|
+
cdId: stamped.getAttribute('data-cd-id'),
|
|
293
|
+
artboardId: artboardEl?.getAttribute('data-dc-screen') ?? null,
|
|
294
|
+
};
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Bare-specimen anchor — specimens have no `.dc-artboard-body`, so
|
|
300
|
+
// resolveHoverTarget bails. Anchor to the EXACT element under the cursor (the
|
|
301
|
+
// same one pickSpecimenEl highlights for the hover preview), via its own
|
|
302
|
+
// data-cd-id when stamped, else a cssPath selector — so the dropped pin lands
|
|
303
|
+
// on the previewed element instead of floating.
|
|
304
|
+
if (!target && isBareSpecimen()) {
|
|
305
|
+
const el = pickSpecimenEl(clientX, clientY);
|
|
306
|
+
if (el) target = { el, cdId: el.getAttribute('data-cd-id'), artboardId: null };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!target) {
|
|
310
|
+
// Floating comment — no element anchor, just the click point (e.g. a click
|
|
311
|
+
// on empty canvas/specimen dead space). The overlay renders a pin at the
|
|
312
|
+
// stored bounds.
|
|
313
|
+
const floatingSel: Selection = {
|
|
314
|
+
file,
|
|
315
|
+
id: undefined,
|
|
316
|
+
selector: '',
|
|
317
|
+
artboardId: null,
|
|
318
|
+
tag: '',
|
|
319
|
+
classes: '',
|
|
320
|
+
text: '',
|
|
321
|
+
dom_path: [],
|
|
322
|
+
bounds: { x: clientX - 12, y: clientY - 12, w: 24, h: 24 },
|
|
323
|
+
html: '',
|
|
324
|
+
};
|
|
325
|
+
openComposer(floatingSel, clientX, clientY);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const sel = hoverTargetToSelection(target, file);
|
|
330
|
+
selSet.replace(sel);
|
|
331
|
+
openComposer(sel, clientX, clientY);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function openComposer(selection: Selection, clientX: number, clientY: number): void {
|
|
335
|
+
if (typeof document !== 'undefined') {
|
|
336
|
+
try {
|
|
337
|
+
document.dispatchEvent(
|
|
338
|
+
new CustomEvent('cm:open-composer', { detail: { selection, clientX, clientY } })
|
|
339
|
+
);
|
|
340
|
+
} catch {
|
|
341
|
+
/* CustomEvent absent — fall through to parent path */
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (typeof window !== 'undefined') {
|
|
345
|
+
try {
|
|
346
|
+
window.parent.postMessage({ dgn: 'comment-compose', selection }, '*');
|
|
347
|
+
} catch {
|
|
348
|
+
/* parent detached */
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
354
|
+
// mountCanvas — the shell harness entry. Renders the canvas wrapped in the lite
|
|
355
|
+
// comment tree, or bare when comments are disabled (gallery thumbnails pass
|
|
356
|
+
// `commentsEnabled: false` via `?comments=0`).
|
|
357
|
+
|
|
358
|
+
export interface MountCanvasOptions {
|
|
359
|
+
rootEl: HTMLElement;
|
|
360
|
+
/** Canvas file key (designRel-prefixed). Defaults to `deriveFile()`. */
|
|
361
|
+
file?: string;
|
|
362
|
+
/** When false, the comment layer is skipped — the canvas renders raw. */
|
|
363
|
+
commentsEnabled: boolean;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function mountCanvas(Canvas: ComponentType, opts: MountCanvasOptions): void {
|
|
367
|
+
const root = createRoot(opts.rootEl);
|
|
368
|
+
if (!opts.commentsEnabled) {
|
|
369
|
+
root.render(createElement(Canvas));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const file = opts.file ?? deriveFile();
|
|
373
|
+
root.render(
|
|
374
|
+
createElement(
|
|
375
|
+
MaybeToolProvider,
|
|
376
|
+
null,
|
|
377
|
+
createElement(
|
|
378
|
+
MaybeSelectionSetProvider,
|
|
379
|
+
null,
|
|
380
|
+
createElement(CommentHost, { file }, createElement(Canvas))
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
);
|
|
384
|
+
}
|
|
@@ -95,7 +95,7 @@ import {
|
|
|
95
95
|
import { type DragState, useArtboardDrag } from './use-artboard-drag.tsx';
|
|
96
96
|
import { CollabProvider, canvasSlugFromPath } from './use-collab.tsx';
|
|
97
97
|
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
98
|
-
import {
|
|
98
|
+
import { MaybeToolProvider, useToolModeOptional } from './use-tool-mode.tsx';
|
|
99
99
|
import { UndoStackProvider, useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
100
100
|
|
|
101
101
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1161,7 +1161,10 @@ interface DesignCanvasProps {
|
|
|
1161
1161
|
*
|
|
1162
1162
|
* `ToolProvider` lives above `DesignCanvasInner` so the viewport
|
|
1163
1163
|
* controller's `isPanDragActive` predicate can read the live tool state
|
|
1164
|
-
* via `useToolModeOptional` (hand-mode bare-drag pan).
|
|
1164
|
+
* via `useToolModeOptional` (hand-mode bare-drag pan). It's wrapped in
|
|
1165
|
+
* `MaybeToolProvider` so when the shell-owned comment mount layer
|
|
1166
|
+
* (canvas-comment-mount.tsx) already provides a ToolProvider, DesignCanvas
|
|
1167
|
+
* consumes that single instance instead of double-mounting.
|
|
1165
1168
|
*/
|
|
1166
1169
|
export function DesignCanvas(props: DesignCanvasProps) {
|
|
1167
1170
|
// Phase 20 — per-canvas undo/redo stack (DDR-050 rev 2). The provider
|
|
@@ -1178,9 +1181,9 @@ export function DesignCanvas(props: DesignCanvasProps) {
|
|
|
1178
1181
|
// CollabProvider is omitted; useCollab() falls back gracefully to null.
|
|
1179
1182
|
const collabSlug = canvasSlugFromPath(canvasFile);
|
|
1180
1183
|
const inner = (
|
|
1181
|
-
<
|
|
1184
|
+
<MaybeToolProvider>
|
|
1182
1185
|
<DesignCanvasInner {...props} />
|
|
1183
|
-
</
|
|
1186
|
+
</MaybeToolProvider>
|
|
1184
1187
|
);
|
|
1185
1188
|
return (
|
|
1186
1189
|
<UndoStackProvider canvasFile={canvasFile}>
|
|
@@ -2300,11 +2303,18 @@ export const MOTION_ROLE_DEFAULTS: Record<MotionRole, RoleConfig> = {
|
|
|
2300
2303
|
spring: {
|
|
2301
2304
|
durationToken: '--dur-panel',
|
|
2302
2305
|
easingToken: 'spring',
|
|
2303
|
-
|
|
2306
|
+
// Spring physics animate toward a single target; a 3-point array
|
|
2307
|
+
// (`[0,-16,0]`) makes motion/react's spring no-op (no movement). Use a
|
|
2308
|
+
// 2-point target and let `repeatType: 'reverse'` carry the return leg.
|
|
2309
|
+
keyframes: { y: [0, -16] },
|
|
2304
2310
|
fallbackMs: 320,
|
|
2305
2311
|
},
|
|
2306
2312
|
scroll: {
|
|
2307
|
-
|
|
2313
|
+
// `--dur-route` is intentionally ~instant (route changes are snap, often
|
|
2314
|
+
// 1ms), which makes a scroll-linked drift imperceptible. Bind to the
|
|
2315
|
+
// longer `--dur-soft` so the demo (and any time-driven scroll fallback)
|
|
2316
|
+
// is actually visible.
|
|
2317
|
+
durationToken: '--dur-soft',
|
|
2308
2318
|
easingToken: '--ease-in-out',
|
|
2309
2319
|
keyframes: { x: [0, 24, 0] },
|
|
2310
2320
|
fallbackMs: 480,
|
|
@@ -2425,6 +2435,11 @@ export function MotionDemo({
|
|
|
2425
2435
|
const repeat = reduced || loop === 'once' ? 0 : Number.POSITIVE_INFINITY;
|
|
2426
2436
|
const repeatType: 'reverse' | 'loop' = loop === 'always' ? 'reverse' : 'loop';
|
|
2427
2437
|
const animate = reduced ? undefined : cfg.keyframes;
|
|
2438
|
+
// DS durations are micro-interaction speeds (often <200ms). Looping them with
|
|
2439
|
+
// no gap strobes ~10×/s. Insert a rest between cycles so each loop replays the
|
|
2440
|
+
// REAL token speed, then pauses — readable cadence, not a flicker. Spring's
|
|
2441
|
+
// own settle is the pause, so it skips the extra delay.
|
|
2442
|
+
const repeatDelay = loop === 'always' && !isSpring ? 0.9 : 0;
|
|
2428
2443
|
|
|
2429
2444
|
return (
|
|
2430
2445
|
<div
|
|
@@ -2441,6 +2456,7 @@ export function MotionDemo({
|
|
|
2441
2456
|
type: isSpring ? 'spring' : 'tween',
|
|
2442
2457
|
repeat,
|
|
2443
2458
|
repeatType,
|
|
2459
|
+
repeatDelay,
|
|
2444
2460
|
}}
|
|
2445
2461
|
className="motion-demo__target"
|
|
2446
2462
|
aria-label={label}
|