experimental-a2 0.8.0 → 0.8.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.
Files changed (94) hide show
  1. package/AGENTS.md +11 -0
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +29 -0
  4. package/docs/guides/07-examples.mdx +56 -0
  5. package/docs/index.mdx +13 -0
  6. package/examples/README.md +15 -0
  7. package/examples/playground/AGENTS.md +11 -0
  8. package/examples/playground/DEPLOY.md +106 -0
  9. package/examples/playground/README.md +19 -0
  10. package/examples/playground/activity-feed.test.ts +10 -0
  11. package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
  12. package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
  13. package/examples/playground/app/agent/events/route.ts +4 -0
  14. package/examples/playground/app/agent/model.ts +3 -0
  15. package/examples/playground/app/agent/new-agent-session.tsx +98 -0
  16. package/examples/playground/app/agent/page.tsx +25 -0
  17. package/examples/playground/app/agent/scheduler/route.ts +5 -0
  18. package/examples/playground/app/agent/server.ts +153 -0
  19. package/examples/playground/app/agent/session.ts +13 -0
  20. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
  21. package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
  22. package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
  23. package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
  24. package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
  25. package/examples/playground/app/canvas/events/route.ts +13 -0
  26. package/examples/playground/app/canvas/model.ts +94 -0
  27. package/examples/playground/app/canvas/open-canvas.tsx +40 -0
  28. package/examples/playground/app/canvas/page.tsx +20 -0
  29. package/examples/playground/app/canvas/server.ts +9 -0
  30. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
  31. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
  32. package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
  33. package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
  34. package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
  35. package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
  36. package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
  37. package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
  38. package/examples/playground/app/chat/[chatId]/session.ts +7 -0
  39. package/examples/playground/app/chat/events/route.ts +7 -0
  40. package/examples/playground/app/chat/model.test.ts +155 -0
  41. package/examples/playground/app/chat/model.ts +310 -0
  42. package/examples/playground/app/chat/new-conversation.tsx +16 -0
  43. package/examples/playground/app/chat/page.tsx +25 -0
  44. package/examples/playground/app/chat/scheduler/route.ts +5 -0
  45. package/examples/playground/app/chat/server.ts +184 -0
  46. package/examples/playground/app/components/activity-feed.tsx +54 -0
  47. package/examples/playground/app/components/connection-pill.tsx +29 -0
  48. package/examples/playground/app/counter/counter-client.tsx +72 -0
  49. package/examples/playground/app/counter/events/route.ts +4 -0
  50. package/examples/playground/app/counter/model.test.ts +36 -0
  51. package/examples/playground/app/counter/model.ts +31 -0
  52. package/examples/playground/app/counter/page.tsx +24 -0
  53. package/examples/playground/app/counter/server.ts +9 -0
  54. package/examples/playground/app/counter/session.ts +13 -0
  55. package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
  56. package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
  57. package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
  58. package/examples/playground/app/documents/[documentId]/session.ts +7 -0
  59. package/examples/playground/app/documents/events/route.ts +4 -0
  60. package/examples/playground/app/documents/model.ts +55 -0
  61. package/examples/playground/app/documents/open-document.tsx +40 -0
  62. package/examples/playground/app/documents/page.tsx +22 -0
  63. package/examples/playground/app/documents/server.ts +9 -0
  64. package/examples/playground/app/globals.css +2078 -0
  65. package/examples/playground/app/layout.tsx +44 -0
  66. package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
  67. package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
  68. package/examples/playground/app/orders/[orderId]/session.ts +11 -0
  69. package/examples/playground/app/orders/create/route.ts +30 -0
  70. package/examples/playground/app/orders/events/route.ts +4 -0
  71. package/examples/playground/app/orders/model.ts +79 -0
  72. package/examples/playground/app/orders/new-order-form.tsx +98 -0
  73. package/examples/playground/app/orders/page.tsx +22 -0
  74. package/examples/playground/app/orders/scheduler/route.ts +5 -0
  75. package/examples/playground/app/orders/server.ts +50 -0
  76. package/examples/playground/app/page.tsx +102 -0
  77. package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
  78. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
  79. package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
  80. package/examples/playground/app/recovery/events/route.ts +3 -0
  81. package/examples/playground/app/recovery/model.ts +55 -0
  82. package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
  83. package/examples/playground/app/recovery/page.tsx +22 -0
  84. package/examples/playground/app/recovery/scheduler/route.ts +7 -0
  85. package/examples/playground/app/recovery/server.ts +53 -0
  86. package/examples/playground/app/recovery/start/route.ts +41 -0
  87. package/examples/playground/css.d.ts +4 -0
  88. package/examples/playground/lib/store.ts +15 -0
  89. package/examples/playground/next-env.d.ts +5 -0
  90. package/examples/playground/next.config.ts +10 -0
  91. package/examples/playground/package.json +46 -0
  92. package/examples/playground/tsconfig.json +37 -0
  93. package/examples/playground/vercel.json +40 -0
  94. package/package.json +5 -2
