@scrawl-board/board 0.1.0-beta.6 → 0.1.0-beta.8

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/dist/browser.d.ts CHANGED
@@ -152,6 +152,21 @@ interface CustomObjectDefinition<Props extends JsonValue = JsonValue> {
152
152
  /** One pure, synchronous step per consecutive schema version. */
153
153
  migrate?: Readonly<Record<number, (oldProps: JsonValue) => JsonValue>>;
154
154
  describe(object: ReadonlyCustomObject<Props>, context: ObjectDescribeContext): BoardScene;
155
+ /**
156
+ * Optional point-level hit-test precision (Phase 8). Every custom object
157
+ * hit-tests against its bounding box (`fallback.bounds`) by default — this
158
+ * lets a non-rectangular shape (e.g. a circular card, an L-shaped region)
159
+ * reject a point that's inside that box but outside its actual visible
160
+ * silhouette, tightening a click/marquee/raycast hit to the shape's real
161
+ * outline. `point` is in this object's own local space — the same
162
+ * untransformed space `describe`'s returned geometry already lives in
163
+ * (the caller inverse-transforms the pointer's board point through
164
+ * `object.transform` before calling this). Absent means every point
165
+ * inside the bounding box hits, matching pre-Phase-8 behavior exactly.
166
+ * Rejecting a point here does not fall through to whatever's underneath —
167
+ * the gesture simply misses this object, same as clicking empty space.
168
+ */
169
+ hitTest?(object: ReadonlyCustomObject<Props>, point: BoardPoint): boolean;
155
170
  }
