@1agh/maude 0.27.0 → 0.28.1
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 +46 -0
- package/cli/commands/design.mjs +69 -3
- package/cli/commands/design.test.mjs +97 -1
- package/cli/commands/doctor.mjs +110 -5
- package/cli/commands/doctor.test.mjs +52 -0
- package/cli/lib/cache.mjs +1 -1
- package/cli/lib/config-lint.mjs +1 -1
- package/cli/lib/copy-tree.mjs +1 -1
- package/cli/lib/design-link.mjs +39 -9
- package/cli/lib/flow-design-integration.test.mjs +1 -1
- package/cli/lib/gitignore-block.test.mjs +2 -2
- package/cli/lib/stack-detect.mjs +1 -1
- package/package.json +9 -9
- package/plugins/design/dev-server/activity.ts +256 -0
- package/plugins/design/dev-server/ai-banner.tsx +4 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +1 -1
- package/plugins/design/dev-server/annotations-layer.tsx +3 -3
- package/plugins/design/dev-server/api.ts +1 -1
- package/plugins/design/dev-server/artboard-activity-overlay.tsx +67 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +1 -1
- package/plugins/design/dev-server/bin/server-up.sh +102 -9
- package/plugins/design/dev-server/bin/visual-sanity.sh +19 -3
- package/plugins/design/dev-server/canvas-comment-mount.tsx +165 -10
- package/plugins/design/dev-server/canvas-cursors.ts +1 -1
- package/plugins/design/dev-server/canvas-lib.tsx +72 -36
- package/plugins/design/dev-server/canvas-shell.tsx +1 -1
- package/plugins/design/dev-server/collab/awareness-bridge.ts +1 -1
- package/plugins/design/dev-server/collab/index.ts +4 -4
- package/plugins/design/dev-server/collab/persistence.ts +1 -1
- package/plugins/design/dev-server/config.schema.json +2 -1
- package/plugins/design/dev-server/context-menu.tsx +1 -1
- package/plugins/design/dev-server/dist/client.bundle.js +19 -19
- package/plugins/design/dev-server/dist/comment-mount.js +84 -6
- package/plugins/design/dev-server/dist/runtime/lib0_decoding.js +1 -620
- package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +1 -604
- package/plugins/design/dev-server/dist/runtime/motion.js +4 -4944
- package/plugins/design/dev-server/dist/runtime/motion_react.js +10 -10054
- package/plugins/design/dev-server/dist/runtime/pixi-js.js +2775 -52482
- package/plugins/design/dev-server/dist/runtime/react-dom.js +1 -231
- package/plugins/design/dev-server/dist/runtime/react-dom_client.js +9 -10357
- package/plugins/design/dev-server/dist/runtime/react.js +1 -535
- package/plugins/design/dev-server/dist/runtime/react_jsx-dev-runtime.js +1 -60
- package/plugins/design/dev-server/dist/runtime/react_jsx-runtime.js +1 -85
- package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +1 -376
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +1 -100
- package/plugins/design/dev-server/dist/runtime/yjs.js +5 -6687
- package/plugins/design/dev-server/draw/brush.ts +3 -3
- package/plugins/design/dev-server/draw/index.ts +5 -5
- package/plugins/design/dev-server/draw/layout.ts +1 -1
- package/plugins/design/dev-server/draw/test/geometry.test.ts +1 -1
- package/plugins/design/dev-server/draw/test/layout.test.ts +1 -1
- package/plugins/design/dev-server/draw/test/primitives.test.ts +1 -1
- package/plugins/design/dev-server/draw/test/serialize.test.ts +1 -1
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/examples/perf-100-artboards.tsx +1 -2
- package/plugins/design/dev-server/export-dialog.tsx +1 -1
- package/plugins/design/dev-server/exporters/html.ts +1 -1
- package/plugins/design/dev-server/exporters/index.ts +2 -2
- package/plugins/design/dev-server/exporters/pdf.ts +1 -1
- package/plugins/design/dev-server/exporters/png.ts +1 -1
- package/plugins/design/dev-server/exporters/pptx.ts +1 -1
- package/plugins/design/dev-server/exporters/svg.ts +1 -1
- package/plugins/design/dev-server/http.ts +1 -1
- package/plugins/design/dev-server/inspect.ts +31 -1
- package/plugins/design/dev-server/marquee-overlay.tsx +1 -1
- package/plugins/design/dev-server/participants-chrome.tsx +86 -1
- package/plugins/design/dev-server/server.ts +11 -2
- package/plugins/design/dev-server/sync/agent.ts +1 -1
- package/plugins/design/dev-server/sync/index.ts +29 -24
- package/plugins/design/dev-server/sync/materialize.ts +2 -2
- package/plugins/design/dev-server/sync/migrate-seed.ts +1 -1
- package/plugins/design/dev-server/sync/projection.ts +3 -3
- package/plugins/design/dev-server/test/activity.test.ts +195 -0
- package/plugins/design/dev-server/test/ai-activity.test.ts +1 -1
- package/plugins/design/dev-server/test/annotations-api.test.ts +1 -2
- package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +2 -2
- package/plugins/design/dev-server/test/annotations-layer.test.ts +6 -6
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +1 -1
- package/plugins/design/dev-server/test/artboard-activity-overlay.test.tsx +56 -0
- package/plugins/design/dev-server/test/boot-self-heal.test.ts +1 -1
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +1 -1
- package/plugins/design/dev-server/test/canvas-edit.test.ts +1 -1
- package/plugins/design/dev-server/test/canvas-header.test.ts +1 -1
- package/plugins/design/dev-server/test/canvas-hmr-runtime.test.tsx +114 -0
- package/plugins/design/dev-server/test/canvas-lib-inline.test.ts +1 -1
- package/plugins/design/dev-server/test/canvas-lib-resolver.test.ts +1 -2
- package/plugins/design/dev-server/test/canvas-meta-api.test.ts +1 -2
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +1 -2
- package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +1 -1
- package/plugins/design/dev-server/test/collab-bridge.test.ts +1 -1
- package/plugins/design/dev-server/test/collab-protocol.test.ts +2 -2
- package/plugins/design/dev-server/test/collab-room.test.ts +1 -1
- package/plugins/design/dev-server/test/collab-stress.test.ts +1 -1
- package/plugins/design/dev-server/test/comments-api.test.ts +1 -2
- package/plugins/design/dev-server/test/compile-entry.test.ts +1 -2
- package/plugins/design/dev-server/test/exporters/canva.test.ts +1 -2
- package/plugins/design/dev-server/test/exporters/history.test.ts +1 -2
- package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +1 -1
- package/plugins/design/dev-server/test/exporters/scope.test.ts +1 -2
- package/plugins/design/dev-server/test/exporters/zip.test.ts +1 -2
- package/plugins/design/dev-server/test/git-lifecycle.test.ts +1 -2
- package/plugins/design/dev-server/test/handoff-static-frames.test.ts +1 -1
- package/plugins/design/dev-server/test/handoff.test.ts +1 -1
- package/plugins/design/dev-server/test/hmr-broadcast.test.ts +1 -1
- package/plugins/design/dev-server/test/input-router.test.ts +2 -2
- package/plugins/design/dev-server/test/locator.test.ts +1 -1
- package/plugins/design/dev-server/test/runtime-bundle.test.ts +1 -1
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +1 -1
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +2 -3
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +1 -1
- package/plugins/design/dev-server/test/snap-distance-pill.test.ts +1 -1
- package/plugins/design/dev-server/test/sync-agent.test.ts +2 -3
- package/plugins/design/dev-server/test/sync-atomic-write.test.ts +2 -3
- package/plugins/design/dev-server/test/sync-codec.test.ts +1 -1
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +1 -1
- package/plugins/design/dev-server/test/sync-echo-guard.test.ts +1 -1
- package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +1 -2
- package/plugins/design/dev-server/test/sync-hardening.test.ts +4 -5
- package/plugins/design/dev-server/test/sync-hubs-config.test.ts +1 -2
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +1 -1
- package/plugins/design/dev-server/test/sync-runtime.test.ts +37 -14
- package/plugins/design/dev-server/test/sync-status.test.ts +1 -1
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +1 -2
- package/plugins/design/dev-server/test/undo-stack.test.ts +2 -2
- package/plugins/design/dev-server/test/use-agent-presence.test.tsx +117 -0
- package/plugins/design/dev-server/test/use-artboard-drag.test.ts +3 -3
- package/plugins/design/dev-server/test/use-canvas-activity.test.tsx +206 -0
- package/plugins/design/dev-server/test/use-selection-set.test.tsx +1 -1
- package/plugins/design/dev-server/test/use-snap-guides.test.ts +1 -1
- package/plugins/design/dev-server/test/use-undo-stack.test.tsx +2 -2
- package/plugins/design/dev-server/use-agent-presence.tsx +244 -0
- package/plugins/design/dev-server/use-annotation-resize.tsx +1 -5
- package/plugins/design/dev-server/use-annotation-selection.tsx +2 -6
- package/plugins/design/dev-server/use-annotations-visibility.tsx +1 -1
- package/plugins/design/dev-server/use-artboard-drag.tsx +1 -1
- package/plugins/design/dev-server/use-canvas-activity.tsx +252 -0
- package/plugins/design/dev-server/use-collab.tsx +3 -4
- package/plugins/design/dev-server/use-selection-set.tsx +1 -1
- package/plugins/design/dev-server/use-tool-mode.tsx +1 -1
- package/plugins/design/dev-server/use-undo-stack.tsx +4 -5
- package/plugins/design/dev-server/ws.ts +21 -2
- package/plugins/design/templates/_shell.html +108 -3
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +18 -3
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// use-canvas-activity — Phase 13. Pure reducer/key helpers + provider gating.
|
|
2
|
+
//
|
|
3
|
+
// bun:test runs without a live DOM renderer, so dynamic event-driven state
|
|
4
|
+
// (the `maude:activity` listener + fade timers) lives in the provider's
|
|
5
|
+
// useEffect and can't be driven here. We unit-test the pure core
|
|
6
|
+
// (applyActivityChange / activityKey / matchesArtboard) directly, and verify
|
|
7
|
+
// the provider → hook derivation via the SSR-capture pattern with `initialState`
|
|
8
|
+
// (which is also the WS-snapshot seed path).
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
|
|
12
|
+
import type { ReactNode } from 'react';
|
|
13
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
type ActivityMessage,
|
|
17
|
+
activityKey,
|
|
18
|
+
applyActivityChange,
|
|
19
|
+
type CanvasActivity,
|
|
20
|
+
CanvasActivityProvider,
|
|
21
|
+
matchesArtboard,
|
|
22
|
+
useCanvasActivity,
|
|
23
|
+
} from '../use-canvas-activity.tsx';
|
|
24
|
+
|
|
25
|
+
describe('use-canvas-activity / activityKey', () => {
|
|
26
|
+
test('canonical design-root-relative path is unchanged', () => {
|
|
27
|
+
expect(activityKey('ui/Foo.tsx')).toBe('ui/Foo.tsx');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('strips a known designRel prefix', () => {
|
|
31
|
+
expect(activityKey('.design/ui/Foo.tsx', '.design')).toBe('ui/Foo.tsx');
|
|
32
|
+
expect(activityKey('.design/ui/Foo.tsx', './.design')).toBe('ui/Foo.tsx');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('normalizes back-slash + leading slash', () => {
|
|
36
|
+
expect(activityKey('\\ui\\Foo.tsx')).toBe('ui/Foo.tsx');
|
|
37
|
+
expect(activityKey('/ui/Foo.tsx')).toBe('ui/Foo.tsx');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('leaves the path alone when designRel does not match', () => {
|
|
41
|
+
expect(activityKey('ui/Foo.tsx', '.design')).toBe('ui/Foo.tsx');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('use-canvas-activity / applyActivityChange', () => {
|
|
46
|
+
const base: ActivityMessage = { file: 'ui/Foo.tsx', status: 'active', ts: 't1' };
|
|
47
|
+
|
|
48
|
+
test('adds an active entry', () => {
|
|
49
|
+
const next = applyActivityChange({}, base);
|
|
50
|
+
expect(next['ui/Foo.tsx']).toEqual({ status: 'active', artboardIds: null, ts: 't1' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('idle overwrites the same key', () => {
|
|
54
|
+
const a = applyActivityChange({}, base);
|
|
55
|
+
const b = applyActivityChange(a, { file: 'ui/Foo.tsx', status: 'idle', ts: 't2' });
|
|
56
|
+
expect(b['ui/Foo.tsx']?.status).toBe('idle');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('carries artboard_ids → artboardIds', () => {
|
|
60
|
+
const next = applyActivityChange({}, { ...base, artboard_ids: ['secondary'] });
|
|
61
|
+
expect(next['ui/Foo.tsx']?.artboardIds).toEqual(['secondary']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('different files coexist; immutable update', () => {
|
|
65
|
+
const a = applyActivityChange({}, base);
|
|
66
|
+
const b = applyActivityChange(a, { file: 'ui/Bar.tsx', status: 'active', ts: 't3' });
|
|
67
|
+
expect(Object.keys(b).sort()).toEqual(['ui/Bar.tsx', 'ui/Foo.tsx']);
|
|
68
|
+
expect(b).not.toBe(a);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('ignores a malformed message (no file)', () => {
|
|
72
|
+
const a = applyActivityChange({}, base);
|
|
73
|
+
const b = applyActivityChange(a, { file: '', status: 'active', ts: 'x' });
|
|
74
|
+
expect(b).toBe(a);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('use-canvas-activity / matchesArtboard', () => {
|
|
79
|
+
test('null scope matches every artboard', () => {
|
|
80
|
+
expect(matchesArtboard(null, 'primary')).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
test('scoped list matches only listed ids', () => {
|
|
83
|
+
expect(matchesArtboard(['secondary'], 'secondary')).toBe(true);
|
|
84
|
+
expect(matchesArtboard(['secondary'], 'primary')).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('use-canvas-activity / provider → hook derivation', () => {
|
|
89
|
+
function capture(node: (v: CanvasActivity) => void, props: Parameters<typeof Wrap>[0]) {
|
|
90
|
+
function Probe() {
|
|
91
|
+
node(useCanvasActivity());
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
renderToStaticMarkup(<Wrap {...props}>{<Probe />}</Wrap>);
|
|
95
|
+
}
|
|
96
|
+
function Wrap(props: {
|
|
97
|
+
file?: string;
|
|
98
|
+
designRel?: string;
|
|
99
|
+
initialState?: Record<
|
|
100
|
+
string,
|
|
101
|
+
{ status: 'active' | 'idle'; artboardIds: string[] | null; ts: string }
|
|
102
|
+
>;
|
|
103
|
+
children: ReactNode;
|
|
104
|
+
}) {
|
|
105
|
+
return (
|
|
106
|
+
<CanvasActivityProvider
|
|
107
|
+
file={props.file}
|
|
108
|
+
designRel={props.designRel}
|
|
109
|
+
initialState={props.initialState}
|
|
110
|
+
>
|
|
111
|
+
{props.children}
|
|
112
|
+
</CanvasActivityProvider>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
test('active entry for the current canvas → present + active', () => {
|
|
117
|
+
let v: CanvasActivity | null = null;
|
|
118
|
+
capture(
|
|
119
|
+
(x) => {
|
|
120
|
+
v = x;
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
file: 'ui/Test.tsx',
|
|
124
|
+
initialState: { 'ui/Test.tsx': { status: 'active', artboardIds: null, ts: 't' } },
|
|
125
|
+
children: null,
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
expect(v).toMatchObject({
|
|
129
|
+
present: true,
|
|
130
|
+
active: true,
|
|
131
|
+
artboardIds: null,
|
|
132
|
+
fileLabel: 'Test.tsx',
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('idle entry → present but not active (fading out)', () => {
|
|
137
|
+
let v: CanvasActivity | null = null;
|
|
138
|
+
capture(
|
|
139
|
+
(x) => {
|
|
140
|
+
v = x;
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
file: 'ui/Test.tsx',
|
|
144
|
+
initialState: { 'ui/Test.tsx': { status: 'idle', artboardIds: null, ts: 't' } },
|
|
145
|
+
children: null,
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
expect(v).toMatchObject({ present: true, active: false });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('no entry for this canvas → inert', () => {
|
|
152
|
+
let v: CanvasActivity | null = null;
|
|
153
|
+
capture(
|
|
154
|
+
(x) => {
|
|
155
|
+
v = x;
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
file: 'ui/Test.tsx',
|
|
159
|
+
initialState: { 'ui/Other.tsx': { status: 'active', artboardIds: null, ts: 't' } },
|
|
160
|
+
children: null,
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
expect(v).toMatchObject({ present: false, active: false });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('scoped artboardIds flow through', () => {
|
|
167
|
+
let v: CanvasActivity | null = null;
|
|
168
|
+
capture(
|
|
169
|
+
(x) => {
|
|
170
|
+
v = x;
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
file: 'ui/Test.tsx',
|
|
174
|
+
initialState: { 'ui/Test.tsx': { status: 'active', artboardIds: ['secondary'], ts: 't' } },
|
|
175
|
+
children: null,
|
|
176
|
+
}
|
|
177
|
+
);
|
|
178
|
+
expect(v?.artboardIds).toEqual(['secondary']);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('designRel-prefixed current file is normalized to match server keys', () => {
|
|
182
|
+
let v: CanvasActivity | null = null;
|
|
183
|
+
capture(
|
|
184
|
+
(x) => {
|
|
185
|
+
v = x;
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
file: '.design/ui/Test.tsx',
|
|
189
|
+
designRel: '.design',
|
|
190
|
+
initialState: { 'ui/Test.tsx': { status: 'active', artboardIds: null, ts: 't' } },
|
|
191
|
+
children: null,
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
expect(v).toMatchObject({ present: true, active: true });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('outside a provider the hook is inert (no overlay)', () => {
|
|
198
|
+
let v: CanvasActivity | null = null;
|
|
199
|
+
function Probe() {
|
|
200
|
+
v = useCanvasActivity();
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
renderToStaticMarkup(<Probe />);
|
|
204
|
+
expect(v).toMatchObject({ present: false, active: false });
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { describe, expect, test } from 'bun:test';
|
|
4
4
|
|
|
5
|
-
import { type Rect, type SnapOptions
|
|
5
|
+
import { computeSnap, type Rect, type SnapOptions } from '../use-snap-guides.tsx';
|
|
6
6
|
|
|
7
7
|
const DEFAULTS: SnapOptions = { gridSize: 40, tolerance: 8, disabled: false };
|
|
8
8
|
|
|
@@ -8,10 +8,10 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
|
8
8
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
-
type CommandRecord,
|
|
12
|
-
type CommandSinks,
|
|
13
11
|
_clearBuilderRegistry,
|
|
14
12
|
_clearStackStore,
|
|
13
|
+
type CommandRecord,
|
|
14
|
+
type CommandSinks,
|
|
15
15
|
registerCommand,
|
|
16
16
|
} from '../undo-stack.ts';
|
|
17
17
|
import {
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file use-agent-presence.tsx — Phase 13.2 / DDR-078 agent presence.
|
|
3
|
+
* @scope plugins/design/dev-server/use-agent-presence.tsx
|
|
4
|
+
* @purpose Turn the Phase 8 `ai-activity` heartbeat into a *presence* peer:
|
|
5
|
+
* when an agent (`/design:edit` / `/design:new`) is editing THIS
|
|
6
|
+
* canvas, surface it like another connected human — an avatar in the
|
|
7
|
+
* participants stack + the activity overlay (DDR-075) tinted with the
|
|
8
|
+
* agent's own color + funny name. Pure client-side synthesis: the
|
|
9
|
+
* agent is NOT injected into Yjs awareness (no protocol surgery), it
|
|
10
|
+
* is a virtual participant derived from `ai-activity`.
|
|
11
|
+
*
|
|
12
|
+
* MVP scope (DDR-078): ONE agent per canvas (matches `ai-activity`'s file key).
|
|
13
|
+
* Multiple agents across the project each show in their own canvas tab; multiple
|
|
14
|
+
* agents on the SAME file at once would need `ai-activity` multi-author and is
|
|
15
|
+
* deliberately deferred. No roaming cursor (avatar + overlay only).
|
|
16
|
+
*
|
|
17
|
+
* Subscription mirrors `ai-banner.tsx`: parent `dgn:'ai-activity'` postMessage
|
|
18
|
+
* relay when embedded, own inspector WS when standalone, plus a GET /_api/ai
|
|
19
|
+
* seed so a tab opened mid-edit shows the agent immediately. Provided ONCE per
|
|
20
|
+
* canvas (canvas-lib bundle) so every DCArtboard + the chrome read one context —
|
|
21
|
+
* same cross-bundle rule as the activity context (DDR-077 lesson).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react';
|
|
25
|
+
|
|
26
|
+
import { colorForName } from './use-collab.tsx';
|
|
27
|
+
|
|
28
|
+
/** Wire shape of an `ai-activity` entry (matches collab/ai-activity.ts). */
|
|
29
|
+
export interface AiEntry {
|
|
30
|
+
file: string;
|
|
31
|
+
author: string;
|
|
32
|
+
startedAt: number;
|
|
33
|
+
lastHeartbeat: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A virtual presence peer derived from an active agent. */
|
|
37
|
+
export interface AgentPresence {
|
|
38
|
+
/** Stable per-session id (`author:startedAt`) — the funny-name + color seed. */
|
|
39
|
+
id: string;
|
|
40
|
+
/** Funny display name, e.g. "Bouncy Otter". */
|
|
41
|
+
name: string;
|
|
42
|
+
/** Deterministic peer color (shared palette with human peers). */
|
|
43
|
+
color: string;
|
|
44
|
+
/** The real author string the slash command reported ("Claude acting for X"). */
|
|
45
|
+
author: string;
|
|
46
|
+
startedAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Funny-name generator — deterministic from the session seed so an agent keeps
|
|
51
|
+
// the same name + color across heartbeats. ~20×20 = 400 combinations.
|
|
52
|
+
|
|
53
|
+
const ADJECTIVES = [
|
|
54
|
+
'Bouncy',
|
|
55
|
+
'Zippy',
|
|
56
|
+
'Sleepy',
|
|
57
|
+
'Sneaky',
|
|
58
|
+
'Witty',
|
|
59
|
+
'Plucky',
|
|
60
|
+
'Mellow',
|
|
61
|
+
'Snappy',
|
|
62
|
+
'Cosmic',
|
|
63
|
+
'Fuzzy',
|
|
64
|
+
'Jolly',
|
|
65
|
+
'Nimble',
|
|
66
|
+
'Breezy',
|
|
67
|
+
'Quirky',
|
|
68
|
+
'Dapper',
|
|
69
|
+
'Wobbly',
|
|
70
|
+
'Spry',
|
|
71
|
+
'Peppy',
|
|
72
|
+
'Groovy',
|
|
73
|
+
'Sly',
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const ANIMALS = [
|
|
77
|
+
'Otter',
|
|
78
|
+
'Walrus',
|
|
79
|
+
'Lynx',
|
|
80
|
+
'Heron',
|
|
81
|
+
'Badger',
|
|
82
|
+
'Falcon',
|
|
83
|
+
'Mantis',
|
|
84
|
+
'Newt',
|
|
85
|
+
'Quokka',
|
|
86
|
+
'Tapir',
|
|
87
|
+
'Gecko',
|
|
88
|
+
'Marten',
|
|
89
|
+
'Pangolin',
|
|
90
|
+
'Axolotl',
|
|
91
|
+
'Ferret',
|
|
92
|
+
'Puffin',
|
|
93
|
+
'Capybara',
|
|
94
|
+
'Narwhal',
|
|
95
|
+
'Wombat',
|
|
96
|
+
'Mongoose',
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
function hash32(s: string): number {
|
|
100
|
+
let h = 5381;
|
|
101
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
|
102
|
+
return h >>> 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Deterministic "Adjective Animal" from a seed (e.g. `author:startedAt`). */
|
|
106
|
+
export function agentFunnyName(seed: string): string {
|
|
107
|
+
const h = hash32(seed || 'agent');
|
|
108
|
+
const adj = ADJECTIVES[h % ADJECTIVES.length] as string;
|
|
109
|
+
const animal = ANIMALS[(h >>> 8) % ANIMALS.length] as string;
|
|
110
|
+
return `${adj} ${animal}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Strip control chars + length-cap the author. The /_api/ai/start endpoint
|
|
115
|
+
* caps it server-side, but the postMessage relay path never round-trips the
|
|
116
|
+
* server, so we re-bound it here at the trust boundary (DDR-078 security
|
|
117
|
+
* follow-up) — keeps a spoofed `author` from bloating the badge even if the
|
|
118
|
+
* origin guard below ever regresses.
|
|
119
|
+
*/
|
|
120
|
+
function sanitizeAuthor(a: unknown): string {
|
|
121
|
+
// Keep printable chars only (drop C0/C1 controls + DEL), cap length — no
|
|
122
|
+
// control-char regex needed.
|
|
123
|
+
let out = '';
|
|
124
|
+
for (const ch of String(a ?? '')) {
|
|
125
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
126
|
+
if (code >= 0x20 && code !== 0x7f) out += ch;
|
|
127
|
+
if (out.length >= 120) break;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Build the virtual presence peer from an `ai-activity` entry. */
|
|
133
|
+
export function deriveAgent(entry: AiEntry): AgentPresence {
|
|
134
|
+
const author = sanitizeAuthor(entry.author);
|
|
135
|
+
const id = `${author}:${entry.startedAt}`;
|
|
136
|
+
const name = agentFunnyName(id);
|
|
137
|
+
return { id, name, color: colorForName(name), author, startedAt: entry.startedAt };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
function readMetaFile(explicit?: string): string {
|
|
143
|
+
if (explicit) return explicit;
|
|
144
|
+
if (typeof window !== 'undefined') {
|
|
145
|
+
const w = window as unknown as { __canvas_meta_file__?: string };
|
|
146
|
+
if (typeof w.__canvas_meta_file__ === 'string') return w.__canvas_meta_file__;
|
|
147
|
+
}
|
|
148
|
+
return '';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const AgentPresenceContext = createContext<AgentPresence | null>(null);
|
|
152
|
+
|
|
153
|
+
export interface AgentPresenceProviderProps {
|
|
154
|
+
/** Canvas file key (designRel-prefixed, matching the `ai-activity` key). */
|
|
155
|
+
file?: string;
|
|
156
|
+
/** Test/seed override — when provided, the live subscription is skipped. */
|
|
157
|
+
initialAgent?: AgentPresence | null;
|
|
158
|
+
children: ReactNode;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function AgentPresenceProvider({
|
|
162
|
+
file,
|
|
163
|
+
initialAgent,
|
|
164
|
+
children,
|
|
165
|
+
}: AgentPresenceProviderProps) {
|
|
166
|
+
const myFile = readMetaFile(file);
|
|
167
|
+
const [entry, setEntry] = useState<AiEntry | null>(null);
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (initialAgent !== undefined) return; // seeded (tests) — no live wiring
|
|
171
|
+
if (!myFile || typeof window === 'undefined') return;
|
|
172
|
+
let cancelled = false;
|
|
173
|
+
|
|
174
|
+
// Seed: a tab opened mid-edit should show the agent without waiting for the
|
|
175
|
+
// next heartbeat.
|
|
176
|
+
fetch('/_api/ai', { headers: { 'Cache-Control': 'no-store' } })
|
|
177
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
178
|
+
.then((j: { entries?: AiEntry[] } | null) => {
|
|
179
|
+
if (cancelled || !j?.entries) return;
|
|
180
|
+
const mine = j.entries.find((e) => e && e.file === myFile);
|
|
181
|
+
if (mine) setEntry(mine);
|
|
182
|
+
})
|
|
183
|
+
.catch(() => {
|
|
184
|
+
/* network blip — live messages will fill in */
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Embedded: parent relays ai-activity via postMessage (ai-banner pattern).
|
|
188
|
+
const onMessage = (e: MessageEvent) => {
|
|
189
|
+
// DDR-078 security follow-up: only the trusted embedding parent relays
|
|
190
|
+
// ai-activity. Reject same-window self-posts (a hostile canvas faking a
|
|
191
|
+
// "Claude is editing" identity) and any non-parent source. Standalone
|
|
192
|
+
// (no parent) receives ai-activity via its own WS, never via postMessage.
|
|
193
|
+
if (e.source !== window.parent || window.parent === window) return;
|
|
194
|
+
const m = e.data as { dgn?: string; file?: string; entry?: AiEntry | null } | null;
|
|
195
|
+
if (!m || typeof m !== 'object' || m.dgn !== 'ai-activity') return;
|
|
196
|
+
if (m.file !== myFile) return;
|
|
197
|
+
setEntry(m.entry ?? null);
|
|
198
|
+
};
|
|
199
|
+
window.addEventListener('message', onMessage);
|
|
200
|
+
|
|
201
|
+
// Standalone (no embedding parent): dial the inspector WS ourselves.
|
|
202
|
+
let ws: WebSocket | null = null;
|
|
203
|
+
let reconnect: ReturnType<typeof setTimeout> | null = null;
|
|
204
|
+
function connectStandalone() {
|
|
205
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
206
|
+
const sock = new WebSocket(`${proto}//${location.host}/_ws`);
|
|
207
|
+
ws = sock;
|
|
208
|
+
sock.addEventListener('message', (ev) => {
|
|
209
|
+
try {
|
|
210
|
+
if (typeof ev.data !== 'string') return;
|
|
211
|
+
const m = JSON.parse(ev.data) as { type?: string; file?: string; entry?: AiEntry | null };
|
|
212
|
+
if (m.type !== 'ai-activity' || m.file !== myFile) return;
|
|
213
|
+
setEntry(m.entry ?? null);
|
|
214
|
+
} catch {
|
|
215
|
+
/* binary collab frame — ignore */
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
sock.addEventListener('close', () => {
|
|
219
|
+
ws = null;
|
|
220
|
+
reconnect = setTimeout(connectStandalone, 2000);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if (window.parent === window) connectStandalone();
|
|
224
|
+
|
|
225
|
+
return () => {
|
|
226
|
+
cancelled = true;
|
|
227
|
+
window.removeEventListener('message', onMessage);
|
|
228
|
+
if (reconnect) clearTimeout(reconnect);
|
|
229
|
+
ws?.close();
|
|
230
|
+
};
|
|
231
|
+
}, [myFile, initialAgent]);
|
|
232
|
+
|
|
233
|
+
const agent = useMemo<AgentPresence | null>(() => {
|
|
234
|
+
if (initialAgent !== undefined) return initialAgent;
|
|
235
|
+
return entry ? deriveAgent(entry) : null;
|
|
236
|
+
}, [entry, initialAgent]);
|
|
237
|
+
|
|
238
|
+
return <AgentPresenceContext.Provider value={agent}>{children}</AgentPresenceContext.Provider>;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The agent currently editing this canvas, or null. Inert outside the provider. */
|
|
242
|
+
export function useAgentPresence(): AgentPresence | null {
|
|
243
|
+
return useContext(AgentPresenceContext);
|
|
244
|
+
}
|
|
@@ -297,11 +297,7 @@ function handlePositions(s: Stroke): Array<{ corner: Corner; x: number; y: numbe
|
|
|
297
297
|
];
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
-
export function AnnotationResizeOverlay({
|
|
301
|
-
store,
|
|
302
|
-
}: {
|
|
303
|
-
store: StrokesStoreValue | null;
|
|
304
|
-
}): ReactNode {
|
|
300
|
+
export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue | null }): ReactNode {
|
|
305
301
|
ensureResizeStyles();
|
|
306
302
|
const annotSel = useAnnotationSelection();
|
|
307
303
|
const controller = useViewportControllerContext();
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* selection resets (the strokes themselves persist via `.annotations.svg`).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { type ReactNode,
|
|
13
|
+
import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react';
|
|
14
14
|
|
|
15
15
|
export interface AnnotationSelectionValue {
|
|
16
16
|
selectedIds: string[];
|
|
@@ -34,11 +34,7 @@ function dedupe(ids: string[]): string[] {
|
|
|
34
34
|
return out;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function AnnotationSelectionProvider({
|
|
38
|
-
children,
|
|
39
|
-
}: {
|
|
40
|
-
children: ReactNode;
|
|
41
|
-
}) {
|
|
37
|
+
export function AnnotationSelectionProvider({ children }: { children: ReactNode }) {
|
|
42
38
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
43
39
|
|
|
44
40
|
const replace = useCallback((id: string | string[]) => {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* siblings under CanvasRouter all consume the same value.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { type ReactNode,
|
|
11
|
+
import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react';
|
|
12
12
|
|
|
13
13
|
export interface AnnotationsVisibilityValue {
|
|
14
14
|
visible: boolean;
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
|
|
39
39
|
import { DRAG_THRESHOLD_PX as INPUT_DRAG_THRESHOLD_PX } from './input-router.tsx';
|
|
40
40
|
import type { Selection } from './use-selection-set.tsx';
|
|
41
|
-
import { type Rect, type SnapResult
|
|
41
|
+
import { computeSnap, type Rect, type SnapResult } from './use-snap-guides.tsx';
|
|
42
42
|
|
|
43
43
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
44
|
// Constants
|