@@ -0,0 +1,682 @@
1
+ 'use client'
2
+ import type {
3
+ CSSProperties,
4
+ PointerEvent as ReactPointerEvent,
5
+ MouseEvent as ReactMouseEvent,
6
+ ReactNode,
7
+ } from 'react'
8
+ import {
9
+ useCallback,
10
+ useEffect,
11
+ useLayoutEffect,
12
+ useMemo,
13
+ useRef,
14
+ useState,
15
+ useSyncExternalStore,
16
+ } from 'react'
17
+ import type { CanvasShape, CanvasState, PaletteColor } from '../model'
18
+ import { ConnectionPill } from '@/app/components/connection-pill'
19
+ import { confirmedCanvasEventsSince, replayCanvas } from './canvas-replay'
20
+ import { SessionProvider, useSession } from './session'
21
+
22
+ const VIEW_W = 960
23
+ const VIEW_H = 540
24
+ /** How long a cursor stays painted after its last move — render-time
25
+ * logic against Date.now(); presence is not folded state. */
26
+ const CURSOR_TTL_MS = 4_000
27
+
28
+ const IDENTITY_KEY = 'a2-playground-canvas-identity'
29
+ const ADJECTIVES = ['Bright', 'Calm', 'Curious', 'Kind', 'Swift'] as const
30
+ const ANIMALS = ['Fox', 'Heron', 'Otter', 'Panda', 'Wren'] as const
31
+ const COLORS = ['teal', 'amber', 'blue', 'violet', 'rose'] as const
32
+ const COLOR_HEX: Record<PaletteColor, string> = {
33
+ teal: '#16836a',
34
+ amber: '#bd7917',
35
+ blue: '#3874c8',
36
+ violet: '#7956b6',
37
+ rose: '#bd5269',
38
+ }
39
+
40
+ /** The tab's identity: an opaque participant id, plus the name and
41
+ * color it publishes as `profile` presence. */
42
+ type CanvasIdentity = { id: string; name: string; color: PaletteColor }
43
+
44
+ const isColor = (value: string | undefined): value is PaletteColor =>
45
+ value !== undefined && (COLORS as readonly string[]).includes(value)
46
+
47
+ function isIdentity(value: unknown): value is CanvasIdentity {
48
+ if (!value || typeof value !== 'object') return false
49
+ const identity = value as Record<string, unknown>
50
+ return (
51
+ typeof identity['id'] === 'string' &&
52
+ typeof identity['name'] === 'string' &&
53
+ typeof identity['color'] === 'string' &&
54
+ isColor(identity['color'])
55
+ )
56
+ }
57
+
58
+ function loadIdentity(): CanvasIdentity {
59
+ // The id is the stable per-tab identity; name and color re-roll on
60
+ // every load — refreshing hands you a new guise.
61
+ let id: string | null = null
62
+ try {
63
+ const stored = sessionStorage.getItem(IDENTITY_KEY)
64
+ if (stored) {
65
+ const parsed: unknown = JSON.parse(stored)
66
+ if (isIdentity(parsed)) id = parsed.id
67
+ }
68
+ } catch {
69
+ // Storage may be unavailable; a tab-local identity still works.
70
+ }
71
+ const random = new Uint32Array(3)
72
+ crypto.getRandomValues(random)
73
+ const identity: CanvasIdentity = {
74
+ id: id ?? crypto.randomUUID(),
75
+ name: `${ADJECTIVES[random[0]! % ADJECTIVES.length]!} ${ANIMALS[random[1]! % ANIMALS.length]!}`,
76
+ color: COLORS[random[2]! % COLORS.length]!,
77
+ }
78
+ try {
79
+ sessionStorage.setItem(IDENTITY_KEY, JSON.stringify(identity))
80
+ } catch {
81
+ // The in-memory identity is enough for this visit.
82
+ }
83
+ return identity
84
+ }
85
+
86
+ let tabIdentity: CanvasIdentity | null = null
87
+ const subscribeToIdentity = (): (() => void) => () => {}
88
+ const serverIdentitySnapshot = (): null => null
89
+ const clientIdentitySnapshot = (): CanvasIdentity | null => {
90
+ if (typeof window === 'undefined') return null
91
+ tabIdentity ??= loadIdentity()
92
+ return tabIdentity
93
+ }
94
+
95
+ const useCanvasIdentity = (): CanvasIdentity | null =>
96
+ useSyncExternalStore(
97
+ subscribeToIdentity,
98
+ clientIdentitySnapshot,
99
+ serverIdentitySnapshot,
100
+ )
101
+
102
+ /** A participant whose `profile` hasn't arrived (TTL'd out, or the
103
+ * snapshot raced the patch) still paints — neutrally. */
104
+ const GUEST_LABEL = { name: 'guest', color: 'teal' } as const
105
+
106
+ const labelOf = (fields: {
107
+ profile?: { value: { name: string; color: PaletteColor } }
108
+ }): { name: string; color: PaletteColor } =>
109
+ fields.profile?.value ?? GUEST_LABEL
110
+
111
+ /**
112
+ * Remote motion chases the newest sample instead of replaying the
113
+ * past: exponential convergence with a ~45ms time constant, so a
114
+ * flick reads as a flick and bursty arrivals never build a playback
115
+ * backlog. Writes the transform imperatively, no React re-render per
116
+ * frame; the loop parks when converged. Local input never goes
117
+ * through here: your own cursor and drag render straight from
118
+ * pointer state.
119
+ */
120
+ const CHASE_TAU_MS = 45
121
+
122
+ function useSmoothPoint(
123
+ x: number,
124
+ y: number,
125
+ ): (el: SVGGElement | null) => void {
126
+ const elRef = useRef<SVGGElement | null>(null)
127
+ const s = useRef({ x, y, tx: x, ty: y, raf: 0, lastT: 0 }).current
128
+
129
+ const paint = useCallback(() => {
130
+ elRef.current?.setAttribute('transform', `translate(${s.x} ${s.y})`)
131
+ }, [s])
132
+
133
+ useLayoutEffect(() => {
134
+ s.tx = x
135
+ s.ty = y
136
+ if (s.raf !== 0) return
137
+ s.lastT = performance.now()
138
+ const step = (now: number): void => {
139
+ const k = 1 - Math.exp(-(now - s.lastT) / CHASE_TAU_MS)
140
+ s.lastT = now
141
+ s.x += (s.tx - s.x) * k
142
+ s.y += (s.ty - s.y) * k
143
+ if (Math.abs(s.tx - s.x) < 0.5 && Math.abs(s.ty - s.y) < 0.5) {
144
+ s.x = s.tx
145
+ s.y = s.ty
146
+ s.raf = 0
147
+ } else {
148
+ s.raf = requestAnimationFrame(step)
149
+ }
150
+ paint()
151
+ }
152
+ s.raf = requestAnimationFrame(step)
153
+ }, [s, paint, x, y])
154
+
155
+ useLayoutEffect(
156
+ () => () => {
157
+ if (s.raf !== 0) cancelAnimationFrame(s.raf)
158
+ s.raf = 0
159
+ },
160
+ [s],
161
+ )
162
+
163
+ // The first paint lands with the mount — no flash at the SVG origin.
164
+ return useCallback(
165
+ (el: SVGGElement | null) => {
166
+ elRef.current = el
167
+ if (el) paint()
168
+ },
169
+ [paint],
170
+ )
171
+ }
172
+
173
+ function Smoothed({
174
+ x,
175
+ y,
176
+ className,
177
+ children,
178
+ }: {
179
+ x: number
180
+ y: number
181
+ className?: string
182
+ children: ReactNode
183
+ }): ReactNode {
184
+ const ref = useSmoothPoint(x, y)
185
+ return (
186
+ <g ref={ref} className={className}>
187
+ {children}
188
+ </g>
189
+ )
190
+ }
191
+
192
+ export function CanvasSession({
193
+ canvasId,
194
+ initialState,
195
+ initialIndex,
196
+ }: {
197
+ canvasId: string
198
+ initialState: CanvasState
199
+ initialIndex: number
200
+ }): ReactNode {
201
+ // The server snapshot is null so hydration matches exactly. The
202
+ // browser snapshot then mints one stable tab identity before presence
203
+ // becomes interactive.
204
+ const identity = useCanvasIdentity()
205
+ return (
206
+ <SessionProvider
207
+ sessionId={canvasId}
208
+ initialState={initialState}
209
+ initialIndex={initialIndex}
210
+ {...(identity ? { participant: identity.id } : {})}
211
+ >
212
+ <CanvasClient
213
+ key={`${canvasId}:${initialIndex}:${identity?.id ?? 'joining'}`}
214
+ canvasId={canvasId}
215
+ identity={identity}
216
+ replayBaseState={initialState}
217
+ replayBaseIndex={initialIndex}
218
+ />
219
+ </SessionProvider>
220
+ )
221
+ }
222
+
223
+ type Drag = {
224
+ shapeId: string
225
+ /** Pointer offset inside the shape, so it doesn't jump to the cursor. */
226
+ dx: number
227
+ dy: number
228
+ /** Where the pointer went down — a few stray pixels is still a click. */
229
+ sx: number
230
+ sy: number
231
+ x: number
232
+ y: number
233
+ moved: boolean
234
+ }
235
+
236
+ const clamp = (value: number, max: number): number =>
237
+ Math.min(Math.max(value, 0), max)
238
+
239
+ function CanvasClient({
240
+ canvasId,
241
+ identity,
242
+ replayBaseState,
243
+ replayBaseIndex,
244
+ }: {
245
+ canvasId: string
246
+ identity: CanvasIdentity | null
247
+ replayBaseState: CanvasState
248
+ replayBaseIndex: number
249
+ }): ReactNode {
250
+ const { state, events, index, connection, push, presence, setPresence } =
251
+ useSession()
252
+ const svgRef = useRef<SVGSVGElement>(null)
253
+ const [profile, setProfile] = useState(() =>
254
+ identity ? { name: identity.name, color: identity.color } : null,
255
+ )
256
+ const [drag, setDrag] = useState<Drag | null>(null)
257
+ /** null = live; a number pins the scene to a received-event prefix. */
258
+ const [scrub, setScrub] = useState<number | null>(null)
259
+ const [error, setError] = useState<string | null>(null)
260
+ const [copied, setCopied] = useState(false)
261
+ /** Render-time clock for cursor expiry — never enters the fold. */
262
+ const [now, setNow] = useState(() => Date.now())
263
+
264
+ const live = scrub === null
265
+ const replayEvents = useMemo(
266
+ () => confirmedCanvasEventsSince(events, replayBaseIndex, index),
267
+ [events, replayBaseIndex, index],
268
+ )
269
+ const replayThrough =
270
+ scrub === null ? replayEvents.length : Math.min(scrub, replayEvents.length)
271
+ // Start at the server-rendered fold, then replay only the tail this
272
+ // client observed. Loading the page never requires the historical log.
273
+ const scene = useMemo(
274
+ () =>
275
+ live ? state : replayCanvas(replayBaseState, replayEvents, replayThrough),
276
+ [live, state, replayEvents, replayThrough, replayBaseState],
277
+ )
278
+
279
+ // Identity metadata is presence: published on join and on every
280
+ // rename. Last-writer-wins per field, so a new name repaints in
281
+ // every open tab — nothing durable to migrate.
282
+ useEffect(() => {
283
+ if (!identity || !profile) return
284
+ const name = profile.name.trim()
285
+ if (!name) return
286
+ setPresence({ profile: { name, color: profile.color } })
287
+ try {
288
+ sessionStorage.setItem(
289
+ IDENTITY_KEY,
290
+ JSON.stringify({ id: identity.id, name, color: profile.color }),
291
+ )
292
+ } catch {
293
+ // The in-memory identity is enough for this visit.
294
+ }
295
+ }, [identity, profile, setPresence])
296
+
297
+ // A blurred window is nobody at the canvas: clear the ephemeral
298
+ // fields; the next pointer move repaints them.
299
+ useEffect(() => {
300
+ if (!identity) return
301
+ const clear = (): void => setPresence({ cursor: null, dragging: null })
302
+ const onVisibility = (): void => {
303
+ if (document.visibilityState === 'hidden') clear()
304
+ }
305
+ window.addEventListener('blur', clear)
306
+ document.addEventListener('visibilitychange', onVisibility)
307
+ return () => {
308
+ window.removeEventListener('blur', clear)
309
+ document.removeEventListener('visibilitychange', onVisibility)
310
+ }
311
+ }, [identity, setPresence])
312
+
313
+ const others = Object.entries(presence).filter(([id]) => id !== identity?.id)
314
+
315
+ // Tick once a second while any cursor could expire.
316
+ const hasCursors = others.some(([, fields]) => fields.cursor)
317
+ useEffect(() => {
318
+ if (!hasCursors) return
319
+ const timer = setInterval(() => setNow(Date.now()), 1_000)
320
+ return () => clearInterval(timer)
321
+ }, [hasCursors])
322
+
323
+ const cursors = live
324
+ ? others.flatMap(([id, fields]) => {
325
+ const cursor = fields.cursor
326
+ if (!cursor || now - cursor.at.getTime() > CURSOR_TTL_MS) return []
327
+ return [{ id, ...cursor.value, ...labelOf(fields) }]
328
+ })
329
+ : [] // durable events replay; presence has no historical state
330
+
331
+ // A ghost is dead once the log has moved past it: show `dragging`
332
+ // only while its `seen` frontier reaches the shape's durable
333
+ // `movedAt` (/guides/presence#reconciling-the-planes-seen).
334
+ const ghosts = new Map<
335
+ string,
336
+ { x: number; y: number; color: PaletteColor }
337
+ >()
338
+ if (live) {
339
+ for (const [, fields] of others) {
340
+ const dragging = fields.dragging
341
+ if (!dragging) continue
342
+ const shape = state.shapes[dragging.value.shapeId]
343
+ if (!shape || dragging.seen < shape.movedAt) continue
344
+ ghosts.set(dragging.value.shapeId, {
345
+ x: dragging.value.x,
346
+ y: dragging.value.y,
347
+ color: labelOf(fields).color,
348
+ })
349
+ }
350
+ }
351
+
352
+ const toPoint = (e: { clientX: number; clientY: number }) => {
353
+ const rect = svgRef.current!.getBoundingClientRect()
354
+ return {
355
+ x: Math.round(((e.clientX - rect.left) / rect.width) * VIEW_W),
356
+ y: Math.round(((e.clientY - rect.top) / rect.height) * VIEW_H),
357
+ }
358
+ }
359
+
360
+ const pushShape = (event: Parameters<typeof push>[0]): void => {
361
+ setError(null)
362
+ push(event).catch((err: unknown) =>
363
+ setError(err instanceof Error ? err.message : String(err)),
364
+ )
365
+ }
366
+
367
+ const createShape = (e: ReactMouseEvent<SVGSVGElement>): void => {
368
+ // detail > 1 is the second click of a double-click; target check
369
+ // skips clicks that started on a shape.
370
+ if (!live || e.target !== e.currentTarget || e.detail !== 1) return
371
+ const p = toPoint(e)
372
+ const random = new Uint32Array(4)
373
+ crypto.getRandomValues(random)
374
+ const w = 70 + (random[2]! % 71)
375
+ const h = 50 + (random[3]! % 61)
376
+ pushShape({
377
+ type: 'shape.created',
378
+ payload: {
379
+ id: crypto.randomUUID().slice(0, 8),
380
+ kind: random[0]! % 2 ? 'rect' : 'ellipse',
381
+ x: clamp(p.x - Math.round(w / 2), VIEW_W - w),
382
+ y: clamp(p.y - Math.round(h / 2), VIEW_H - h),
383
+ w,
384
+ h,
385
+ color: COLORS[random[1]! % COLORS.length]!,
386
+ },
387
+ })
388
+ }
389
+
390
+ const startDrag = (
391
+ e: ReactPointerEvent<SVGGraphicsElement>,
392
+ shape: CanvasShape,
393
+ ): void => {
394
+ if (!live || !identity) return
395
+ e.currentTarget.setPointerCapture(e.pointerId)
396
+ const p = toPoint(e)
397
+ setDrag({
398
+ shapeId: shape.id,
399
+ dx: p.x - shape.x,
400
+ dy: p.y - shape.y,
401
+ sx: p.x,
402
+ sy: p.y,
403
+ x: shape.x,
404
+ y: shape.y,
405
+ moved: false,
406
+ })
407
+ }
408
+
409
+ const onPointerMove = (e: ReactPointerEvent<SVGSVGElement>): void => {
410
+ if (!live || !identity) return
411
+ const p = toPoint(e)
412
+ if (drag) {
413
+ const shape = state.shapes[drag.shapeId]
414
+ const x = clamp(p.x - drag.dx, VIEW_W - (shape?.w ?? 0))
415
+ const y = clamp(p.y - drag.dy, VIEW_H - (shape?.h ?? 0))
416
+ const moved =
417
+ drag.moved || Math.abs(p.x - drag.sx) + Math.abs(p.y - drag.sy) > 3
418
+ setDrag({ ...drag, x, y, moved })
419
+ // One patch, both fields: the ghost everyone else renders, and
420
+ // the cursor riding along. Coalesced on the wire by the client.
421
+ setPresence({
422
+ cursor: p,
423
+ dragging: { shapeId: drag.shapeId, x, y },
424
+ })
425
+ } else {
426
+ setPresence({ cursor: p })
427
+ }
428
+ }
429
+
430
+ const endDrag = (): void => {
431
+ if (!drag || !identity) return
432
+ setDrag(null)
433
+ // Facts decide; presence repaints. The seen rule kills the ghost
434
+ // the moment shape.moved arrives, so an explicit dragging-null
435
+ // would only race the event across planes — and losing that race
436
+ // rubber-bands the shape to its pre-drag position. Clear presence
437
+ // only when no event is coming: a sub-threshold click, or a
438
+ // rejected push.
439
+ if (!drag.moved) {
440
+ setPresence({ dragging: null })
441
+ return
442
+ }
443
+ setError(null)
444
+ push({
445
+ type: 'shape.moved',
446
+ payload: { id: drag.shapeId, x: drag.x, y: drag.y },
447
+ }).catch((err: unknown) => {
448
+ setPresence({ dragging: null })
449
+ setError(err instanceof Error ? err.message : String(err))
450
+ })
451
+ }
452
+
453
+ const onPointerLeave = (): void => {
454
+ if (!identity || drag) return
455
+ setPresence({ cursor: null })
456
+ }
457
+
458
+ const deleteShape = (shapeId: string): void => {
459
+ if (!live) return
460
+ pushShape({
461
+ type: 'shape.deleted',
462
+ payload: { id: shapeId },
463
+ })
464
+ }
465
+
466
+ const copyLink = async (): Promise<void> => {
467
+ try {
468
+ await navigator.clipboard.writeText(window.location.href)
469
+ setCopied(true)
470
+ setTimeout(() => setCopied(false), 1500)
471
+ } catch (err) {
472
+ setError(err instanceof Error ? err.message : String(err))
473
+ }
474
+ }
475
+
476
+ const shapes = Object.values(scene.shapes)
477
+
478
+ return (
479
+ <article>
480
+ <header className="session-header document-header">
481
+ <div>
482
+ <p className="eyebrow">Multiplayer canvas</p>
483
+ <h1>
484
+ <code>{canvasId}</code>
485
+ </h1>
486
+ </div>
487
+ <ConnectionPill connection={connection} />
488
+ <span className="badge">
489
+ {shapes.length} {shapes.length === 1 ? 'shape' : 'shapes'}
490
+ </span>
491
+ <button
492
+ className="secondary compact"
493
+ type="button"
494
+ onClick={() => void copyLink()}
495
+ >
496
+ {copied ? 'Copied!' : 'Copy link'}
497
+ </button>
498
+ </header>
499
+ <p className="hint">
500
+ Click to create a shape, drag to move it, double-click to delete. Open
501
+ the same URL in a second tab: shapes are durable events; cursors and
502
+ drag ghosts are presence, never stored. Rename yourself below and every
503
+ tab repaints — your profile is presence too. Deployed, pushes and
504
+ presence ride one WebSocket; in dev the same route serves SSE.
505
+ </p>
506
+
507
+ <div className="canvas-toolbar">
508
+ <label className="identity-field">
509
+ You are
510
+ <input
511
+ value={profile?.name ?? ''}
512
+ onChange={(e) =>
513
+ setProfile((current) =>
514
+ current
515
+ ? { ...current, name: e.target.value.slice(0, 40) }
516
+ : current,
517
+ )
518
+ }
519
+ placeholder="Joining…"
520
+ disabled={!identity}
521
+ />
522
+ </label>
523
+ <div className="collaborators" aria-label="Cursors on this canvas">
524
+ {profile ? (
525
+ <span className="collaborator">
526
+ <span
527
+ className="collaborator-dot"
528
+ style={
529
+ {
530
+ '--collaborator-color': COLOR_HEX[profile.color],
531
+ } as CSSProperties
532
+ }
533
+ />
534
+ {profile.name.trim() || 'guest'} (you)
535
+ </span>
536
+ ) : null}
537
+ {cursors.length === 0 ? (
538
+ <span className="hint">No other cursors right now</span>
539
+ ) : (
540
+ cursors.map((cursor) => (
541
+ <span className="collaborator" key={cursor.id}>
542
+ <span
543
+ className="collaborator-dot"
544
+ style={
545
+ {
546
+ '--collaborator-color': COLOR_HEX[cursor.color],
547
+ } as CSSProperties
548
+ }
549
+ />
550
+ {cursor.name}
551
+ </span>
552
+ ))
553
+ )}
554
+ </div>
555
+ </div>
556
+
557
+ <div className={`canvas-stage${live ? '' : ' historical'}`}>
558
+ <svg
559
+ ref={svgRef}
560
+ className="canvas-svg"
561
+ viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
562
+ role="application"
563
+ aria-label="Shared canvas"
564
+ onClick={createShape}
565
+ onPointerMove={onPointerMove}
566
+ onPointerUp={endDrag}
567
+ onPointerLeave={onPointerLeave}
568
+ >
569
+ {shapes.map((shape) => {
570
+ const mine = drag?.shapeId === shape.id ? drag : null
571
+ const ghost = mine ? null : ghosts.get(shape.id)
572
+ const hex = COLOR_HEX[shape.color]
573
+ const common = {
574
+ className: `canvas-shape${ghost ? ' ghost' : ''}${mine ? ' dragging' : ''}`,
575
+ fill: hex,
576
+ stroke: ghost ? COLOR_HEX[ghost.color] : hex,
577
+ onPointerDown: (e: ReactPointerEvent<SVGGraphicsElement>) =>
578
+ startDrag(e, shape),
579
+ onDoubleClick: () => deleteShape(shape.id),
580
+ }
581
+ const geometry =
582
+ shape.kind === 'rect' ? (
583
+ <rect width={shape.w} height={shape.h} rx={6} {...common} />
584
+ ) : (
585
+ <ellipse
586
+ cx={shape.w / 2}
587
+ cy={shape.h / 2}
588
+ rx={shape.w / 2}
589
+ ry={shape.h / 2}
590
+ {...common}
591
+ />
592
+ )
593
+ // Your own drag renders raw from pointer state; scrubbed
594
+ // scenes stay static. Everything else lives in one chaser
595
+ // per shape, stable across its whole life: the target is
596
+ // the ghost while a live remote drag holds the shape, the
597
+ // fold position otherwise — so a drop is just a target
598
+ // change, converging in lockstep with the cursor chaser.
599
+ if (mine || !live) {
600
+ return (
601
+ <g
602
+ key={shape.id}
603
+ transform={`translate(${mine ? mine.x : shape.x} ${mine ? mine.y : shape.y})`}
604
+ >
605
+ {geometry}
606
+ </g>
607
+ )
608
+ }
609
+ return (
610
+ <Smoothed
611
+ key={shape.id}
612
+ x={ghost?.x ?? shape.x}
613
+ y={ghost?.y ?? shape.y}
614
+ >
615
+ {geometry}
616
+ </Smoothed>
617
+ )
618
+ })}
619
+ {cursors.map((cursor) => (
620
+ <Smoothed
621
+ key={cursor.id}
622
+ x={cursor.x}
623
+ y={cursor.y}
624
+ className="canvas-cursor"
625
+ >
626
+ <path
627
+ d="M 0 0 L 0 16 L 4.6 12.3 L 7.2 18 L 9.5 17 L 6.9 11.3 L 12.6 11.3 Z"
628
+ fill={COLOR_HEX[cursor.color]}
629
+ />
630
+ <text x={13} y={21} fill={COLOR_HEX[cursor.color]}>
631
+ {cursor.name}
632
+ </text>
633
+ </Smoothed>
634
+ ))}
635
+ </svg>
636
+ <p className={`canvas-status${live ? '' : ' replaying'}`}>
637
+ <span className="canvas-status-text">
638
+ {live
639
+ ? `Live — ${replayEvents.length} confirmed ${replayEvents.length === 1 ? 'event' : 'events'} received since the snapshot`
640
+ : `Replaying from the server snapshot through ${replayThrough} of ${replayEvents.length} confirmed events.`}
641
+ </span>
642
+ {/* Lives in the fixed-slot status line, hidden-not-removed,
643
+ so neither the slider row nor this line ever reflows. */}
644
+ <button
645
+ className="secondary compact canvas-back-to-live"
646
+ type="button"
647
+ disabled={live}
648
+ onClick={() => setScrub(null)}
649
+ >
650
+ Back to live
651
+ </button>
652
+ </p>
653
+ </div>
654
+
655
+ <div className="canvas-scrubber">
656
+ <input
657
+ type="range"
658
+ min={0}
659
+ max={replayEvents.length}
660
+ value={replayThrough}
661
+ disabled={replayEvents.length === 0}
662
+ onChange={(e) => {
663
+ const value = Number(e.target.value)
664
+ setScrub(value >= replayEvents.length ? null : value)
665
+ }}
666
+ aria-label="Replay events received since the server snapshot"
667
+ />
668
+ <span className="hint">
669
+ {live
670
+ ? `live — ${replayEvents.length} confirmed ${replayEvents.length === 1 ? 'event' : 'events'}`
671
+ : `confirmed event ${replayThrough} of ${replayEvents.length}`}
672
+ </span>
673
+ </div>
674
+ <p className="frontier">
675
+ This page starts from one server-rendered state snapshot, then folds
676
+ each confirmed event the client receives. The slider replays only that
677
+ observed tail.
678
+ </p>
679
+ {error ? <p className="error">{error}</p> : null}
680
+ </article>
681
+ )
682
+ }