156
171
  interface SceneNodeBase {
157
172
  key: string;
@@ -185,6 +200,15 @@ interface SceneGroup extends SceneNodeBase {
185
200
  kind: "group";
186
201
  children: readonly BoardScene[];
187
202
  }
203
+ /**
204
+ * **No renderer or SVG-export interpreter exists for this node kind yet**
205
+ * (tracked as deferred work — see `renderer/shapes/customObjects.ts`'s
206
+ * `"path"` case). Returning a `ScenePath` from `describe()` renders nothing,
207
+ * exports nothing, and contributes no hit-test bounds — it neither errors
208
+ * nor emits a diagnostic. Until an interpreter ships, build custom shapes
209
+ * from `SceneRect`/`SceneEllipse`/`SceneGroup`/`SceneText`/`SceneImage`
210
+ * instead.
211
+ */
188
212
  interface ScenePath extends SceneNodeBase {
189
213
  kind: "path";
190
214
  /** SVG-style path data, board-local coordinates. */
@@ -340,8 +364,23 @@ interface Lockable {
340
364
  lockedByName?: string;
341
365
  }
342
366
 
367
+ /**
368
+ * Per-object visibility (Phase 8) — mirrors `itemLock.ts`'s `Lockable`
369
+ * pattern exactly, but simpler: unlike a lock, hidden state carries no
370
+ * holder/ownership concept, so there's no analogue to `LockHolder`/
371
+ * `canUnlockItem`. A hidden object stays fully present in the Document
372
+ * (still serializes, persists, syncs, undoes/redoes) — it just skips
373
+ * rendering and hit-testing/selection candidacy. `hidden` absent or
374
+ * `false` means visible; this keeps every pre-Phase-8 document (which has
375
+ * no `hidden` field on any object at all) implicitly fully visible with
376
+ * zero migration needed.
377
+ */
378
+ interface Hideable {
379
+ hidden?: boolean;
380
+ }
381
+
343
382
  /** A kitchen timer sitting on the board. Remaining time is derived, not ticked. */
344
- interface KitchenTimer extends Lockable {
383
+ interface KitchenTimer extends Lockable, Hideable {
345
384
  id: string;
346
385
  x: number;
347
386
  y: number;
@@ -355,6 +394,133 @@ interface KitchenTimer extends Lockable {
355
394
  runningSince?: number;
356
395
  }
357
396
 
397
+ interface RectangleObject extends Lockable, Hideable {
398
+ id: string;
399
+ x: number;
400
+ y: number;
401
+ width: number;
402
+ height: number;
403
+ fill?: string;
404
+ stroke?: string;
405
+ strokeWidth?: number;
406
+ /** Corner radius in board units; clamped to at most half the shorter side at render time. */
407
+ cornerRadius?: number;
408
+ /** `[0, 1]`; undefined means fully opaque (Phase 4). */
409
+ opacity?: number;
410
+ /**
411
+ * Radians, about the shape's own center `(x + width/2, y - height/2)`.
412
+ * Undefined means 0 (Phase 3). `x`/`y`/`width`/`height` stay in the
413
+ * shape's own unrotated local frame — rotation is a separate, applied-last
414
+ * transform, not baked into them, matching how Stroke/CustomBoardObject
415
+ * keep geometry and placement independent via their own `matrix`.
416
+ */
417
+ rotation?: number;
418
+ }
419
+ interface EllipseObject extends Lockable, Hideable {
420
+ id: string;
421
+ x: number;
422
+ y: number;
423
+ width: number;
424
+ height: number;
425
+ fill?: string;
426
+ stroke?: string;
427
+ strokeWidth?: number;
428
+ /** `[0, 1]`; undefined means fully opaque (Phase 4). */
429
+ opacity?: number;
430
+ /** Radians, about the shape's own center — see RectangleObject's `rotation` doc. */
431
+ rotation?: number;
432
+ }
433
+ /** `"none"` is a plain line with no arrowhead; today's only real head shape is `"triangle"`. New head shapes extend this union without touching `ArrowObject`'s own fields. */
434
+ type ArrowHeadStyle = "triangle" | "none";
435
+ interface LineObject extends Lockable, Hideable {
436
+ id: string;
437
+ start: BoardPoint;
438
+ end: BoardPoint;
439
+ stroke?: string;
440
+ strokeWidth?: number;
441
+ opacity?: number;
442
+ }
443
+ interface ArrowObject extends Lockable, Hideable {
444
+ id: string;
445
+ start: BoardPoint;
446
+ end: BoardPoint;
447
+ head?: ArrowHeadStyle;
448
+ stroke?: string;
449
+ strokeWidth?: number;
450
+ opacity?: number;
451
+ }
452
+ /**
453
+ * Triangle(3)/Diamond(4)/Pentagon(5)/Hexagon(6)/Octagon(8) as one shared
454
+ * type instead of five near-duplicate interfaces — a regular N-gon
455
+ * inscribed in the same `x`/`y`/`width`/`height`/`rotation` bounding box
456
+ * Rectangle already uses, parameterized by `sides`. Diamond is exactly a
457
+ * 4-sided regular polygon with vertex 0 pointing right (not up, like
458
+ * Triangle/Pentagon/Hexagon) — see `polygonGeometry.ts`'s
459
+ * `polygonStartAngle`, which encodes each side count's own vertex
460
+ * orientation so the outline always matches the legacy drag-preview shape.
461
+ */
462
+ interface PolygonObject extends Lockable, Hideable {
463
+ id: string;
464
+ x: number;
465
+ y: number;
466
+ width: number;
467
+ height: number;
468
+ sides: 3 | 4 | 5 | 6 | 8;
469
+ fill?: string;
470
+ stroke?: string;
471
+ strokeWidth?: number;
472
+ opacity?: number;
473
+ /** Radians, about the shape's own center — see RectangleObject's `rotation` doc. */
474
+ rotation?: number;
475
+ }
476
+ /** Same bounding-box/rotation convention as Rectangle; a 5-pointed star with a tuned inner-radius ratio, matching the legacy tool's own default (see `polygonGeometry.ts`'s `starPoints`). */
477
+ interface StarObject extends Lockable, Hideable {
478
+ id: string;
479
+ x: number;
480
+ y: number;
481
+ width: number;
482
+ height: number;
483
+ /** Vertex count; today's only shipped preset is 5, matching the legacy tool. */
484
+ points: number;
485
+ /** `(0, 1)` — inner vertex radius as a fraction of the outer radius. */
486
+ innerRadiusRatio: number;
487
+ fill?: string;
488
+ stroke?: string;
489
+ strokeWidth?: number;
490
+ opacity?: number;
491
+ rotation?: number;
492
+ }
493
+ /** Same bounding-box/rotation convention as Rectangle; the standard parametric heart curve (see `polygonGeometry.ts`'s `heartPoints`), no extra parameters beyond the shared shape fields. */
494
+ interface HeartObject extends Lockable, Hideable {
495
+ id: string;
496
+ x: number;
497
+ y: number;
498
+ width: number;
499
+ height: number;
500
+ fill?: string;
501
+ stroke?: string;
502
+ strokeWidth?: number;
503
+ opacity?: number;
504
+ rotation?: number;
505
+ }
506
+ /**
507
+ * A logical grouping of other board objects (Phase 3 — Selection,
508
+ * Transformation & Grouping). Deliberately has no `x`/`y`/`transform` of its
509
+ * own — a group's bounds are always derived on demand from its (recursively
510
+ * resolved) children, and "moving/rotating/scaling the group" is exactly a
511
+ * multi-object transform applied to those children, nothing more. A group
512
+ * has no renderer/mesh of its own; its only visual presence is the
513
+ * selection gizmo's bounding box while it's the current selection.
514
+ *
515
+ * `children` may itself contain other group ids (nested groups) — expanding
516
+ * a group into its leaf members is always done by the caller (recursively,
517
+ * with cycle protection), never assumed here.
518
+ */
519
+ interface GroupObject extends Lockable, Hideable {
520
+ id: string;
521
+ children: string[];
522
+ }
523
+
358
524
  interface BoardPoint {
359
525
  x: number;
360
526
  y: number;
@@ -375,7 +541,7 @@ interface StrokePoint extends BoardPoint {
375
541
  * geometric outline, not an expressive ink mark.
376
542
  */
377
543
  type StrokeTool = "marker" | "highlighter" | "shape";
378
- interface Stroke extends Lockable {
544
+ interface Stroke extends Lockable, Hideable {
379
545
  id: string;
380
546
  color: string;
381
547
  baseWidth: number;
@@ -391,7 +557,7 @@ interface Stroke extends Lockable {
391
557
  clusterId?: string;
392
558
  }
393
559
  type SerializedPoint = [number, number, number, number];
394
- interface SerializedStroke extends Lockable {
560
+ interface SerializedStroke extends Lockable, Hideable {
395
561
  id: string;
396
562
  color: string;
397
563
  baseWidth: number;
@@ -417,6 +583,31 @@ interface SerializedDocument {
417
583
  timers?: KitchenTimer[];
418
584
  /** Absent in documents saved before Custom board objects existed (ticket #22). */
419
585
  customObjects?: CustomBoardObject[];
586
+ /** Absent in documents saved before semantic Rectangle objects existed (Phase 2). */
587
+ rectangles?: RectangleObject[];
588
+ /** Absent in documents saved before semantic Ellipse objects existed (Phase 2). */
589
+ ellipses?: EllipseObject[];
590
+ /** Absent in documents saved before Groups existed (Phase 3). */
591
+ groups?: GroupObject[];
592
+ /** Absent in documents saved before semantic Line objects existed (Phase 4). */
593
+ lines?: LineObject[];
594
+ /** Absent in documents saved before semantic Arrow objects existed (Phase 4). */
595
+ arrows?: ArrowObject[];
596
+ /** Absent in documents saved before semantic Polygon objects existed (Phase 4). */
597
+ polygons?: PolygonObject[];
598
+ /** Absent in documents saved before semantic Star objects existed (Phase 4). */
599
+ stars?: StarObject[];
600
+ /** Absent in documents saved before semantic Heart objects existed (Phase 4). */
601
+ hearts?: HeartObject[];
602
+ /**
603
+ * Every content-object id (every type above except comments, which are
604
+ * host-synced and never enter this schema) in paint order, back to front.
605
+ * Absent in documents saved before per-object z-order existed (Phase 3) —
606
+ * migration synthesizes a default order preserving the old fixed-Z-band
607
+ * visual stacking exactly, so an existing document never visibly changes
608
+ * on load; only an explicit reorder action touches this from then on.
609
+ */
610
+ objectOrder?: string[];
420
611
  }
421
612
  /**
422
613
  * One collaborator's vote on a note. One per person; toggling removes it.
@@ -430,7 +621,7 @@ interface NoteVote {
430
621
  * A sticky note: content floating above the board at a z-offset (pillar 3 —
431
622
  * depth as an organizational axis). Center position in board space.
432
623
  */
433
- interface StickyNote extends Lockable {
624
+ interface StickyNote extends Lockable, Hideable {
434
625
  id: string;
435
626
  x: number;
436
627
  y: number;
@@ -448,7 +639,7 @@ interface StickyNote extends Lockable {
448
639
  * top-left corner; lines flow downward (-y). Text joins the clustering
449
640
  * system like handwriting (build prompt §6.4).
450
641
  */
451
- interface TextBlock extends Lockable {
642
+ interface TextBlock extends Lockable, Hideable {
452
643
  id: string;
453
644
  x: number;
454
645
  y: number;
@@ -462,7 +653,7 @@ interface TextBlock extends Lockable {
462
653
  * Interactive structured table on the board. Position (x, y) is top-left in board units.
463
654
  * Cells are indexed as `${row},${col}` keys mapping to cell text content.
464
655
  */
465
- interface TableBlock extends Lockable {
656
+ interface TableBlock extends Lockable, Hideable {
466
657
  id: string;
467
658
  x: number;
468
659
  y: number;
@@ -479,7 +670,7 @@ interface TableBlock extends Lockable {
479
670
  * An imported image block on the board plane.
480
671
  * Coordinates (x, y) represent the center of the image in board space.
481
672
  */
482
- interface ImageBlock extends Lockable {
673
+ interface ImageBlock extends Lockable, Hideable {
483
674
  id: string;
484
675
  /**
485
676
  * A legacy, read-only data URL (or, historically, an arbitrary string) —
@@ -616,7 +807,7 @@ interface BoardSnapshot {
616
807
  * here, matching the engine's own internal selection-badge behavior.
617
808
  */
618
809
  interface FocusedItem {
619
- readonly type: "stroke" | "note" | "text" | "table" | "image" | "timer" | "custom";
810
+ readonly type: "stroke" | "note" | "text" | "table" | "image" | "timer" | "rectangle" | "ellipse" | "group" | "line" | "arrow" | "polygon" | "star" | "heart" | "custom";
620
811
  readonly id: string;
621
812
  readonly locked: boolean;
622
813
  readonly lockedBy?: string;
@@ -697,6 +888,8 @@ interface BoardEventMap {
697
888
  "asset-diagnostic": AssetDiagnostic;
698
889
  /** A batch of Ops was reconciled (not applied as-sent) by the persistence adapter (ticket #24). */
699
890
  "persistence-diagnostic": PersistenceDiagnostic;
891
+ /** The collaboration server has confirmed receipt of these op ids (Phase 7) — observability only; the Document was already correct via optimistic local apply before this ever fires. */
892
+ "collaboration-ops-acknowledged": CollaborationAckDiagnostic;
700
893
  audit: unknown;
701
894
  error: BoardControllerError;
702
895
  disposed: undefined;
@@ -760,9 +953,12 @@ interface PresenceView {
760
953
  readonly height: number;
761
954
  }
762
955
  /**
763
- * A Host-owned collaborator, synced in for cursor/roster rendering only.
764
- * Presence is ephemeral — it never touches the Document, Ops, undo/redo,
765
- * or persistence so this is a read/query capability, not an adapter.
956
+ * A collaborator, synced in for cursor/roster rendering only. Presence is
957
+ * ephemeral — it never touches the Document, Ops, undo/redo, or persistence
958
+ * (ADR 0006/0007). Two ways a roster gets populated (`presence.sync`
959
+ * directly, or a `CollaborationAdapter`'s optional presence channel —
960
+ * Phase 6, ADR 0015) both feed the exact same read/query capability below;
961
+ * a Host picks one, not both, for a given controller.
766
962
  */
767
963
  interface PresenceUser {
768
964
  readonly id: string;
@@ -771,6 +967,14 @@ interface PresenceUser {
771
967
  readonly tool?: string;
772
968
  readonly cursor?: PresenceCursor;
773
969
  readonly view?: PresenceView;
970
+ /** Host-supplied extras (avatar URL, role, etc.) — opaque to Scrawl, never interpreted. */
971
+ readonly metadata?: Record<string, unknown>;
972
+ }
973
+ /** This client's own local presence, published via `presence.broadcast()` (Phase 6). */
974
+ interface LocalPresence {
975
+ readonly cursor?: PresenceCursor | null;
976
+ readonly view?: PresenceView | null;
977
+ readonly tool?: string;
774
978
  }
775
979
  /**
776
980
  * The Custom arm wraps `CustomBoardObject` under the same `type` discriminant
@@ -798,6 +1002,22 @@ type BoardObject = ({
798
1002
  } & ImageBlock) | ({
799
1003
  type: "timer";
800
1004
  } & KitchenTimer) | ({
1005
+ type: "rectangle";
1006
+ } & RectangleObject) | ({
1007
+ type: "ellipse";
1008
+ } & EllipseObject) | ({
1009
+ type: "group";
1010
+ } & GroupObject) | ({
1011
+ type: "line";
1012
+ } & LineObject) | ({
1013
+ type: "arrow";
1014
+ } & ArrowObject) | ({
1015
+ type: "polygon";
1016
+ } & PolygonObject) | ({
1017
+ type: "star";
1018
+ } & StarObject) | ({
1019
+ type: "heart";
1020
+ } & HeartObject) | ({
801
1021
  type: "custom";
802
1022
  customType: ObjectType;
803
1023
  } & Omit<CustomBoardObject, "type">);
@@ -826,6 +1046,30 @@ type BoardObjectInput = {
826
1046
  type: "timer";
827
1047
  id?: string;
828
1048
  } & Omit<KitchenTimer, "id">) | ({
1049
+ type: "rectangle";
1050
+ id?: string;
1051
+ } & Omit<RectangleObject, "id">) | ({
1052
+ type: "ellipse";
1053
+ id?: string;
1054
+ } & Omit<EllipseObject, "id">) | ({
1055
+ type: "group";
1056
+ id?: string;
1057
+ } & Omit<GroupObject, "id">) | ({
1058
+ type: "line";
1059
+ id?: string;
1060
+ } & Omit<LineObject, "id">) | ({
1061
+ type: "arrow";
1062
+ id?: string;
1063
+ } & Omit<ArrowObject, "id">) | ({
1064
+ type: "polygon";
1065
+ id?: string;
1066
+ } & Omit<PolygonObject, "id">) | ({
1067
+ type: "star";
1068
+ id?: string;
1069
+ } & Omit<StarObject, "id">) | ({
1070
+ type: "heart";
1071
+ id?: string;
1072
+ } & Omit<HeartObject, "id">) | ({
829
1073
  type: "custom";
830
1074
  id?: string;
831
1075
  customType: ObjectType;
@@ -856,10 +1100,34 @@ type LoadResult = {
856
1100
  } | {
857
1101
  state: "missing";
858
1102
  };
1103
+ /**
1104
+ * Result of a whole-document `PersistenceAdapter.replace()` call (ADR 0006:
1105
+ * "Whole-document writes survive only for create, clear-board and import,
1106
+ * where replacing everything is the actual intent"). Revision-gated, unlike
1107
+ * `applyOps` — `conflict` means `baseRevision` was stale (someone else's
1108
+ * write landed first); the caller must reload and never overwrites blind.
1109
+ */
1110
+ type ReplaceResult = {
1111
+ state: "applied";
1112
+ revision: string;
1113
+ } | {
1114
+ state: "conflict";
1115
+ currentRevision: string;
1116
+ };
1117
+ /**
1118
+ * The one sanctioned seam for persisting a Board's Document to a Host's own
1119
+ * storage — implement this against a database, an HTTP API, IndexedDB
1120
+ * (see `@scrawl-board/board/local`'s `createIndexedDBPersistence`), or
1121
+ * anything else. `load()` fetches the current state on connect; `applyOps()`
1122
+ * streams incremental Ops as edits happen; `replace()` is only for
1123
+ * whole-document writes (create, clear-board, import — see ADR 0006) and is
1124
+ * revision-gated so a stale write never silently clobbers a newer one.
1125
+ * Passed via `createBoardController({ adapters: { persistence } })`.
1126
+ */
859
1127
  interface PersistenceAdapter {
860
1128
  load(context: DocumentContext): Promise<LoadResult>;
861
1129
  applyOps(context: DocumentContext, ops: readonly ControllerOp[]): Promise<ApplyOpsResult>;
862
- replace(context: DocumentContext, document: CurrentSerializedDocument, baseRevision: string): Promise<unknown>;
1130
+ replace(context: DocumentContext, document: CurrentSerializedDocument, baseRevision: string): Promise<ReplaceResult>;
863
1131
  }
864
1132
  /**
865
1133
  * `"reconcile"` (ticket #24) means the server authoritatively resolved the
@@ -883,14 +1151,59 @@ interface PersistenceDiagnostic {
883
1151
  rejectedOpIds: readonly string[];
884
1152
  revision: string;
885
1153
  }
1154
+ /** Emitted as `"collaboration-ops-acknowledged"` (Phase 7) — the server has confirmed receipt of these op ids on the live pipe. */
1155
+ interface CollaborationAckDiagnostic {
1156
+ opIds: readonly string[];
1157
+ }
886
1158
  interface ControllerOp {
887
1159
  id: string;
888
1160
  schemaVersion: 1;
889
1161
  kind: "upsert" | "restore" | "remove";
890
- objectType: "stroke" | "note" | "text" | "table" | "image" | "timer" | "custom";
1162
+ objectType: "stroke" | "note" | "text" | "table" | "image" | "timer" | "rectangle" | "ellipse" | "group" | "line" | "arrow" | "polygon" | "star" | "heart" | "custom"
1163
+ /**
1164
+ * A whole-document paint-order sync (Phase 3), not a per-object type —
1165
+ * `objectId` is always the fixed sentinel `"order"` and `payload` is
1166
+ * `{ order: string[] }`. The only `objectType` with no matching
1167
+ * `BoardObject`/document collection; kept in this same union (rather
1168
+ * than a separate wire message) so it flows through the existing
1169
+ * `PersistenceAdapter`/`CollaborationAdapter` opaquely, unchanged.
1170
+ */
1171
+ | "order";
891
1172
  objectId: string;
892
1173
  payload?: unknown;
1174
+ /**
1175
+ * This op's position in its own originating client's local sequence
1176
+ * (Phase 7) — 1, 2, 3, ... per controller instance, distinct from `id`
1177
+ * (an opaque, globally-unique identifier used for dedup/ack, not
1178
+ * ordering) and from a server's own authoritative ordering (e.g.
1179
+ * `referenceCollaborationServer.ts`'s per-room `version` counter).
1180
+ * Present on every op this SDK originates locally; a remote peer's op
1181
+ * carries whatever its own origin set, unchanged — never renumbered in
1182
+ * transit. Absent on an op minted by decoding the legacy wire envelope
1183
+ * (`scrawlOpEnvelope.ts`), which predates this field and has no
1184
+ * per-client sequence concept of its own.
1185
+ */
1186
+ clientSequence?: number;
1187
+ /**
1188
+ * The `CollaboratorIdentity.id` of this op's originating client (Phase
1189
+ * 7) — set for every op this SDK originates locally when `identity` is
1190
+ * configured, omitted entirely otherwise (never sent as `undefined`).
1191
+ * The explicit foundation for a future per-author undo filter (a local
1192
+ * user's own undo should only ever touch their own ops) — no undo-stack
1193
+ * behavior itself changes this phase.
1194
+ */
1195
+ clientId?: string;
893
1196
  }
1197
+ /**
1198
+ * The one sanctioned seam for real-time multiplayer — implement this against
1199
+ * a Host's own collaboration backend (WebSocket relay, CRDT server, etc.).
1200
+ * `connect()` is called once per controller with the local user's
1201
+ * `identity` and a `receive` callback the adapter invokes with incoming
1202
+ * Ops, presence updates, acks, and connection status; it resolves with a
1203
+ * `CollaborationSession` the controller uses to send local Ops and presence
1204
+ * back out. Passed via `createBoardController({ adapters: { collaboration } })`;
1205
+ * omit it entirely to run single-player.
1206
+ */
894
1207
  interface CollaborationAdapter {
895
1208
  connect(options: DocumentContext & {
896
1209
  identity: CollaboratorIdentity;
@@ -901,14 +1214,72 @@ interface CollaboratorIdentity {
901
1214
  id: string;
902
1215
  name: string;
903
1216
  color?: string;
1217
+ /** Host-supplied extras (avatar URL, role, etc.) — opaque to Scrawl, forwarded into any resulting `PresenceUser` unread and never interpreted. */
1218
+ metadata?: Record<string, unknown>;
904
1219
  }
905
1220
  interface CollaborationReceiver {
906
1221
  ops(ops: readonly ControllerOp[]): void;
1222
+ /**
1223
+ * The current presence roster (Phase 6, ADR 0015) — always a full
1224
+ * replacement, never a delta, matching `presence.sync`'s existing
1225
+ * semantics exactly (an adapter that aggregates wire deltas into a full
1226
+ * roster before calling this is the adapter's own job, not the
1227
+ * controller's). Required on this interface (not optional) because a
1228
+ * Host only ever *consumes* `CollaborationReceiver` — never implements
1229
+ * it — so adding a required method here cannot break an existing custom
1230
+ * `CollaborationAdapter`. An adapter with no presence support simply
1231
+ * never calls it.
1232
+ */
1233
+ presence(users: readonly PresenceUser[]): void;
1234
+ /**
1235
+ * The server has confirmed receipt of these op ids (Phase 7) —
1236
+ * distinguishes "sent" from "server accepted," which `sendOps` alone
1237
+ * (fire-and-forget) cannot. Required for the same reason `presence` is:
1238
+ * Hosts only ever consume this interface, never implement it, so this
1239
+ * cannot break an existing custom `CollaborationAdapter`. An adapter with
1240
+ * no ack support simply never calls it — the collaboration pipe still
1241
+ * works exactly as it did before this existed, just without the
1242
+ * bookkeeping/observability this enables.
1243
+ */
1244
+ acknowledged(opIds: readonly string[]): void;
907
1245
  status(state: "online" | "reconnecting" | "offline"): void;
908
1246
  error(cause: unknown): void;
909
1247
  }
1248
+ /**
1249
+ * Result of `CollaborationSession.requestSync()` (Phase 7). `"ops"` means
1250
+ * the adapter's own live-pipe cache fully covered the gap since the
1251
+ * caller's last known revision — apply `ops` and the client is caught up,
1252
+ * no persistence reload needed. `"unavailable"` means it couldn't (gap too
1253
+ * large, server restarted, or the adapter has no retained history at all)
1254
+ * — the caller must fall back to a persistence-backed reload. This is a
1255
+ * best-effort *liveness* cache, deliberately never a durable source of
1256
+ * truth (ADR 0006's "collaboration is never a second source of document
1257
+ * truth" — see ADR 0015's own extension of that principle to presence,
1258
+ * now extended once more, the same way, to this).
1259
+ */
1260
+ type CollaborationSyncResult = {
1261
+ state: "ops";
1262
+ ops: readonly ControllerOp[];
1263
+ serverRevision: string;
1264
+ } | {
1265
+ state: "unavailable";
1266
+ };
910
1267
  interface CollaborationSession {
911
1268
  sendOps(ops: readonly ControllerOp[]): void;
1269
+ /**
1270
+ * Publishes this client's own local presence (Phase 6, ADR 0015) —
1271
+ * best-effort, unordered, never persisted, never an Op. Optional: an
1272
+ * adapter that doesn't support presence simply omits this method, and
1273
+ * `presence.broadcast()` becomes a silent no-op.
1274
+ */
1275
+ updatePresence?(presence: LocalPresence): void;
1276
+ /**
1277
+ * Requests an incremental catch-up after a reconnect (Phase 7) — optional;
1278
+ * an adapter that doesn't support this simply omits the method, and the
1279
+ * caller (`resyncAfterReconnect`) goes straight to its existing
1280
+ * persistence-backed full reload, unchanged from Phase 6.
1281
+ */
1282
+ requestSync?(): Promise<CollaborationSyncResult>;
912
1283
  close(): Promise<void>;
913
1284
  }
914
1285
  interface CreateBoardControllerOptions {
@@ -948,6 +1319,87 @@ interface CreateBoardControllerOptions {
948
1319
  * doesn't use the React theme system can set this directly instead.
949
1320
  */
950
1321
  boardTheme?: BoardThemeOptions;
1322
+ /**
1323
+ * Debounced auto-flush of pending persistence Ops after document changes
1324
+ * settle (Phase 5). Enabled by default (1000ms debounce) whenever
1325
+ * `adapters.persistence` is configured — today, without this, a Host must
1326
+ * call `flush()` manually after every edit for anything to persist. Pass
1327
+ * `false` to opt out entirely and drive `flush()` yourself, preserving
1328
+ * prior behavior exactly. Never fires on a per-change basis — rapid edits
1329
+ * coalesce into one flush of their final state (ADR 0006).
1330
+ */
1331
+ autosave?: boolean | {
1332
+ debounceMs?: number;
1333
+ };
1334
+ /**
1335
+ * Throttle for `presence.broadcast()` (Phase 6, ADR 0015) — the minimum
1336
+ * interval between outgoing presence updates sent via the configured
1337
+ * `CollaborationAdapter`. Defaults to 50ms. A trailing throttle: the
1338
+ * latest value passed to `broadcast()` always eventually sends, even if
1339
+ * calls arrive faster than this interval.
1340
+ */
1341
+ presenceThrottleMs?: number;
1342
+ /**
1343
+ * Caps how many `ControllerOp`s can sit queued, unsent, for the
1344
+ * persistence pipe (`pendingOps`) or the collaboration pipe
1345
+ * (`pendingCollaborationOps`) at once (Phase 7) — each pipe is capped
1346
+ * independently. Prevents unbounded memory growth from a long-lived
1347
+ * offline session or a stuck adapter. Exceeding it never fails or drops
1348
+ * the local edit itself (the Document already applied it optimistically)
1349
+ * — only queueing for that one pipe is skipped, and a
1350
+ * `{code:"queue-overflow", retryable:false}` error is emitted so a Host
1351
+ * can react. Defaults to 1000 — the Phase 9 collaboration coalescing
1352
+ * above (`collaborationCoalesceMs`) already keeps a busy drag from
1353
+ * approaching this on its own, so hitting it in practice means a pipe
1354
+ * has been offline/stuck for a genuinely long editing session.
1355
+ *
1356
+ * **Recovery** (Phase 9): the dropped op itself is gone from that one
1357
+ * pipe's queue — there is no automatic backfill, and the live
1358
+ * controller keeps running with that pipe now silently missing one
1359
+ * edit. Two things stay true regardless: (1) the in-memory Document is
1360
+ * never affected — a queue-overflow can never corrupt or roll back a
1361
+ * local edit, only skip sending it; (2) staleness is per-object, not
1362
+ * permanent — any *later* edit to that same object produces a brand
1363
+ * new, undropped Op carrying its full current state, which naturally
1364
+ * supersedes the gap (the Op model is already last-write-wins/
1365
+ * idempotent, so a superseding Op doesn't need the earlier one to have
1366
+ * arrived). The real risk is an object that's dropped and never edited
1367
+ * again before the controller is disposed or the page reloads — a Host
1368
+ * that needs strict durability should treat `queue-overflow` as a
1369
+ * signal to check `persistence.state`/`pendingOps` pressure (via
1370
+ * `usePersistenceStatus`/`getSnapshot().connection.persistence`)
1371
+ * before disposing, not assume disposing and reconnecting alone
1372
+ * repairs the gap (a fresh `load()` only returns what the backend
1373
+ * already has, which is exactly what's missing the dropped edit).
1374
+ */
1375
+ maxPendingOps?: number;
1376
+ /**
1377
+ * Coalescing window (ms) for outgoing collaboration Ops (Phase 9) — same
1378
+ * trailing-throttle shape as `presenceThrottleMs`: the first Op after an
1379
+ * idle period sends immediately, and subsequent Ops for the *same*
1380
+ * object within this window replace each other (latest value wins,
1381
+ * matching the already-idempotent Op model) rather than each triggering
1382
+ * its own send. A multi-second drag that previously sent one full Op per
1383
+ * pointer-move now sends at most one per window per touched object.
1384
+ * Persistence (`pendingOps`) is unaffected — it already debounces via
1385
+ * `autosave`, so this option only changes live collaboration traffic.
1386
+ * Defaults to 50ms.
1387
+ */
1388
+ collaborationCoalesceMs?: number;
1389
+ /**
1390
+ * Caps how many resolved objects a single `content.copy`/`content.cut`
1391
+ * (or their Cmd/Ctrl+C/X keyboard equivalents) will hold in the
1392
+ * in-memory clipboard at once (Phase 9) — `expandSelection` recursively
1393
+ * expands groups, so an unbounded selection (a huge group, or thousands
1394
+ * of individually selected strokes) could otherwise clone and retain an
1395
+ * arbitrarily large snapshot indefinitely, until the next copy/cut
1396
+ * replaces it. Exceeding it rejects the whole copy/cut (nothing is
1397
+ * cloned, and — for cut — nothing is removed from the Document either,
1398
+ * never a partial copy of an arbitrary subset) and emits a
1399
+ * `{code:"clipboard-overflow", retryable:false}` error. Defaults to
1400
+ * 5000.
1401
+ */
1402
+ maxClipboardItems?: number;
951
1403
  }
952
1404
  interface BoardController {
953
1405
  readonly document: ReadonlyBoardDocument;
@@ -979,6 +1431,15 @@ interface BoardController {
979
1431
  };
980
1432
  readonly view: {
981
1433
  fit(): void;
1434
+ /**
1435
+ * Frame the current selection (Phase 8), the same way `fit()` frames the
1436
+ * whole board. A no-op with nothing selected — deliberately doesn't fall
1437
+ * back to `fit()`'s "frame everything," which would be a surprising
1438
+ * result for an empty selection. On a headless board this can only
1439
+ * re-center the view (no viewport to compute a real zoom-to-fit from),
1440
+ * matching `fit()`'s own headless limitation exactly.
1441
+ */
1442
+ zoomToSelection(): void;
982
1443
  zoomTo(value: number): void;
983
1444
  centerOn(point: BoardPoint): void;
984
1445
  get(): BoardView;
@@ -1000,6 +1461,64 @@ interface BoardController {
1000
1461
  * Unknown ids are silently skipped, matching `remove`'s convention.
1001
1462
  */
1002
1463
  duplicate(ids: readonly string[]): readonly string[];
1464
+ /**
1465
+ * Creates a new Group referencing `ids` as its children and returns its
1466
+ * id, as one undoable step. Unknown ids are silently skipped, matching
1467
+ * `duplicate`/`remove`'s convention. A child id that's itself a group
1468
+ * makes a nested group — expanding nested groups into their leaf
1469
+ * members is always the caller's job, never assumed here (matches the
1470
+ * document-model `GroupObject` itself).
1471
+ */
1472
+ group(ids: readonly string[]): string;
1473
+ /**
1474
+ * Dissolves one group, returning its immediate children's ids (a nested
1475
+ * subgroup among them stays intact, itself still a group) — the group
1476
+ * record itself is removed, the children are untouched. A no-op
1477
+ * (returns `[]`) if `groupId` isn't a group.
1478
+ */
1479
+ ungroup(groupId: string): readonly string[];
1480
+ /**
1481
+ * Aligns every given object's matching edge/center to the corresponding
1482
+ * edge/center of their combined bounding box, as one undoable step.
1483
+ * `"top"`/`"bottom"` follow board space's Y-up convention (`"top"` is
1484
+ * the larger Y). Ids that don't resolve, or resolve to a Group (which
1485
+ * has no position of its own), are skipped. A no-op under 2 resolvable
1486
+ * ids — there's nothing to align relative to.
1487
+ */
1488
+ align(ids: readonly string[], edge: "left" | "right" | "top" | "bottom" | "centerX" | "centerY"): void;
1489
+ /**
1490
+ * Spaces the middle objects' centers evenly between the first and last
1491
+ * (sorted along `axis`), as one undoable step — the two endpoints don't
1492
+ * move. Ids that don't resolve, or resolve to a Group, are skipped. A
1493
+ * no-op under 3 resolvable ids — there's no "middle" to distribute.
1494
+ */
1495
+ distribute(ids: readonly string[], axis: "x" | "y"): void;
1496
+ /**
1497
+ * Snapshots `ids` (recursively expanded through any group, same as
1498
+ * `duplicate`) into an internal in-memory clipboard — never
1499
+ * `navigator.clipboard`, scoped to this one controller instance and
1500
+ * replaced wholesale by the next `copy`/`cut`. Read-only; works even
1501
+ * on a read-only board.
1502
+ */
1503
+ copy(ids: readonly string[]): void;
1504
+ /** `copy`, then removes every resolved object (recursively through any group) as one undoable step. */
1505
+ cut(ids: readonly string[]): void;
1506
+ /**
1507
+ * Clones the current clipboard contents onto the board as one undoable
1508
+ * step, offset the same small cascade `duplicate` uses (no cursor
1509
+ * position to paste relative to yet). Returns the new top-level ids —
1510
+ * a pasted group's own id stands for its (also-pasted) children, which
1511
+ * aren't listed separately. `[]` when the clipboard is empty.
1512
+ */
1513
+ paste(): readonly string[];
1514
+ /**
1515
+ * Select every top-level object (Phase 8) — a group's own id stands for
1516
+ * its children, which aren't selected separately, matching `paste`'s own
1517
+ * "what the user sees" id list. Hidden objects are excluded, consistent
1518
+ * with them already being excluded from marquee selection. Works with
1519
+ * no canvas/engine, same as {@link toggleSelectionVisibility}.
1520
+ */
1521
+ selectAll(): void;
1003
1522
  table: {
1004
1523
  addRow(tableId: string): void;
1005
1524
  addCol(tableId: string): void;
@@ -1010,6 +1529,29 @@ interface BoardController {
1010
1529
  };
1011
1530
  select(ids: readonly string[]): void;
1012
1531
  import(document: SerializedBoardDocument): readonly string[];
1532
+ /**
1533
+ * Toggle lock state for the current selection (or focused note/text/
1534
+ * table/image/timer), matching whatever a single Host lock/unlock
1535
+ * control already does per object type. A no-op with nothing selected,
1536
+ * on a headless board, or when every actionable target is locked by
1537
+ * another collaborator who isn't the current lock holder.
1538
+ */
1539
+ toggleSelectionLock(): void;
1540
+ /**
1541
+ * Toggle hidden state for the current selection, as one undo entry
1542
+ * (Phase 8). If any selected object is hidden, shows every selected
1543
+ * object; otherwise hides them all — same "any wins" semantics as
1544
+ * {@link toggleSelectionLock}. Hidden objects stay fully present in the
1545
+ * document (they still serialize, persist, sync, undo/redo) — they just
1546
+ * stop rendering and stop being hit-testable/selectable via pointer
1547
+ * interaction. Unlike `toggleSelectionLock`, this works on a headless
1548
+ * board too: it only touches `selection`/the document, no canvas or
1549
+ * engine involved. Custom objects have no visibility concept (no
1550
+ * `Hideable` field) and are silently skipped, matching how
1551
+ * `toggleSelectionLock` already excludes them. A no-op with nothing
1552
+ * selected or when the selection is only custom objects.
1553
+ */
1554
+ toggleSelectionVisibility(): void;
1013
1555
  };
1014
1556
  readonly query: {
1015
1557
  get(id: string): DeepReadonly<BoardObject> | undefined;
@@ -1038,6 +1580,15 @@ interface BoardController {
1038
1580
  follow(view: PresenceView): void;
1039
1581
  /** Ease the camera to a peer's view; returns false (no-op) while mid-stroke. */
1040
1582
  gather(view: PresenceView): boolean;
1583
+ /**
1584
+ * Publishes this client's own cursor/tool/view for other collaborators
1585
+ * (Phase 6, ADR 0015), via the configured `CollaborationAdapter` —
1586
+ * throttled internally (`presenceThrottleMs` option, default 50ms) so
1587
+ * a raw pointermove stream never becomes a message-per-event flood. A
1588
+ * no-op if no collaboration adapter is configured, or if the
1589
+ * configured one doesn't implement `updatePresence`.
1590
+ */
1591
+ broadcast(local: LocalPresence): void;
1041
1592
  };
1042
1593
  readonly export: {
1043
1594
  svg(): string;
@@ -1063,7 +1614,19 @@ interface BoardController {
1063
1614
  }>;
1064
1615
  dispose(): Promise<void>;
1065
1616
  }
1617
+ /**
1618
+ * Creates a {@link BoardController} — the SDK's canonical, capability-grouped
1619
+ * entry point (`content`, `tools`, `style`, `history`, `view`, `query`,
1620
+ * `comments`, `presence`, `export`, `assets`, plus top-level `getSnapshot`/
1621
+ * `subscribe`/`on`/`setReadOnly`/`flush`/`dispose`) for driving a Board
1622
+ * imperatively from any JS/TS runtime. Supply a `canvas` to render, or omit
1623
+ * it to run headless (SSR, tests, or a document/history-only integration).
1624
+ * Persistence and Collaboration are opt-in via `options.adapters` — without
1625
+ * them the controller runs entirely in memory. Call `dispose()` when done to
1626
+ * release the renderer, adapters, and any pending timers.
1627
+ */
1066
1628
  declare function createBoardController(options: CreateBoardControllerOptions): BoardController;
1629
+ /** @deprecated Use `BoardController.getSnapshot()`'s return type instead. */
1067
1630
  type LocalBoardSnapshot = {
1068
1631
  documentId: string;
1069
1632
  selectedStrokeId: string | null;
@@ -1072,10 +1635,12 @@ type LocalBoardSnapshot = {
1072
1635
  canRedo: boolean;
1073
1636
  disposed: boolean;
1074
1637
  };
1638
+ /** @deprecated Use `CreateBoardControllerOptions` with `createBoardController` instead. */
1075
1639
  type LocalBoardOptions = {
1076
1640
  documentId: string;
1077
1641
  initialDocument?: SerializedBoardDocument;
1078
1642
  };
1643
+ /** @deprecated Use `BoardController` (from `createBoardController`) instead — this stroke-only, single-tool surface predates the full capability-grouped controller. */
1079
1644
  type LocalBoard = {
1080
1645
  drawStroke(stroke: Stroke): void;
1081
1646
  selectAt(point: BoardPoint): string | null;
@@ -1086,7 +1651,20 @@ type LocalBoard = {
1086
1651
  subscribe(listener: () => void): () => void;
1087
1652
  dispose(): Promise<void>;
1088
1653
  };
1654
+ /** @deprecated Use `createBoardController` instead — this is a thin, stroke-only wrapper kept for the original Phase 1 tracer's compatibility. */
1089
1655
  declare function createLocalBoard(options: LocalBoardOptions): LocalBoard;
1090
1656
 
1091
- export { AssetResolutionError, STAMPS, SUPPORTED_ASSET_MEDIA_TYPES, assetRef, clampAssetCacheBytes, cloneCustomObject, createBoardController, createLocalBoard, isAssetRef, isStampKind, stampDataUrl };
1092
- export type { ApplyOpsResult, AssetDiagnostic, AssetIngestRequest, AssetIngestResult, AssetIngestor, AssetKind, AssetPurpose, AssetRef, AssetResolutionErrorCode, AssetResolveRequest, AssetResolveResult, AssetResolver, BoardController, BoardControllerError, BoardEventMap, BoardKeyInput, BoardObject, BoardObjectInput, BoardObjectPatch, BoardPointerInput, BoardScene, BoardSnapshot, BoardStyle, BoardThemeOptions, BoardView, BuiltInTool, CollaborationAdapter, CollaborationReceiver, CollaborationSession, CollaborationSnapshot, CollaboratorIdentity, CommentMarker, ControllerOp, CreateBoardControllerOptions, CustomBoardObject, CustomObjectAddInput, CustomObjectDefinition, CustomTool, CustomToolDefinition, DeepReadonly, DocumentContext, ExtensionCommand, ExtensionDiagnostic, ExtensionHitResult, ExtensionId, ExtensionRequirement, FocusedItem, InputModifiers, JsonObject, JsonValue, LoadResult, LocalBoard, LocalBoardOptions, LocalBoardSnapshot, Mat2x3, ObjectDescribeContext, ObjectIntent, ObjectType, PersistenceAdapter, PersistenceDiagnostic, PersistenceSnapshot, PresenceCursor, PresenceUser, PresenceView, QueryableBoardObject, ReadonlyBoardDocument, ReadonlyCustomObject, ReadonlyDocumentChange, SceneEllipse, SceneGroup, SceneImage, ScenePath, SceneRect, SceneText, ScrawlExtension, ScreenPoint, ScreenRect, StampKind, SupportedAssetMediaType, ToolCancelReason, ToolCapabilities, ToolCursor, ToolId };
1657
+ interface CreateMemoryPersistenceOptions {
1658
+ /** Pre-seeded documents, keyed by document id as if a prior session had already saved them. Seeded documents start at revision "1". */
1659
+ seed?: Record<string, CurrentSerializedDocument>;
1660
+ }
1661
+ /**
1662
+ * Creates a real, in-memory `PersistenceAdapter`. One instance can back
1663
+ * multiple documents (keyed by `DocumentContext.documentId`, like every
1664
+ * other adapter in this package). State lives only in this instance —
1665
+ * discarded on garbage collection, never written to disk.
1666
+ */
1667
+ declare function createMemoryPersistence(options?: CreateMemoryPersistenceOptions): PersistenceAdapter;
1668
+
1669
+ export { AssetResolutionError, STAMPS, SUPPORTED_ASSET_MEDIA_TYPES, assetRef, clampAssetCacheBytes, cloneCustomObject, createBoardController, createLocalBoard, createMemoryPersistence, isAssetRef, isStampKind, stampDataUrl };
1670
+ export type { ApplyOpsResult, AssetDiagnostic, AssetExportFailure, AssetIngestRequest, AssetIngestResult, AssetIngestor, AssetKind, AssetPurpose, AssetRef, AssetResolutionErrorCode, AssetResolveRequest, AssetResolveResult, AssetResolver, BoardController, BoardControllerError, BoardEventMap, BoardKeyInput, BoardObject, BoardObjectInput, BoardObjectPatch, BoardPointerInput, BoardScene, BoardSnapshot, BoardStyle, BoardThemeOptions, BoardView, BuiltInTool, CollaborationAckDiagnostic, CollaborationAdapter, CollaborationReceiver, CollaborationSession, CollaborationSnapshot, CollaborationSyncResult, CollaboratorIdentity, CommentMarker, ControllerOp, CreateBoardControllerOptions, CreateMemoryPersistenceOptions, CustomBoardObject, CustomObjectAddInput, CustomObjectDefinition, CustomTool, CustomToolDefinition, DeepReadonly, DocumentContext, ExportDocumentSVGOptions, ExportDocumentSVGResult, ExtensionCommand, ExtensionDiagnostic, ExtensionHitResult, ExtensionId, ExtensionRequirement, FocusedItem, InputModifiers, JsonObject, JsonValue, LoadResult, LocalBoard, LocalBoardOptions, LocalBoardSnapshot, LocalPresence, Mat2x3, ObjectDescribeContext, ObjectIntent, ObjectType, PersistenceAdapter, PersistenceDiagnostic, PersistenceSnapshot, PresenceCursor, PresenceUser, PresenceView, QueryableBoardObject, ReadonlyBoardDocument, ReadonlyCustomObject, ReadonlyDocumentChange, ReplaceResult, SceneEllipse, SceneGroup, SceneImage, ScenePath, SceneRect, SceneText, ScrawlExtension, ScreenPoint, ScreenRect, SearchHit, SearchHitKind, SearchableComment, StampKind, SupportedAssetMediaType, ToolCancelReason, ToolCapabilities, ToolCursor, ToolId };