@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.
Files changed (105) hide show
  1. package/README.md +7 -0
  2. package/cli/bin/maude.mjs +5 -1
  3. package/cli/commands/design-link.test.mjs +207 -0
  4. package/cli/commands/design.mjs +42 -12
  5. package/cli/commands/doctor.mjs +350 -0
  6. package/cli/commands/doctor.test.mjs +185 -0
  7. package/cli/commands/help.mjs +24 -0
  8. package/cli/commands/hub.mjs +231 -0
  9. package/cli/commands/hub.test.mjs +87 -0
  10. package/cli/lib/config-lint.mjs +141 -0
  11. package/cli/lib/config-lint.test.mjs +117 -0
  12. package/cli/lib/design-link.mjs +216 -0
  13. package/cli/lib/hubs-config.mjs +123 -0
  14. package/cli/lib/hubs-config.test.mjs +100 -0
  15. package/cli/lib/preflight.mjs +232 -0
  16. package/cli/lib/stack-detect.mjs +344 -0
  17. package/cli/lib/stack-detect.test.mjs +121 -0
  18. package/package.json +17 -9
  19. package/plugins/design/dependencies.json +147 -0
  20. package/plugins/design/dependencies.schema.json +107 -0
  21. package/plugins/design/dev-server/ai-banner.tsx +188 -0
  22. package/plugins/design/dev-server/annotations-context-toolbar.tsx +115 -41
  23. package/plugins/design/dev-server/annotations-layer.tsx +256 -107
  24. package/plugins/design/dev-server/api.ts +17 -1
  25. package/plugins/design/dev-server/artboard-marquee.tsx +10 -6
  26. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  27. package/plugins/design/dev-server/bin/preflight.sh +32 -0
  28. package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
  29. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  30. package/plugins/design/dev-server/canvas-lib.tsx +534 -32
  31. package/plugins/design/dev-server/canvas-shell.tsx +479 -29
  32. package/plugins/design/dev-server/client/app.jsx +72 -0
  33. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  34. package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
  35. package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
  36. package/plugins/design/dev-server/collab/index.ts +47 -0
  37. package/plugins/design/dev-server/collab/persistence.ts +123 -0
  38. package/plugins/design/dev-server/collab/protocol.ts +108 -0
  39. package/plugins/design/dev-server/collab/registry.ts +110 -0
  40. package/plugins/design/dev-server/collab/room.ts +215 -0
  41. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  42. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  43. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  44. package/plugins/design/dev-server/comments-overlay.tsx +41 -4
  45. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  46. package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
  47. package/plugins/design/dev-server/dist/client.bundle.js +75 -3
  48. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  49. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  50. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
  51. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
  52. package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
  53. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  54. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  55. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  56. package/plugins/design/dev-server/handoff.ts +24 -0
  57. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  58. package/plugins/design/dev-server/http.ts +91 -1
  59. package/plugins/design/dev-server/input-router.tsx +52 -2
  60. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  61. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  62. package/plugins/design/dev-server/runtime-bundle.ts +15 -0
  63. package/plugins/design/dev-server/server.ts +78 -11
  64. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  65. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  66. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  67. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  68. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  69. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  70. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  71. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  72. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  73. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  74. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  75. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  77. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  78. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  79. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  80. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  81. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  82. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  83. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  84. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  85. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  86. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  87. package/plugins/design/dev-server/undo-stack.ts +240 -0
  88. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  89. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  90. package/plugins/design/dev-server/use-collab.tsx +478 -0
  91. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  92. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  93. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  94. package/plugins/design/dev-server/ws.ts +123 -7
  95. package/plugins/design/templates/_shell.html +51 -6
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  97. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  98. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  99. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  100. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  101. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  102. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  103. package/plugins/flow/dependencies.json +143 -0
  104. package/plugins/flow/dependencies.schema.json +107 -0
  105. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -0,0 +1,110 @@
