@scrawl-board/board 0.1.0-beta.1 → 0.1.0-beta.11

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.
@@ -0,0 +1,518 @@
1
+ /** Wire grammar: `asset:<namespace>:<opaque-id>`. Interpreted only by the Host. */
2
+ type AssetRef = string;
3
+
4
+ type Mat2x3 = [number, number, number, number, number, number];
5
+
6
+ /** A stable namespaced string, e.g. `com.acme.kanban/card`. */
7
+ type ObjectType = string;
8
+ type JsonValue = null | boolean | number | string | readonly JsonValue[] | {
9
+ readonly [key: string]: JsonValue;
10
+ };
11
+ interface CustomBoardObject {
12
+ id: string;
13
+ type: ObjectType;
14
+ schemaVersion: number;
15
+ transform: Mat2x3;
16
+ /** Safe placeholder geometry, refreshed by the SDK on every valid command. */
17
+ fallback: {
18
+ bounds: {
19
+ x: number;
20
+ y: number;
21
+ width: number;
22
+ height: number;
23
+ };
24
+ label?: string;
25
+ };
26
+ lock?: {
27
+ holderId: string;
28
+ acquiredAt: number;
29
+ };
30
+ props: JsonValue;
31
+ }
32
+
33
+ interface Lockable {
34
+ locked?: boolean;
35
+ lockedBy?: string;
36
+ lockedByName?: string;
37
+ }
38
+
39
+ /**
40
+ * Per-object visibility (Phase 8) — mirrors `itemLock.ts`'s `Lockable`
41
+ * pattern exactly, but simpler: unlike a lock, hidden state carries no
42
+ * holder/ownership concept, so there's no analogue to `LockHolder`/
43
+ * `canUnlockItem`. A hidden object stays fully present in the Document
44
+ * (still serializes, persists, syncs, undoes/redoes) — it just skips
45
+ * rendering and hit-testing/selection candidacy. `hidden` absent or
46
+ * `false` means visible; this keeps every pre-Phase-8 document (which has
47
+ * no `hidden` field on any object at all) implicitly fully visible with
48
+ * zero migration needed.
49
+ */
50
+ interface Hideable {
51
+ hidden?: boolean;
52
+ }
53
+
54
+ /** A kitchen timer sitting on the board. Remaining time is derived, not ticked. */
55
+ interface KitchenTimer extends Lockable, Hideable {
56
+ id: string;
57
+ x: number;
58
+ y: number;
59
+ /** Face diameter in board units. */
60
+ size: number;
61
+ /** What you set it to — 1, 5, 10, 15 minutes. */
62
+ durationMs: number;
63
+ /** Remaining at the last start or pause. */
64
+ remainingMs: number;
65
+ /** Wall-clock ms when the current run started. Absent means paused. */
66
+ runningSince?: number;
67
+ }
68
+
69
+ interface RectangleObject extends Lockable, Hideable {
70
+ id: string;
71
+ x: number;
72
+ y: number;
73
+ width: number;
74
+ height: number;
75
+ fill?: string;
76
+ stroke?: string;
77
+ strokeWidth?: number;
78
+ /** Corner radius in board units; clamped to at most half the shorter side at render time. */
79
+ cornerRadius?: number;
80
+ /** `[0, 1]`; undefined means fully opaque (Phase 4). */
81
+ opacity?: number;
82
+ /**
83
+ * Radians, about the shape's own center `(x + width/2, y - height/2)`.
84
+ * Undefined means 0 (Phase 3). `x`/`y`/`width`/`height` stay in the
85
+ * shape's own unrotated local frame — rotation is a separate, applied-last
86
+ * transform, not baked into them, matching how Stroke/CustomBoardObject
87
+ * keep geometry and placement independent via their own `matrix`.
88
+ */
89
+ rotation?: number;
90
+ }
91
+ interface EllipseObject extends Lockable, Hideable {
92
+ id: string;
93
+ x: number;
94
+ y: number;
95
+ width: number;
96
+ height: number;
97
+ fill?: string;
98
+ stroke?: string;
99
+ strokeWidth?: number;
100
+ /** `[0, 1]`; undefined means fully opaque (Phase 4). */
101
+ opacity?: number;
102
+ /** Radians, about the shape's own center — see RectangleObject's `rotation` doc. */
103
+ rotation?: number;
104
+ }
105
+ /** `"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. */
106
+ type ArrowHeadStyle = "triangle" | "none";
107
+ interface LineObject extends Lockable, Hideable {
108
+ id: string;
109
+ start: BoardPoint;
110
+ end: BoardPoint;
111
+ stroke?: string;
112
+ strokeWidth?: number;
113
+ opacity?: number;
114
+ }
115
+ /** An endpoint anchored to a normalized position on another object's bounds. */
116
+ interface ConnectorBinding {
117
+ objectId: string;
118
+ /** Horizontal and vertical fractions of the target's axis-aligned bounds. */
119
+ x: number;
120
+ y: number;
121
+ }
122
+ interface ArrowObject extends Lockable, Hideable {
123
+ /** Omitted for legacy arrows. Connectors share Arrow's persistence/history contract. */
124
+ routing?: "straight" | "curved" | "polyline";
125
+ /** Intermediate board-space vertices for a multi-point connector. */
126
+ waypoints?: BoardPoint[];
127
+ startBinding?: ConnectorBinding;
128
+ endBinding?: ConnectorBinding;
129
+ id: string;
130
+ start: BoardPoint;
131
+ end: BoardPoint;
132
+ head?: ArrowHeadStyle;
133
+ stroke?: string;
134
+ strokeWidth?: number;
135
+ opacity?: number;
136
+ }
137
+ /**
138
+ * Triangle(3)/Diamond(4)/Pentagon(5)/Hexagon(6)/Octagon(8) as one shared
139
+ * type instead of five near-duplicate interfaces — a regular N-gon
140
+ * inscribed in the same `x`/`y`/`width`/`height`/`rotation` bounding box
141
+ * Rectangle already uses, parameterized by `sides`. Diamond is exactly a
142
+ * 4-sided regular polygon with vertex 0 pointing right (not up, like
143
+ * Triangle/Pentagon/Hexagon) — see `polygonGeometry.ts`'s
144
+ * `polygonStartAngle`, which encodes each side count's own vertex
145
+ * orientation so the outline always matches the legacy drag-preview shape.
146
+ */
147
+ interface PolygonObject extends Lockable, Hideable {
148
+ id: string;
149
+ x: number;
150
+ y: number;
151
+ width: number;
152
+ height: number;
153
+ sides: 3 | 4 | 5 | 6 | 8;
154
+ fill?: string;
155
+ stroke?: string;
156
+ strokeWidth?: number;
157
+ opacity?: number;
158
+ /** Radians, about the shape's own center — see RectangleObject's `rotation` doc. */
159
+ rotation?: number;
160
+ }
161
+ /** 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`). */
162
+ interface StarObject extends Lockable, Hideable {
163
+ id: string;
164
+ x: number;
165
+ y: number;
166
+ width: number;
167
+ height: number;
168
+ /** Vertex count; today's only shipped preset is 5, matching the legacy tool. */
169
+ points: number;
170
+ /** `(0, 1)` — inner vertex radius as a fraction of the outer radius. */
171
+ innerRadiusRatio: number;
172
+ fill?: string;
173
+ stroke?: string;
174
+ strokeWidth?: number;
175
+ opacity?: number;
176
+ rotation?: number;
177
+ }
178
+ /** 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. */
179
+ interface HeartObject extends Lockable, Hideable {
180
+ id: string;
181
+ x: number;
182
+ y: number;
183
+ width: number;
184
+ height: number;
185
+ fill?: string;
186
+ stroke?: string;
187
+ strokeWidth?: number;
188
+ opacity?: number;
189
+ rotation?: number;
190
+ }
191
+ /**
192
+ * A logical grouping of other board objects (Phase 3 — Selection,
193
+ * Transformation & Grouping). Deliberately has no `x`/`y`/`transform` of its
194
+ * own — a group's bounds are always derived on demand from its (recursively
195
+ * resolved) children, and "moving/rotating/scaling the group" is exactly a
196
+ * multi-object transform applied to those children, nothing more. A group
197
+ * has no renderer/mesh of its own; its only visual presence is the
198
+ * selection gizmo's bounding box while it's the current selection.
199
+ *
200
+ * `children` may itself contain other group ids (nested groups) — expanding
201
+ * a group into its leaf members is always done by the caller (recursively,
202
+ * with cycle protection), never assumed here.
203
+ */
204
+ interface GroupObject extends Lockable, Hideable {
205
+ id: string;
206
+ children: string[];
207
+ }
208
+
209
+ interface BoardPoint {
210
+ x: number;
211
+ y: number;
212
+ }
213
+ /**
214
+ * Which drawing tool made a stroke; undefined means marker (back-compat).
215
+ * `"shape"` (rect/ellipse/line/arrow/polygon/star/heart) renders at its
216
+ * exact configured width with no pressure variance or end taper — a
217
+ * geometric outline, not an expressive ink mark.
218
+ */
219
+ type StrokeTool = "marker" | "highlighter" | "shape";
220
+ type SerializedPoint = [number, number, number, number];
221
+ interface SerializedStroke extends Lockable, Hideable {
222
+ id: string;
223
+ color: string;
224
+ baseWidth: number;
225
+ tool?: StrokeTool;
226
+ points: SerializedPoint[];
227
+ /** Omitted when identity. */
228
+ matrix?: [number, number, number, number, number, number];
229
+ clusterId?: string;
230
+ }
231
+ /** A named notebook containing sections. */
232
+ interface BoardNotebook {
233
+ readonly id: string;
234
+ readonly name: string;
235
+ readonly color: string;
236
+ }
237
+ /** A named section containing pages. */
238
+ interface BoardSection {
239
+ readonly id: string;
240
+ readonly notebookId: string;
241
+ readonly name: string;
242
+ readonly color: string;
243
+ }
244
+ /** An additional page. The first page remains in the document's top-level collections. */
245
+ interface SerializedBoardPage {
246
+ id: string;
247
+ sectionId?: string;
248
+ name: string;
249
+ content: SerializedDocument;
250
+ }
251
+ interface SerializedDocument {
252
+ /** Notebook and section metadata. Older boards use an implicit default of each. */
253
+ notebooks?: BoardNotebook[];
254
+ sections?: BoardSection[];
255
+ /** Section of the original first page; defaults to `default`. */
256
+ pageSectionId?: string;
257
+ /** Display name of the first page (id `default`). */
258
+ pageName?: string;
259
+ /** Additional pages, in sidebar order. Nested pages are not allowed. */
260
+ pages?: SerializedBoardPage[];
261
+ /** Absent in every historical document; current saves always write 1. */
262
+ schemaVersion?: 1;
263
+ strokes: SerializedStroke[];
264
+ /** Absent in documents saved before notes existed. */
265
+ notes?: StickyNote[];
266
+ /** Absent in documents saved before the text tool existed. */
267
+ textBlocks?: TextBlock[];
268
+ /** Absent in documents saved before interactive tables existed. */
269
+ tables?: TableBlock[];
270
+ /** Absent in documents saved before images existed. */
271
+ images?: ImageBlock[];
272
+ /** Absent in documents saved before kitchen timers existed. */
273
+ timers?: KitchenTimer[];
274
+ /** Absent in documents saved before Custom board objects existed (ticket #22). */
275
+ customObjects?: CustomBoardObject[];
276
+ /** Absent in documents saved before semantic Rectangle objects existed (Phase 2). */
277
+ rectangles?: RectangleObject[];
278
+ /** Absent in documents saved before semantic Ellipse objects existed (Phase 2). */
279
+ ellipses?: EllipseObject[];
280
+ /** Absent in documents saved before Groups existed (Phase 3). */
281
+ groups?: GroupObject[];
282
+ /** Absent in documents saved before semantic Line objects existed (Phase 4). */
283
+ lines?: LineObject[];
284
+ /** Absent in documents saved before semantic Arrow objects existed (Phase 4). */
285
+ arrows?: ArrowObject[];
286
+ /** Absent in documents saved before semantic Polygon objects existed (Phase 4). */
287
+ polygons?: PolygonObject[];
288
+ /** Absent in documents saved before semantic Star objects existed (Phase 4). */
289
+ stars?: StarObject[];
290
+ /** Absent in documents saved before semantic Heart objects existed (Phase 4). */
291
+ hearts?: HeartObject[];
292
+ /**
293
+ * Every content-object id (every type above except comments, which are
294
+ * host-synced and never enter this schema) in paint order, back to front.
295
+ * Absent in documents saved before per-object z-order existed (Phase 3) —
296
+ * migration synthesizes a default order preserving the old fixed-Z-band
297
+ * visual stacking exactly, so an existing document never visibly changes
298
+ * on load; only an explicit reorder action touches this from then on.
299
+ */
300
+ objectOrder?: string[];
301
+ }
302
+ /**
303
+ * One collaborator's vote on a note. One per person; toggling removes it.
304
+ */
305
+ interface NoteVote {
306
+ userId: string;
307
+ name: string;
308
+ color: string;
309
+ }
310
+ /**
311
+ * A sticky note: content floating above the board at a z-offset (pillar 3 —
312
+ * depth as an organizational axis). Center position in board space.
313
+ */
314
+ interface StickyNote extends Lockable, Hideable {
315
+ id: string;
316
+ x: number;
317
+ y: number;
318
+ /** Square side length in board units. */
319
+ size: number;
320
+ /** Height above the board surface; drives shadow offset, blur, and opacity. */
321
+ zOffset: number;
322
+ color: string;
323
+ text: string;
324
+ /** One vote per collaborator. Peel follows the count. */
325
+ votes?: NoteVote[];
326
+ }
327
+ /**
328
+ * Typed text on the board surface, rendered as SDF glyphs. Position is the
329
+ * top-left corner; lines flow downward (-y). Text joins the clustering
330
+ * system like handwriting (build prompt §6.4).
331
+ */
332
+ interface TextBlock extends Lockable, Hideable {
333
+ id: string;
334
+ x: number;
335
+ y: number;
336
+ text: string;
337
+ /** Line height in board units. */
338
+ fontSize: number;
339
+ color: string;
340
+ clusterId?: string;
341
+ }
342
+ /**
343
+ * Interactive structured table on the board. Position (x, y) is top-left in board units.
344
+ * Cells are indexed as `${row},${col}` keys mapping to cell text content.
345
+ */
346
+ interface TableBlock extends Lockable, Hideable {
347
+ id: string;
348
+ x: number;
349
+ y: number;
350
+ rows: number;
351
+ cols: number;
352
+ colWidths: number[];
353
+ rowHeights: number[];
354
+ cells: Record<string, string>;
355
+ color?: string;
356
+ backgroundColor?: string;
357
+ clusterId?: string;
358
+ }
359
+ /**
360
+ * An imported image block on the board plane.
361
+ * Coordinates (x, y) represent the center of the image in board space.
362
+ */
363
+ interface ImageBlock extends Lockable, Hideable {
364
+ id: string;
365
+ /**
366
+ * A legacy, read-only data URL (or, historically, an arbitrary string) —
367
+ * never written by new code once `ref` exists. Ticket #23's Host-managed
368
+ * Assets add `ref` as the durable path going forward; `src` and `ref` are
369
+ * mutually exclusive in practice, but both fields exist on every
370
+ * `ImageBlock` so old and new objects share one shape.
371
+ */
372
+ src: string;
373
+ /** Opaque Asset reference (ticket #23); when present, `src` is ignored. */
374
+ ref?: AssetRef;
375
+ x: number;
376
+ y: number;
377
+ width: number;
378
+ height: number;
379
+ aspectRatio: number;
380
+ name?: string;
381
+ createdAt?: string;
382
+ /** Present when this image is a stamp from the pad, not a photo. */
383
+ stamp?: string;
384
+ }
385
+
386
+ declare const strokeIdBrand: unique symbol;
387
+ type StrokeId = string & {
388
+ readonly [strokeIdBrand]: "StrokeId";
389
+ };
390
+
391
+ declare const CURRENT_DOCUMENT_SCHEMA_VERSION: 1;
392
+ type CurrentSerializedStroke = Omit<SerializedStroke, "id"> & {
393
+ id: StrokeId;
394
+ };
395
+ interface CurrentSerializedDocument extends Required<Omit<SerializedDocument, "pages" | "pageName" | "notebooks" | "sections" | "pageSectionId">> {
396
+ schemaVersion: typeof CURRENT_DOCUMENT_SCHEMA_VERSION;
397
+ strokes: CurrentSerializedStroke[];
398
+ pageName?: string;
399
+ notebooks?: BoardNotebook[];
400
+ sections?: BoardSection[];
401
+ pageSectionId?: string;
402
+ pages?: SerializedBoardPage[];
403
+ }
404
+
405
+ interface DocumentContext {
406
+ documentId: string;
407
+ signal: AbortSignal;
408
+ }
409
+ type LoadResult = {
410
+ state: "found";
411
+ document: CurrentSerializedDocument;
412
+ revision: string;
413
+ } | {
414
+ state: "missing";
415
+ };
416
+ /**
417
+ * Result of a whole-document `PersistenceAdapter.replace()` call (ADR 0006:
418
+ * "Whole-document writes survive only for create, clear-board and import,
419
+ * where replacing everything is the actual intent"). Revision-gated, unlike
420
+ * `applyOps` — `conflict` means `baseRevision` was stale (someone else's
421
+ * write landed first); the caller must reload and never overwrites blind.
422
+ */
423
+ type ReplaceResult = {
424
+ state: "applied";
425
+ revision: string;
426
+ } | {
427
+ state: "conflict";
428
+ currentRevision: string;
429
+ };
430
+ /**
431
+ * The one sanctioned seam for persisting a Board's Document to a Host's own
432
+ * storage — implement this against a database, an HTTP API, IndexedDB
433
+ * (see `@scrawl-board/board/local`'s `createIndexedDBPersistence`), or
434
+ * anything else. `load()` fetches the current state on connect; `applyOps()`
435
+ * streams incremental Ops as edits happen; `replace()` is only for
436
+ * whole-document writes (create, clear-board, import — see ADR 0006) and is
437
+ * revision-gated so a stale write never silently clobbers a newer one.
438
+ * Passed via `createBoardController({ adapters: { persistence } })`.
439
+ */
440
+ interface PersistenceAdapter {
441
+ load(context: DocumentContext): Promise<LoadResult>;
442
+ applyOps(context: DocumentContext, ops: readonly ControllerOp[]): Promise<ApplyOpsResult>;
443
+ replace(context: DocumentContext, document: CurrentSerializedDocument, baseRevision: string): Promise<ReplaceResult>;
444
+ }
445
+ /**
446
+ * `"reconcile"` (ticket #24) means the server authoritatively resolved the
447
+ * whole batch — some Ops it accepted, `rejectedOpIds` it didn't (a stale
448
+ * tombstoned id, a permission change, an unrecognized schema, or a
449
+ * conflicting concurrent edit). The batch is never re-queued in this case;
450
+ * the controller instead reloads authoritative state via `load()`.
451
+ */
452
+ type ApplyOpsResult = {
453
+ state: "applied";
454
+ revision: string;
455
+ } | {
456
+ state: "reconcile";
457
+ revision: string;
458
+ rejectedOpIds: readonly string[];
459
+ reason: "tombstone" | "permission" | "schema" | "conflict";
460
+ };
461
+ interface ControllerOp {
462
+ id: string;
463
+ schemaVersion: 1;
464
+ kind: "upsert" | "restore" | "remove";
465
+ objectType: "stroke" | "note" | "text" | "table" | "image" | "timer" | "rectangle" | "ellipse" | "group" | "line" | "arrow" | "polygon" | "star" | "heart" | "custom"
466
+ /**
467
+ * A whole-document paint-order sync (Phase 3), not a per-object type —
468
+ * `objectId` is always the fixed sentinel `"order"` and `payload` is
469
+ * `{ order: string[] }`. The only `objectType` with no matching
470
+ * `BoardObject`/document collection; kept in this same union (rather
471
+ * than a separate wire message) so it flows through the existing
472
+ * `PersistenceAdapter`/`CollaborationAdapter` opaquely, unchanged.
473
+ */
474
+ | "order" | "page" | "notebook" | "section";
475
+ /** Absent for the original page; scopes content and order Ops to an additional page. */
476
+ pageId?: string;
477
+ objectId: string;
478
+ payload?: unknown;
479
+ /**
480
+ * This op's position in its own originating client's local sequence
481
+ * (Phase 7) — 1, 2, 3, ... per controller instance, distinct from `id`
482
+ * (an opaque, globally-unique identifier used for dedup/ack, not
483
+ * ordering) and from a server's own authoritative ordering (e.g.
484
+ * `referenceCollaborationServer.ts`'s per-room `version` counter).
485
+ * Present on every op this SDK originates locally; a remote peer's op
486
+ * carries whatever its own origin set, unchanged — never renumbered in
487
+ * transit. Absent on an op minted by decoding the legacy wire envelope
488
+ * (`scrawlOpEnvelope.ts`), which predates this field and has no
489
+ * per-client sequence concept of its own.
490
+ */
491
+ clientSequence?: number;
492
+ /**
493
+ * The `CollaboratorIdentity.id` of this op's originating client (Phase
494
+ * 7) — set for every op this SDK originates locally when `identity` is
495
+ * configured, omitted entirely otherwise (never sent as `undefined`).
496
+ * The explicit foundation for a future per-author undo filter (a local
497
+ * user's own undo should only ever touch their own ops) — no undo-stack
498
+ * behavior itself changes this phase.
499
+ */
500
+ clientId?: string;
501
+ }
502
+
503
+ interface CreateIndexedDBPersistenceOptions {
504
+ /** IndexedDB database name — change to isolate multiple boards sharing an origin. Defaults to `"scrawl-board"`. */
505
+ databaseName?: string;
506
+ /** Injectable for tests (e.g. `fake-indexeddb`) or a non-`window` runtime that still provides IndexedDB. Defaults to `globalThis.indexedDB`. */
507
+ indexedDB?: IDBFactory;
508
+ }
509
+ /**
510
+ * Creates a `PersistenceAdapter` backed by the browser's IndexedDB. One
511
+ * instance can back multiple documents (keyed by
512
+ * `DocumentContext.documentId`), stored in object-level records so a
513
+ * partial save never corrupts unrelated objects.
514
+ */
515
+ declare function createIndexedDBPersistence(options?: CreateIndexedDBPersistenceOptions): PersistenceAdapter;
516
+
517
+ export { createIndexedDBPersistence };
518
+ export type { CreateIndexedDBPersistenceOptions };