1
+ // Per-server registry of active collab rooms.
2
+ //
3
+ // Rooms are lazy: created on first connection for a given slug, retained while
4
+ // any peer is connected, torn down (with a final flush) when the last peer
5
+ // disconnects. The registry is also the surface the git-lifecycle handler will
6
+ // call into (Phase 8 Task 7) to force-snapshot every dirty room before a reload
7
+ // prompt — see DDR-051 §3.
8
+
9
+ import { Y_TYPES } from './persistence.ts';
10
+ import type { Room, RoomCallbacks } from './room.ts';
11
+ import { createRoom } from './room.ts';
12
+
13
+ export interface Registry {
14
+ /** Get-or-create. Reuses an existing room for the same slug. */
15
+ get(slug: string): Room;
16
+ /** Existence check — returns the live room if any, else null. NEVER creates. */
17
+ peek(slug: string): Room | null;
18
+ /**
19
+ * Phase 8 Task 3 bridge — inspector-channel writes (REST `/_api/comments*`
20
+ * or the legacy WS comments-add path) call this so the live Y.Array sees
21
+ * the change and broadcasts it to collab peers. No-op when no room is
22
+ * live; the next cold open will seed from the freshly-written JSON anyway.
23
+ *
24
+ * `comments` is the post-mutation JSON list (the same shape persistJson
25
+ * writes back). We replace the Y.Array contents wholesale inside a
26
+ * transaction tagged `'inspector-write'` so the doc.update broadcaster
27
+ * downstreams it to peers but skips the in-flight debounce loop.
28
+ */
29
+ syncRoomFromComments(slug: string, comments: readonly unknown[]): void;
30
+ /**
31
+ * Phase 8 Task 5 bridge — same shape as syncRoomFromComments but for the
32
+ * `annotations` Y.Map. The PUT /_api/annotations endpoint passes the
33
+ * post-write SVG; the room replaces `Y.Map.svg` so collab peers see the
34
+ * updated stroke set without waiting for a cold-open re-seed.
35
+ */
36
+ syncRoomFromAnnotations(slug: string, svg: string): void;
37
+ /** Flush every dirty room synchronously. DDR-051 branch-switch path. */
38
+ flushAll(): Promise<void>;
39
+ /** Tear down everything (e.g. on server shutdown). */
40
+ destroyAll(): Promise<void>;
41
+ /** Tear down a single room when its last peer leaves. */
42
+ drop(slug: string): Promise<void>;
43
+ /** Test/introspection. */
44
+ size(): number;
45
+ }
46
+
47
+ export function createRegistry(callbacks: RoomCallbacks): Registry {
48
+ const rooms = new Map<string, Room>();
49
+
50
+ function get(slug: string): Room {
51
+ let room = rooms.get(slug);
52
+ if (!room) {
53
+ room = createRoom(slug, callbacks);
54
+ rooms.set(slug, room);
55
+ }
56
+ return room;
57
+ }
58
+
59
+ function peek(slug: string): Room | null {
60
+ return rooms.get(slug) ?? null;
61
+ }
62
+
63
+ function syncRoomFromComments(slug: string, comments: readonly unknown[]): void {
64
+ const room = rooms.get(slug);
65
+ if (!room) return;
66
+ room.doc.transact(() => {
67
+ const arr = room.doc.getArray<unknown>(Y_TYPES.comments);
68
+ if (arr.length > 0) arr.delete(0, arr.length);
69
+ if (comments.length > 0) arr.push(comments as unknown[]);
70
+ }, 'inspector-write');
71
+ }
72
+
73
+ function syncRoomFromAnnotations(slug: string, svg: string): void {
74
+ const room = rooms.get(slug);
75
+ if (!room) return;
76
+ room.doc.transact(() => {
77
+ const map = room.doc.getMap<string>(Y_TYPES.annotations);
78
+ map.set('svg', svg);
79
+ }, 'inspector-write');
80
+ }
81
+
82
+ async function flushAll(): Promise<void> {
83
+ await Promise.all(Array.from(rooms.values(), (r) => r.flush()));
84
+ }
85
+
86
+ async function drop(slug: string): Promise<void> {
87
+ const room = rooms.get(slug);
88
+ if (!room) return;
89
+ if (room.size() > 0) return; // still active, leave it
90
+ rooms.delete(slug);
91
+ await room.destroy();
92
+ }
93
+
94
+ async function destroyAll(): Promise<void> {
95
+ const all = Array.from(rooms.values());
96
+ rooms.clear();
97
+ await Promise.all(all.map((r) => r.destroy()));
98
+ }
99
+
100
+ return {
101
+ get,
102
+ peek,
103
+ syncRoomFromComments,
104
+ syncRoomFromAnnotations,
105
+ flushAll,
106
+ destroyAll,
107
+ drop,
108
+ size: () => rooms.size,
109
+ };
110
+ }
@@ -0,0 +1,215 @@
1
+ // Per-canvas Y.Doc room — registry, connection set, persistence schedule.
2
+ //
3
+ // One Room per canvas slug. Holds the Y.Doc + Awareness + the set of connected
4
+ // peers, broadcasts updates, debounces JSON snapshot writes to disk
5
+ // (DDR-051 — JSON is canonical, `.ydoc.bin` is a cache).
6
+ //
7
+ // The Room is transport-agnostic — it accepts CollabConn objects from the WS
8
+ // layer (ws.ts). Tests construct rooms directly without booting Bun.serve.
9
+
10
+ import { existsSync, mkdirSync } from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ import { Awareness, removeAwarenessStates } from 'y-protocols/awareness';
14
+ import * as Y from 'yjs';
15
+
16
+ import {
17
+ type CollabConn,
18
+ encodeAwarenessFrame,
19
+ encodeHandshake,
20
+ encodeSyncUpdate,
21
+ handleMessage,
22
+ } from './protocol.ts';
23
+
24
+ export interface RoomConn extends CollabConn {
25
+ /** Stable id for this connection (UUID from ws.data.id). */
26
+ id: string;
27
+ }
28
+
29
+ export interface RoomCallbacks {
30
+ /**
31
+ * Persist the JSON projection of `doc` for this slug. Called debounced — the
32
+ * Room batches Y.Doc updates within 800 ms windows and fires this once at
33
+ * quiescence. Implementations MUST be idempotent.
34
+ */
35
+ persistJson(slug: string, doc: Y.Doc): Promise<void> | void;
36
+
37
+ /**
38
+ * Persist the binary Y.Doc state cache (`.ydoc.bin`). Called on the same
39
+ * debounce schedule as persistJson. Separate hook so tests can stub one
40
+ * without the other.
41
+ */
42
+ persistBinary(slug: string, state: Uint8Array): Promise<void> | void;
43
+
44
+ /**
45
+ * Seed the Y.Doc from on-disk persistence — `.ydoc.bin` first, JSON
46
+ * snapshots second. Called once at room construction. MUST be synchronous
47
+ * with respect to the Y.Doc passed in so the first peer's handshake sees
48
+ * the seeded state.
49
+ */
50
+ seed(slug: string, doc: Y.Doc): Promise<void> | void;
51
+ }
52
+
53
+ export interface Room {
54
+ readonly slug: string;
55
+ readonly doc: Y.Doc;
56
+ readonly awareness: Awareness;
57
+ connect(conn: RoomConn): Promise<void>;
58
+ disconnect(conn: RoomConn): void;
59
+ receive(conn: RoomConn, payload: Uint8Array): void;
60
+ /** Force the debounced flush to fire now. DDR-051 §3 (branch-switch). */
61
+ flush(): Promise<void>;
62
+ /** Tear down — clears timers + removes awareness. */
63
+ destroy(): Promise<void>;
64
+ /** Test/inspection: connection count. */
65
+ size(): number;
66
+ }
67
+
68
+ const DEBOUNCE_MS = 800;
69
+
70
+ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
71
+ const doc = new Y.Doc();
72
+ // Awareness needs a clientID; reuse the Y.Doc's so peers can attribute updates.
73
+ const awareness = new Awareness(doc);
74
+
75
+ const conns = new Map<string, RoomConn>();
76
+ let dirty = false;
77
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
78
+ let destroyed = false;
79
+ let seedPromise: Promise<void> | null = null;
80
+
81
+ function scheduleFlush() {
82
+ dirty = true;
83
+ if (flushTimer) clearTimeout(flushTimer);
84
+ flushTimer = setTimeout(() => {
85
+ flushTimer = null;
86
+ void flush();
87
+ }, DEBOUNCE_MS);
88
+ }
89
+
90
+ async function flush(): Promise<void> {
91
+ if (!dirty || destroyed) return;
92
+ dirty = false;
93
+ if (flushTimer) {
94
+ clearTimeout(flushTimer);
95
+ flushTimer = null;
96
+ }
97
+ try {
98
+ await callbacks.persistJson(slug, doc);
99
+ await callbacks.persistBinary(slug, Y.encodeStateAsUpdate(doc));
100
+ } catch (err) {
101
+ // Re-arm so the next mutation retries. Loud-log; persistence loss is a
102
+ // user-visible bug if it stays silent.
103
+ dirty = true;
104
+ console.error(`[collab/${slug}] flush failed:`, err);
105
+ }
106
+ }
107
+
108
+ function broadcast(payload: Uint8Array, except?: RoomConn) {
109
+ for (const c of conns.values()) {
110
+ if (except && c.id === except.id) continue;
111
+ try {
112
+ c.send(payload);
113
+ } catch {
114
+ /* close handler will clean up dead sockets */
115
+ }
116
+ }
117
+ }
118
+
119
+ // Y.Doc update -> broadcast to all peers + schedule debounced flush.
120
+ doc.on('update', (update: Uint8Array, origin: unknown) => {
121
+ if (destroyed) return;
122
+ // Don't echo back to the origin — they already have it; reduces noise.
123
+ const except =
124
+ origin && typeof origin === 'object' && 'id' in origin ? (origin as RoomConn) : undefined;
125
+ broadcast(encodeSyncUpdate(update), except);
126
+ scheduleFlush();
127
+ });
128
+
129
+ // Awareness changes -> broadcast (NOT persisted; ephemeral by design).
130
+ awareness.on(
131
+ 'update',
132
+ (
133
+ { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
134
+ origin: unknown
135
+ ) => {
136
+ if (destroyed) return;
137
+ const changed = added.concat(updated, removed);
138
+ if (changed.length === 0) return;
139
+ const except =
140
+ origin && typeof origin === 'object' && 'id' in origin ? (origin as RoomConn) : undefined;
141
+ broadcast(encodeAwarenessFrame(awareness, changed), except);
142
+ }
143
+ );
144
+
145
+ function getOrStartSeed(): Promise<void> {
146
+ if (!seedPromise) {
147
+ const result = callbacks.seed(slug, doc);
148
+ seedPromise = Promise.resolve(result).catch((err) => {
149
+ console.error(`[collab/${slug}] seed failed:`, err);
150
+ });
151
+ }
152
+ return seedPromise;
153
+ }
154
+
155
+ async function connect(conn: RoomConn): Promise<void> {
156
+ await getOrStartSeed();
157
+ conns.set(conn.id, conn);
158
+ const frames = encodeHandshake(doc, awareness);
159
+ for (const frame of frames) conn.send(frame);
160
+ }
161
+
162
+ function disconnect(conn: RoomConn): void {
163
+ conns.delete(conn.id);
164
+ // Awareness states keyed by the conn token; clean them up so other peers'
165
+ // cursor renderers can drop the avatar.
166
+ const states = awareness.getStates();
167
+ const stale: number[] = [];
168
+ for (const clientId of states.keys()) {
169
+ // Bridge: our Awareness state setters tag the state with `__connId`
170
+ // matching conn.id; remove states whose owning conn just left.
171
+ const state = states.get(clientId) as { __connId?: string } | undefined;
172
+ if (state && state.__connId === conn.id) stale.push(clientId);
173
+ }
174
+ if (stale.length) removeAwarenessStates(awareness, stale, conn);
175
+ }
176
+
177
+ function receive(conn: RoomConn, payload: Uint8Array): void {
178
+ const reply = handleMessage(payload, doc, awareness, conn);
179
+ if (reply) conn.send(reply);
180
+ }
181
+
182
+ async function destroy(): Promise<void> {
183
+ destroyed = true;
184
+ if (flushTimer) {
185
+ clearTimeout(flushTimer);
186
+ flushTimer = null;
187
+ }
188
+ if (dirty) await flush();
189
+ conns.clear();
190
+ awareness.destroy();
191
+ doc.destroy();
192
+ }
193
+
194
+ return {
195
+ slug,
196
+ doc,
197
+ awareness,
198
+ connect,
199
+ disconnect,
200
+ receive,
201
+ flush,
202
+ destroy,
203
+ size: () => conns.size,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Ensure `<designRoot>/_state/` exists. Idempotent; safe to call on every
209
+ * room construction (mkdir recursive returns the path or noop).
210
+ */
211
+ export function ensureStateDir(designRoot: string): string {
212
+ const dir = path.join(designRoot, '_state');
213
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
214
+ return dir;
215
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @file commands/annotation-strokes-command.ts — undo entry for annotation
3
+ * stroke add / erase / batch-translate.
4
+ * @scope plugins/design/dev-server/commands/annotation-strokes-command.ts
5
+ * @purpose Records a `Stroke[]` pair (before/after) and routes both
6
+ * directions through a single `putFn`. The server endpoint is
7
+ * `PUT /_api/annotations` which replaces the entire SVG; we
8
+ * match that shape rather than diffing.
9
+ *
10
+ * Per DDR-050 rev 2 the runtime command is rebuilt per iframe
11
+ * mount from a serializable `CommandRecord` so the stack
12
+ * survives canvas switches. The `putFn` here also updates the
13
+ * iframe's local React strokes state (see annotations-layer
14
+ * `putStrokes`) so undo/redo visibly refresh, not just PUT.
15
+ *
16
+ * Why per-stroke commands (not coalesced into a 300 ms window):
17
+ * matches Figma/FigJam — Cmd+Z reverts the most recent tap or
18
+ * pen stroke individually. Coalescing into windows led to a
19
+ * matoucí "why did Cmd+Z erase two of my last lines?" UX.
20
+ */
21
+
22
+ import type { Stroke } from '../annotations-layer.tsx';
23
+ import type { CommandRecord, CommandSinks, EditCommand } from '../undo-stack.ts';
24
+ import { registerCommand } from '../undo-stack.ts';
25
+
26
+ /**
27
+ * Push-once callable that submits a full `Stroke[]` to the server (or its
28
+ * test stub) AND updates the iframe's local strokes state. The
29
+ * annotations-layer `putStrokes` is the production binding.
30
+ */
31
+ export type StrokesPutFn = (next: readonly Stroke[]) => void | Promise<void>;
32
+
33
+ export interface AnnotationStrokesPayload {
34
+ before: readonly Stroke[];
35
+ after: readonly Stroke[];
36
+ }
37
+
38
+ export const ANNOTATION_STROKES_KIND = 'annotation-strokes';
39
+
40
+ export interface AnnotationStrokesCommandInit {
41
+ before: readonly Stroke[];
42
+ after: readonly Stroke[];
43
+ putFn: StrokesPutFn;
44
+ /** Optional label override (eraser → "erase 1 stroke"). */
45
+ label?: string;
46
+ /** Telemetry kind. */
47
+ kind?: string;
48
+ }
49
+
50
+ export function createAnnotationStrokesCommand(init: AnnotationStrokesCommandInit): EditCommand {
51
+ const { putFn } = init;
52
+ const before = init.before.map(cloneStroke);
53
+ const after = init.after.map(cloneStroke);
54
+ const label = init.label ?? defaultLabel(before, after);
55
+ const kind = init.kind ?? ANNOTATION_STROKES_KIND;
56
+
57
+ return {
58
+ kind,
59
+ label,
60
+ async do() {
61
+ await putFn(after.map(cloneStroke));
62
+ },
63
+ async undo() {
64
+ await putFn(before.map(cloneStroke));
65
+ },
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Build a persistable record from the same inputs as `createAnnotationStrokesCommand`.
71
+ * Used by the consumer alongside the EditCommand so the runtime side-effect
72
+ * and the stored shape share one snapshot.
73
+ */
74
+ export function buildAnnotationStrokesRecord(opts: {
75
+ before: readonly Stroke[];
76
+ after: readonly Stroke[];
77
+ label?: string;
78
+ }): CommandRecord<AnnotationStrokesPayload> {
79
+ const before = opts.before.map(cloneStroke);
80
+ const after = opts.after.map(cloneStroke);
81
+ return {
82
+ kind: ANNOTATION_STROKES_KIND,
83
+ label: opts.label ?? defaultLabel(before, after),
84
+ payload: { before, after },
85
+ };
86
+ }
87
+
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+ // Registry
90
+
91
+ registerCommand<AnnotationStrokesPayload>(ANNOTATION_STROKES_KIND, (record, sinks) => {
92
+ const putFn = sinks.strokesPutFn as StrokesPutFn | undefined;
93
+ if (!putFn) return null;
94
+ return createAnnotationStrokesCommand({
95
+ before: record.payload.before,
96
+ after: record.payload.after,
97
+ putFn,
98
+ label: record.label,
99
+ });
100
+ });
101
+
102
+ // ─────────────────────────────────────────────────────────────────────────────
103
+ // Internals
104
+
105
+ /**
106
+ * Structured clone — works for every Stroke variant because they're plain
107
+ * JSON-shaped (no Date / Map / DOM nodes). The pen `points: WorldPoint[]`
108
+ * array is nested-cloned too so callers can't poison the snapshot by
109
+ * mutating the source.
110
+ */
111
+ function cloneStroke<T extends Stroke>(s: T): T {
112
+ // structuredClone is available on every runtime we ship to (Bun, modern
113
+ // browsers, Node 17+). Fall back to JSON for the rare older harness.
114
+ if (typeof structuredClone === 'function') return structuredClone(s);
115
+ return JSON.parse(JSON.stringify(s)) as T;
116
+ }
117
+
118
+ function defaultLabel(before: readonly Stroke[], after: readonly Stroke[]): string {
119
+ const added = after.length - before.length;
120
+ if (added > 0) return `add ${added} stroke${added === 1 ? '' : 's'}`;
121
+ if (added < 0) {
122
+ const erased = -added;
123
+ return `erase ${erased} stroke${erased === 1 ? '' : 's'}`;
124
+ }
125
+ // Same count — could be translate / edit / fill-change.
126
+ return `edit ${after.length} stroke${after.length === 1 ? '' : 's'}`;
127
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @file commands/equal-spacing-command.ts — undo label helpers for
3
+ * equal-spacing / align gestures.
4
+ * @scope plugins/design/dev-server/commands/equal-spacing-command.ts
5
+ * @purpose Distribute + align both call `dragBus.commitPositions(moved)`,
6
+ * which wraps the move in a generic MoveArtboardsCommand. Per
7
+ * DDR-050 the underlying command type is shared (the inverse
8
+ * payload is identical — a layout snapshot pair) and only the
9
+ * HUD label differs. These helpers compute the labels.
10
+ *
11
+ * Why not a separate Command class? The wrapping logic would
12
+ * duplicate `before`/`after`/`patchFn` + add an indirection.
13
+ * A label is a one-line string; a factory is overkill.
14
+ * Keeping `MoveArtboardsCommand` as the only artboard-layout
15
+ * command keeps the future Yjs swap (Phase 8) one-for-one.
16
+ */
17
+
18
+ export type AlignMode = 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y';
19
+
20
+ /** "equal-space 3 artboards" — N must be ≥ 3 (distribute requires it). */
21
+ export function equalSpacingLabel(n: number): string {
22
+ return `equal-space ${n} artboard${n === 1 ? '' : 's'}`;
23
+ }
24
+
25
+ /** "align left 4 artboards" — N must be ≥ 2 (align requires it). */
26
+ export function alignLabel(mode: AlignMode, n: number): string {
27
+ return `align ${mode} ${n} artboard${n === 1 ? '' : 's'}`;
28
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @file commands/move-artboards-command.ts — undo entry for artboard moves
3
+ * @scope plugins/design/dev-server/commands/move-artboards-command.ts
4
+ * @purpose Reversible record of an artboard-layout PATCH. Pairs the full
5
+ * `before` and `after` layout snapshots with an injected
6
+ * `patchFn` (in production, `applyArtboardLayout`; in tests, a
7
+ * spy). The command is rebuilt per iframe mount from a
8
+ * `CommandRecord` so the stack survives canvas switches
9
+ * (DDR-050 rev 2).
10
+ *
11
+ * Why full snapshots, not a sparse diff. The server-side endpoint
12
+ * (`PATCH /_api/canvas-meta`, see api.ts:523–528) shallow-merges patch.layout
13
+ * over the existing meta. A sparse `{ artboards: [movedOnly] }` would drop
14
+ * every unchanged rect on its head. Storing the full array is simpler and
15
+ * resilient — the external-edit invalidation (DDR-050 rule 6) clears the
16
+ * stack whenever the file changes outside our PATCH so we never restore a
17
+ * stale layout.
18
+ */
19
+
20
+ import type { CommandRecord, CommandSinks, EditCommand } from '../undo-stack.ts';
21
+ import { registerCommand } from '../undo-stack.ts';
22
+
23
+ export interface ArtboardLayoutEntry {
24
+ id: string;
25
+ x: number;
26
+ y: number;
27
+ /** Width — JSX-authoritative; carried through for shape-completeness. */
28
+ w?: number;
29
+ h?: number;
30
+ }
31
+
32
+ /**
33
+ * Signature compatible with canvas-lib's `applyArtboardLayout(layout)`.
34
+ * Tests pass a spy; production wiring passes the real React-state-+-PATCH
35
+ * applier.
36
+ */
37
+ export type LayoutPatchFn = (layout: ArtboardLayoutEntry[]) => void | Promise<void>;
38
+
39
+ export interface MoveArtboardsPayload {
40
+ before: readonly ArtboardLayoutEntry[];
41
+ after: readonly ArtboardLayoutEntry[];
42
+ }
43
+
44
+ /** Convenience constant — keeps spelling consistent across files. */
45
+ export const MOVE_ARTBOARDS_KIND = 'move-artboards';
46
+
47
+ export interface MoveArtboardsCommandInit {
48
+ before: readonly ArtboardLayoutEntry[];
49
+ after: readonly ArtboardLayoutEntry[];
50
+ patchFn: LayoutPatchFn;
51
+ /** Optional label override (equal-spacing wraps with its own copy). */
52
+ label?: string;
53
+ /** Telemetry kind. Defaults to `MOVE_ARTBOARDS_KIND`. */
54
+ kind?: string;
55
+ }
56
+
57
+ export function createMoveArtboardsCommand(init: MoveArtboardsCommandInit): EditCommand {
58
+ const { before, after, patchFn } = init;
59
+ // Snapshot once — mutating the source arrays later cannot poison the command.
60
+ const beforeSnapshot = before.map(cloneEntry);
61
+ const afterSnapshot = after.map(cloneEntry);
62
+ const movedCount = countMoved(beforeSnapshot, afterSnapshot);
63
+ const label = init.label ?? `move ${movedCount} artboard${movedCount === 1 ? '' : 's'}`;
64
+ const kind = init.kind ?? MOVE_ARTBOARDS_KIND;
65
+
66
+ return {
67
+ kind,
68
+ label,
69
+ async do() {
70
+ await patchFn(afterSnapshot.map(cloneEntry));
71
+ },
72
+ async undo() {
73
+ await patchFn(beforeSnapshot.map(cloneEntry));
74
+ },
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Build a persistable record from the same inputs. Use this together with
80
+ * the EditCommand so the runtime side-effect AND the persisted shape share
81
+ * one snapshot.
82
+ */
83
+ export function buildMoveArtboardsRecord(opts: {
84
+ before: readonly ArtboardLayoutEntry[];
85
+ after: readonly ArtboardLayoutEntry[];
86
+ label?: string;
87
+ }): CommandRecord<MoveArtboardsPayload> {
88
+ const before = opts.before.map(cloneEntry);
89
+ const after = opts.after.map(cloneEntry);
90
+ const moved = countMoved(before, after);
91
+ const label = opts.label ?? `move ${moved} artboard${moved === 1 ? '' : 's'}`;
92
+ return { kind: MOVE_ARTBOARDS_KIND, label, payload: { before, after } };
93
+ }
94
+
95
+ /**
96
+ * Diff helper — returns `null` when `before` and `after` describe the same
97
+ * layout (call sites use this to skip pushing a no-op drag onto the stack).
98
+ * Compares positions only; size diffs are ignored (size is JSX-authoritative
99
+ * per DDR-027, position is the only mutable channel).
100
+ */
101
+ export function diffLayoutPositions(
102
+ before: readonly ArtboardLayoutEntry[],
103
+ after: readonly ArtboardLayoutEntry[]
104
+ ): { changed: number } | null {
105
+ if (before.length !== after.length) return { changed: Math.max(before.length, after.length) };
106
+ const byId = new Map<string, ArtboardLayoutEntry>();
107
+ for (const r of before) byId.set(r.id, r);
108
+ let changed = 0;
109
+ for (const r of after) {
110
+ const prev = byId.get(r.id);
111
+ if (!prev || prev.x !== r.x || prev.y !== r.y) changed++;
112
+ }
113
+ if (changed === 0) return null;
114
+ return { changed };
115
+ }
116
+
117
+ // ─────────────────────────────────────────────────────────────────────────────
118
+ // Registry — rebuild EditCommand from a persisted CommandRecord + current
119
+ // iframe's sinks. Runs once on module load (top-level side-effect intentional).
120
+
121
+ registerCommand<MoveArtboardsPayload>(MOVE_ARTBOARDS_KIND, (record, sinks) => {
122
+ const patchFn = sinks.layoutPatchFn as LayoutPatchFn | undefined;
123
+ if (!patchFn) return null;
124
+ return createMoveArtboardsCommand({
125
+ before: record.payload.before,
126
+ after: record.payload.after,
127
+ patchFn,
128
+ label: record.label,
129
+ });
130
+ });
131
+
132
+ // ─────────────────────────────────────────────────────────────────────────────
133
+ // Internals
134
+
135
+ function cloneEntry(r: ArtboardLayoutEntry): ArtboardLayoutEntry {
136
+ const out: ArtboardLayoutEntry = { id: r.id, x: r.x, y: r.y };
137
+ if (typeof r.w === 'number') out.w = r.w;
138
+ if (typeof r.h === 'number') out.h = r.h;
139
+ return out;
140
+ }
141
+
142
+ function countMoved(
143
+ before: readonly ArtboardLayoutEntry[],
144
+ after: readonly ArtboardLayoutEntry[]
145
+ ): number {
146
+ const byId = new Map<string, ArtboardLayoutEntry>();
147
+ for (const r of before) byId.set(r.id, r);
148
+ let n = 0;
149
+ for (const r of after) {
150
+ const prev = byId.get(r.id);
151
+ if (!prev) {
152
+ n++;
153
+ continue;
154
+ }
155
+ if (prev.x !== r.x || prev.y !== r.y) n++;
156
+ }
157
+ return n;
158
+ }