@neta-art/cohub 8.10.2 → 8.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/board/animation.js +14 -2
  2. package/dist/board/core/file-preview.d.ts +20 -141
  3. package/dist/board/core/file-preview.js +25 -202
  4. package/dist/board/core/file-snapshot.d.ts +106 -0
  5. package/dist/board/core/file-snapshot.js +503 -0
  6. package/dist/board/index.d.ts +4 -2
  7. package/dist/board/index.js +4 -2
  8. package/dist/board/mutation.js +2 -1
  9. package/dist/board/render/board-background.d.ts +16 -0
  10. package/dist/board/render/{themes/clean-theme.js → board-background.js} +28 -35
  11. package/dist/board/render/index.d.ts +3 -3
  12. package/dist/board/render/index.js +2 -2
  13. package/dist/board/render/renderers/file-card-renderer.js +62 -17
  14. package/dist/board/replay.d.ts +52 -0
  15. package/dist/board/replay.js +261 -0
  16. package/dist/board/semantic-document.d.ts +9 -2
  17. package/dist/board/semantic-document.js +1 -3
  18. package/dist/chunks/environment.d.ts +272 -7
  19. package/dist/chunks/environment.js +1 -0
  20. package/dist/chunks/http.d.ts +109 -7
  21. package/dist/chunks/http.js +47 -10
  22. package/dist/chunks/websocket.d.ts +1 -1
  23. package/dist/http.d.ts +3 -3
  24. package/dist/index.d.ts +65 -4
  25. package/dist/index.js +514 -64
  26. package/dist/protocol/dist/board-animation.d.ts +1 -0
  27. package/dist/protocol/dist/board-animation.js +34 -0
  28. package/dist/protocol/dist/board-authoring.d.ts +2 -1
  29. package/dist/protocol/dist/board-capability-registry.js +53 -3
  30. package/dist/protocol/dist/board-codec.js +149 -2
  31. package/dist/protocol/dist/board-constants.js +29 -7
  32. package/dist/protocol/dist/board-document.d.ts +30 -16
  33. package/dist/protocol/dist/board-document.js +15 -12
  34. package/dist/protocol/dist/board-effect.d.ts +4 -2
  35. package/dist/protocol/dist/board-effect.js +3 -2
  36. package/dist/protocol/dist/board.d.ts +148 -3
  37. package/dist/protocol/dist/board.js +7 -0
  38. package/dist/protocol/dist/index.d.ts +4 -2
  39. package/dist/protocol/dist/provenance.d.ts +21 -0
  40. package/dist/types.d.ts +1 -1
  41. package/docs/app-runtime-guide.md +28 -3
  42. package/package.json +1 -1
  43. package/dist/board/render/themes/board-theme-registry.d.ts +0 -22
  44. package/dist/board/render/themes/board-theme-registry.js +0 -13
  45. package/dist/board/render/themes/clean-theme.d.ts +0 -5
@@ -1,12 +1,11 @@
1
- import { parseBoardCssColor } from "../css-color.js";
1
+ import { parseBoardCssColor } from "./css-color.js";
2
2
  import { Container, Graphics, RenderTexture, TilingSprite } from "pixi.js";
3
- //#region src/board/render/themes/clean-theme.ts
3
+ //#region src/board/render/board-background.ts
4
4
  const partsByContainer = /* @__PURE__ */ new WeakMap();
5
- /** Positive modulo so tile offsets stay valid for negative viewport offsets. */
6
5
  function wrap(value, period) {
7
6
  return (value % period + period) % period;
8
7
  }
9
- function buildGridTexture(context, size, color, opacity, kind = "dots") {
8
+ function buildGridTexture(context, size, color, opacity, kind) {
10
9
  const graphics = new Graphics();
11
10
  if (kind === "grid") graphics.moveTo(0, .5).lineTo(size, .5).moveTo(.5, 0).lineTo(.5, size).stroke({
12
11
  color,
@@ -46,11 +45,10 @@ function sync(parts, context) {
46
45
  parts.lastBg = bgColor;
47
46
  parts.lastBgAlpha = bgAlpha;
48
47
  }
49
- const appearance = document.appearance;
50
- const visible = appearance.grid?.visible === true;
51
- const size = Math.max(4, appearance.grid?.size ?? 24);
52
- const opacity = appearance.grid?.opacity ?? .12;
53
- const kind = appearance.background?.kind === "grid" ? "grid" : "dots";
48
+ const visible = document.appearance.grid?.visible === true;
49
+ const size = Math.max(4, document.appearance.grid?.size ?? 24);
50
+ const opacity = document.appearance.grid?.opacity ?? .12;
51
+ const kind = document.appearance.background?.kind === "grid" ? "grid" : "dots";
54
52
  const key = `${kind}|${size}|${palette.border}|${opacity}`;
55
53
  if (!visible) {
56
54
  if (parts.sprite) parts.sprite.visible = false;
@@ -80,30 +78,25 @@ function sync(parts, context) {
80
78
  sprite.tileScale.set(viewport.zoom);
81
79
  sprite.tilePosition.set(wrap(viewport.x, step), wrap(viewport.y, step));
82
80
  }
83
- const cleanBoardTheme = {
84
- id: "clean",
85
- canRender: () => true,
86
- createBackground: (context) => {
87
- const container = new Container();
88
- const fill = new Graphics();
89
- container.addChild(fill);
90
- const parts = {
91
- fill,
92
- sprite: null,
93
- textureKey: "",
94
- lastWidth: -1,
95
- lastHeight: -1,
96
- lastBg: NaN,
97
- lastBgAlpha: NaN
98
- };
99
- partsByContainer.set(container, parts);
100
- sync(parts, context);
101
- return container;
102
- },
103
- updateBackground: (container, context) => {
104
- const parts = partsByContainer.get(container);
105
- if (parts) sync(parts, context);
106
- }
107
- };
81
+ function createBoardBackground(context) {
82
+ const container = new Container({ label: "board-background" });
83
+ const fill = new Graphics();
84
+ container.addChild(fill);
85
+ partsByContainer.set(container, {
86
+ fill,
87
+ sprite: null,
88
+ textureKey: "",
89
+ lastWidth: -1,
90
+ lastHeight: -1,
91
+ lastBg: NaN,
92
+ lastBgAlpha: NaN
93
+ });
94
+ sync(partsByContainer.get(container), context);
95
+ return container;
96
+ }
97
+ function updateBoardBackground(container, context) {
98
+ const parts = partsByContainer.get(container);
99
+ if (parts) sync(parts, context);
100
+ }
108
101
  //#endregion
109
- export { cleanBoardTheme };
102
+ export { createBoardBackground, updateBoardBackground };
@@ -1,11 +1,11 @@
1
+ import { BoardCardRenderer, BoardRenderContext, BoardRenderPalette, boardCardRenderersForTest, getBoardCardRenderer, registerBoardCardRenderer } from "./renderers/board-renderer-registry.js";
2
+ import { BoardBackgroundContext, createBoardBackground, updateBoardBackground } from "./board-background.js";
1
3
  import { ConnectionLayer, ConnectionRenderInput, createConnectionLayer, framesFromItems } from "./connection-layer.js";
2
4
  import { parseBoardCssColor } from "./css-color.js";
3
5
  import { BoardMediaAction, boardMediaActionAt, mediaPlayBadgeHit, mediaPlayBadgeVisible } from "./media-interaction.js";
4
- import { BoardCardRenderer, BoardRenderContext, BoardRenderPalette, boardCardRenderersForTest, getBoardCardRenderer, registerBoardCardRenderer } from "./renderers/board-renderer-registry.js";
5
6
  import { defaultBoardPalette } from "./palette.js";
6
7
  import { TASK_CARD_FULL_DETAIL_ZOOM } from "./renderers/task-card-renderer.js";
7
8
  import { ensureBoardTextMeasurement, installBoardTextMeasurement } from "./text-measurement.js";
8
9
  import { getBoardResolution, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket } from "./text-resolution.js";
9
- import { BoardThemeContext, BoardThemeRenderer, getBoardThemeRenderer, registerBoardThemeRenderer } from "./themes/board-theme-registry.js";
10
10
  import { VIDEO_THUMBNAIL_MAX_EDGE, VideoNaturalSize, loadVideoThumbnailTexture, videoTextureNaturalSize, videoThumbnailSize } from "./video-thumbnail.js";
11
- export { BoardCardRenderer, BoardMediaAction, BoardRenderContext, BoardRenderPalette, BoardThemeContext, BoardThemeRenderer, ConnectionLayer, ConnectionRenderInput, TASK_CARD_FULL_DETAIL_ZOOM, VIDEO_THUMBNAIL_MAX_EDGE, VideoNaturalSize, boardCardRenderersForTest, boardMediaActionAt, createConnectionLayer, defaultBoardPalette, ensureBoardTextMeasurement, framesFromItems, getBoardCardRenderer, getBoardResolution, getBoardThemeRenderer, installBoardTextMeasurement, loadVideoThumbnailTexture, mediaPlayBadgeHit, mediaPlayBadgeVisible, parseBoardCssColor, registerBoardCardRenderer, registerBoardThemeRenderer, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket, videoTextureNaturalSize, videoThumbnailSize };
11
+ export { BoardBackgroundContext, BoardCardRenderer, BoardMediaAction, BoardRenderContext, BoardRenderPalette, ConnectionLayer, ConnectionRenderInput, TASK_CARD_FULL_DETAIL_ZOOM, VIDEO_THUMBNAIL_MAX_EDGE, VideoNaturalSize, boardCardRenderersForTest, boardMediaActionAt, createBoardBackground, createConnectionLayer, defaultBoardPalette, ensureBoardTextMeasurement, framesFromItems, getBoardCardRenderer, getBoardResolution, installBoardTextMeasurement, loadVideoThumbnailTexture, mediaPlayBadgeHit, mediaPlayBadgeVisible, parseBoardCssColor, registerBoardCardRenderer, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket, updateBoardBackground, videoTextureNaturalSize, videoThumbnailSize };
@@ -1,4 +1,5 @@
1
1
  import { parseBoardCssColor } from "./css-color.js";
2
+ import { createBoardBackground, updateBoardBackground } from "./board-background.js";
2
3
  import { getBoardResolution, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket } from "./text-resolution.js";
3
4
  import { createConnectionLayer, framesFromItems } from "./connection-layer.js";
4
5
  import { TASK_CARD_FULL_DETAIL_ZOOM } from "./renderers/task-card-renderer.js";
@@ -6,6 +7,5 @@ import { boardMediaActionAt, mediaPlayBadgeHit, mediaPlayBadgeVisible } from "./
6
7
  import { defaultBoardPalette } from "./palette.js";
7
8
  import { ensureBoardTextMeasurement, installBoardTextMeasurement } from "./text-measurement.js";
8
9
  import { boardCardRenderersForTest, getBoardCardRenderer, registerBoardCardRenderer } from "./renderers/board-renderer-registry.js";
9
- import { getBoardThemeRenderer, registerBoardThemeRenderer } from "./themes/board-theme-registry.js";
10
10
  import { VIDEO_THUMBNAIL_MAX_EDGE, loadVideoThumbnailTexture, videoTextureNaturalSize, videoThumbnailSize } from "./video-thumbnail.js";
11
- export { TASK_CARD_FULL_DETAIL_ZOOM, VIDEO_THUMBNAIL_MAX_EDGE, boardCardRenderersForTest, boardMediaActionAt, createConnectionLayer, defaultBoardPalette, ensureBoardTextMeasurement, framesFromItems, getBoardCardRenderer, getBoardResolution, getBoardThemeRenderer, installBoardTextMeasurement, loadVideoThumbnailTexture, mediaPlayBadgeHit, mediaPlayBadgeVisible, parseBoardCssColor, registerBoardCardRenderer, registerBoardThemeRenderer, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket, videoTextureNaturalSize, videoThumbnailSize };
11
+ export { TASK_CARD_FULL_DETAIL_ZOOM, VIDEO_THUMBNAIL_MAX_EDGE, boardCardRenderersForTest, boardMediaActionAt, createBoardBackground, createConnectionLayer, defaultBoardPalette, ensureBoardTextMeasurement, framesFromItems, getBoardCardRenderer, getBoardResolution, installBoardTextMeasurement, loadVideoThumbnailTexture, mediaPlayBadgeHit, mediaPlayBadgeVisible, parseBoardCssColor, registerBoardCardRenderer, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket, updateBoardBackground, videoTextureNaturalSize, videoThumbnailSize };
@@ -1,5 +1,6 @@
1
- import { BOARD_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
- import { fileBaseName, filePreviewKind } from "../../core/file-preview.js";
1
+ import { BOARD_FONT_STACK, BOARD_MONO_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
+ import { fileCategory, fileStem } from "../../core/file-snapshot.js";
3
+ import { fileCategoryAccent, filePreviewKind, fileTypeLabel } from "../../core/file-preview.js";
3
4
  import { syncTextResolution } from "../text-resolution.js";
4
5
  import { positionShell } from "./base-card-renderer.js";
5
6
  import { drawFarPlate } from "./far-plate.js";
@@ -31,8 +32,10 @@ const TITLE_LINE = TITLE_SIZE * 1.35;
31
32
  const TITLE_MAX_LINES = 2;
32
33
  const EXCERPT_SIZE = 11;
33
34
  const EXCERPT_LINE = EXCERPT_SIZE * 1.45;
34
- const EXCERPT_MAX_LINES = 4;
35
+ const EXCERPT_MAX_LINES = 3;
36
+ const TYPE_MARK_SIZE = 28;
35
37
  const GAP = 4;
38
+ const STRIPE = 2;
36
39
  /** Zoom below which the title is dropped (glyphs are sub-pixel). */
37
40
  const LOD_TITLE_ZOOM = .35;
38
41
  /** Zoom below which the excerpt is dropped. */
@@ -168,8 +171,12 @@ function sync(container, item, context) {
168
171
  const coverFailed = Boolean(key && !texture && context.hasError(key));
169
172
  const fileState = context.fileState(item.ref.path);
170
173
  const band = coverHeight(item, height);
174
+ const kind = filePreviewKind(item.snapshot);
175
+ const category = fileCategory(item.ref.path, item.snapshot?.mimeType);
176
+ const accent = fileCategoryAccent(category, context.palette);
171
177
  syncTextResolution(parts.title, parts.titleRes, context.zoom);
172
178
  syncTextResolution(parts.excerpt, parts.excerptRes, context.zoom);
179
+ syncTextResolution(parts.typeMark, parts.typeMarkRes, context.zoom);
173
180
  const visualSig = [
174
181
  key ?? "",
175
182
  width,
@@ -180,9 +187,12 @@ function sync(container, item, context) {
180
187
  texture ? `${texture.width}x${texture.height}` : "none",
181
188
  coverFailed,
182
189
  fileState,
190
+ kind,
191
+ category,
183
192
  context.colorScheme,
184
193
  context.palette.surface,
185
- context.palette.hover
194
+ context.palette.hover,
195
+ accent
186
196
  ].join("|");
187
197
  if (visualSig !== parts.visualSig) {
188
198
  parts.visualSig = visualSig;
@@ -218,41 +228,61 @@ function sync(container, item, context) {
218
228
  }
219
229
  parts.cover.visible = showCover;
220
230
  parts.coverMask.visible = showCover;
221
- if (!band) parts.plate.rect(1, 1, 2, height - 2).fill({
222
- color: context.palette.muted,
223
- alpha: .35
231
+ if (!band) parts.plate.rect(1, 1, STRIPE, height - 2).fill({
232
+ color: accent,
233
+ alpha: .55
224
234
  });
225
235
  parts.title.visible = detail !== "plate";
226
236
  parts.excerpt.visible = detail === "full";
237
+ parts.typeMark.visible = kind === "blank" && detail !== "plate";
227
238
  }
228
- if (detail === "plate") return;
229
- const title = item.snapshot?.title || fileBaseName(item.ref.path);
239
+ if (detail === "plate") {
240
+ parts.typeMark.visible = false;
241
+ return;
242
+ }
243
+ const title = item.snapshot?.title || fileStem(item.ref.path);
230
244
  const excerpt = item.snapshot?.excerpt ?? "";
245
+ const mark = fileTypeLabel(item.ref.path);
231
246
  const innerWidth = Math.max(1, width - 20);
247
+ const showTypeMark = kind === "blank";
232
248
  const textSig = [
233
249
  title,
234
250
  excerpt,
251
+ mark,
235
252
  detail,
236
253
  innerWidth,
237
254
  band,
238
255
  height,
256
+ kind,
239
257
  context.palette.text,
240
- context.palette.muted
258
+ context.palette.muted,
259
+ accent
241
260
  ].join("|");
242
261
  if (textSig === parts.textSig) return;
243
262
  parts.textSig = textSig;
244
263
  const top = band > 0 ? band + PADDING * .8 : PADDING;
245
264
  const contentBottom = height - PADDING;
246
- const titleLines = linesInRoom(Math.max(0, contentBottom - top), TITLE_LINE, TITLE_MAX_LINES);
265
+ let cursor = top;
266
+ if (showTypeMark) {
267
+ const markSize = Math.max(18, Math.min(TYPE_MARK_SIZE, Math.round(height * .22)));
268
+ parts.typeMark.style.fill = accent;
269
+ parts.typeMark.style.fontSize = markSize;
270
+ parts.typeMark.style.lineHeight = markSize * 1.1;
271
+ if (parts.typeMark.text !== mark) parts.typeMark.text = mark;
272
+ parts.typeMark.position.set(PADDING, cursor);
273
+ parts.typeMark.visible = true;
274
+ cursor += parts.typeMark.height + GAP;
275
+ } else parts.typeMark.visible = false;
276
+ const titleLines = linesInRoom(Math.max(0, contentBottom - cursor), TITLE_LINE, TITLE_MAX_LINES);
247
277
  parts.title.style.fill = context.palette.text;
248
278
  fitTextToLines(parts.title, title, titleLines, innerWidth);
249
- parts.title.position.set(PADDING, top);
279
+ parts.title.position.set(PADDING, cursor);
250
280
  parts.title.visible = titleLines > 0;
251
281
  if (detail !== "full") {
252
282
  parts.excerpt.visible = false;
253
283
  return;
254
284
  }
255
- const excerptTop = top + (titleLines > 0 ? parts.title.height + GAP : 0);
285
+ const excerptTop = cursor + (titleLines > 0 ? parts.title.height + GAP : 0);
256
286
  const excerptLines = linesInRoom(contentBottom - excerptTop, EXCERPT_LINE, EXCERPT_MAX_LINES);
257
287
  const showExcerpt = Boolean(excerpt) && excerptLines > 0;
258
288
  if (showExcerpt) {
@@ -302,8 +332,20 @@ const fileCardRenderer = {
302
332
  resolution,
303
333
  roundPixels: true
304
334
  });
335
+ const typeMark = new Text({
336
+ text: "",
337
+ style: {
338
+ fill: context.palette.muted,
339
+ fontFamily: BOARD_MONO_FONT_STACK,
340
+ fontSize: TYPE_MARK_SIZE,
341
+ fontWeight: "700",
342
+ lineHeight: TYPE_MARK_SIZE * 1.1
343
+ },
344
+ resolution,
345
+ roundPixels: true
346
+ });
305
347
  body.mask = clip;
306
- body.addChild(cover, coverMask, title, excerpt);
348
+ body.addChild(cover, coverMask, typeMark, title, excerpt);
307
349
  root.addChild(plate, body, clip);
308
350
  partsByContainer.set(root, {
309
351
  root,
@@ -312,12 +354,14 @@ const fileCardRenderer = {
312
354
  clip,
313
355
  cover,
314
356
  coverMask,
357
+ typeMark,
315
358
  title,
316
359
  excerpt,
317
360
  visualSig: "",
318
361
  textSig: "",
319
362
  titleRes: { resolution },
320
- excerptRes: { resolution }
363
+ excerptRes: { resolution },
364
+ typeMarkRes: { resolution }
321
365
  });
322
366
  if (item.type === "file") sync(root, item, context);
323
367
  return root;
@@ -330,11 +374,12 @@ const fileCardRenderer = {
330
374
  * would mean one draw call per distinct image and defeat the batch.
331
375
  */
332
376
  renderFar: (graphics, item, context) => {
377
+ const category = fileCategory(item.type === "file" ? item.ref.path : "", item.type === "file" ? item.snapshot?.mimeType : void 0);
333
378
  drawFarPlate(graphics, item.frame, {
334
379
  fill: context.palette.surface,
335
380
  fillAlpha: .96,
336
- accent: context.palette.muted,
337
- accentAlpha: .4
381
+ accent: fileCategoryAccent(category, context.palette),
382
+ accentAlpha: .45
338
383
  });
339
384
  },
340
385
  destroy: (container) => {
@@ -0,0 +1,52 @@
1
+ import { RequestSource } from "../protocol/dist/provenance.js";
2
+ import { BoardTransactionsPage } from "../protocol/dist/board.js";
3
+ import { BoardDocument } from "../protocol/dist/board-document.js";
4
+ import "../protocol/dist/index.js";
5
+ //#region src/board/replay.d.ts
6
+ type BoardReplayActorKind = "human" | "cli" | "agent";
7
+ /** One step on the replay timeline. */
8
+ type BoardReplayEntry = {
9
+ version: number;
10
+ actorId: string;
11
+ kind: BoardReplayActorKind;
12
+ /** Epoch milliseconds. */
13
+ at: number;
14
+ /** Whether the step changed anything visible in the render document. */
15
+ visual: boolean;
16
+ };
17
+ type BoardReplayPlayer = ReturnType<typeof createBoardReplayPlayer>;
18
+ declare function boardReplayActorKind(source: RequestSource | null): BoardReplayActorKind;
19
+ /**
20
+ * Create a player positioned at the live version described by the first page.
21
+ *
22
+ * `transactions` are newest-first as served; the player keeps them oldest-first.
23
+ * Older pages can be added with `prepend`, and newer transactions that arrive
24
+ * live can be added with `append`, so the timeline grows in both directions
25
+ * without a reset.
26
+ */
27
+ declare function createBoardReplayPlayer(page: BoardTransactionsPage): {
28
+ /** Oldest version the loaded transactions can rewind to. */
29
+ readonly floor: number;
30
+ /** Newest version known to the player. */
31
+ readonly head: number;
32
+ readonly entries: readonly BoardReplayEntry[];
33
+ /** Current position. */
34
+ readonly version: number;
35
+ seek: (version: number) => number;
36
+ documentAt: (version: number) => BoardDocument;
37
+ /** Item ids touched by the transaction that produced `version`, for camera follow. */
38
+ changedItemIds(version: number): string[];
39
+ /** Add an older page (as served, newest-first). */
40
+ prepend(older: BoardTransactionsPage): void;
41
+ /**
42
+ * Add transactions that landed after the current head. Forward payloads are
43
+ * enough to extend the timeline; the scrub position is untouched.
44
+ *
45
+ * Returns false when the page does not reach back to the head: the chain
46
+ * would have a hole, so nothing is appended and the caller must fetch an
47
+ * older page (`before` = the page's oldest version) and try again.
48
+ */
49
+ append(latest: BoardTransactionsPage): boolean;
50
+ };
51
+ //#endregion
52
+ export { BoardReplayActorKind, BoardReplayEntry, BoardReplayPlayer, boardReplayActorKind, createBoardReplayPlayer };
@@ -0,0 +1,261 @@
1
+ import { BOARD_DOCUMENT_KIND } from "../protocol/dist/board.js";
2
+ import { BoardAppearanceSchema, parseBoardDocument } from "../protocol/dist/board-document.js";
3
+ import { boardNodeToAuthoringItem } from "../protocol/dist/board-codec.js";
4
+ import { DEFAULT_BOARD_APPEARANCE, boardAuthoringItemToDocumentItem } from "./semantic-document.js";
5
+ //#region src/board/replay.ts
6
+ function boardReplayActorKind(source) {
7
+ if (!source) return "human";
8
+ if (source.toolCallId) return "agent";
9
+ return source.via === "cli" ? "cli" : "human";
10
+ }
11
+ function isRecord(value) {
12
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
13
+ }
14
+ /** Apply one stored operation (forward payload or inverse) to the mutable state. */
15
+ function applyOperation(state, type, payload) {
16
+ switch (type) {
17
+ case "board.patch": {
18
+ const patch = isRecord(payload.patch) ? payload.patch : {};
19
+ if (isRecord(patch.metadata)) state.metadata = patch.metadata;
20
+ if (isRecord(patch.metadataPatch)) state.metadata = {
21
+ ...state.metadata,
22
+ ...patch.metadataPatch
23
+ };
24
+ return;
25
+ }
26
+ case "node.create": {
27
+ const node = payload.node;
28
+ if (node) state.nodes.set(node.nodeId, node);
29
+ return;
30
+ }
31
+ case "node.patch": {
32
+ const nodeId = payload.nodeId;
33
+ const current = state.nodes.get(nodeId);
34
+ if (current && isRecord(payload.patch)) state.nodes.set(nodeId, {
35
+ ...current,
36
+ ...payload.patch,
37
+ nodeId
38
+ });
39
+ return;
40
+ }
41
+ case "node.delete":
42
+ state.nodes.delete(payload.nodeId);
43
+ return;
44
+ case "connection.create": {
45
+ const connection = payload.connection;
46
+ if (connection) state.connections.set(connection.id, connection);
47
+ return;
48
+ }
49
+ case "connection.patch": {
50
+ const connectionId = payload.connectionId;
51
+ const current = state.connections.get(connectionId);
52
+ if (current && isRecord(payload.patch)) state.connections.set(connectionId, {
53
+ ...current,
54
+ ...payload.patch,
55
+ id: connectionId
56
+ });
57
+ return;
58
+ }
59
+ case "connection.delete":
60
+ state.connections.delete(payload.connectionId);
61
+ return;
62
+ default: return;
63
+ }
64
+ }
65
+ /**
66
+ * Inverses are stored either as a full operation (`{ type, payload }`) or, for
67
+ * `board.patch`, as a bare `{ patch }`. Normalise both to `(type, payload)`.
68
+ */
69
+ function inverseOf(operation) {
70
+ const inverse = operation.inverse;
71
+ if (!inverse) return null;
72
+ if (typeof inverse.type === "string" && isRecord(inverse.payload)) return {
73
+ type: inverse.type,
74
+ payload: inverse.payload
75
+ };
76
+ if (operation.type === "board.patch" && isRecord(inverse.patch)) return {
77
+ type: "board.patch",
78
+ payload: { patch: inverse.patch }
79
+ };
80
+ return null;
81
+ }
82
+ function operationItemIds(operation) {
83
+ const payload = operation.payload;
84
+ switch (operation.type) {
85
+ case "node.create": return isRecord(payload.node) && typeof payload.node.nodeId === "string" ? [payload.node.nodeId] : [];
86
+ case "node.patch":
87
+ case "node.delete": return typeof payload.nodeId === "string" ? [payload.nodeId] : [];
88
+ default: return [];
89
+ }
90
+ }
91
+ const VISUAL_OPERATION_TYPES = /* @__PURE__ */ new Set([
92
+ "board.patch",
93
+ "node.create",
94
+ "node.patch",
95
+ "node.delete",
96
+ "connection.create",
97
+ "connection.patch",
98
+ "connection.delete"
99
+ ]);
100
+ function entryOf(transaction) {
101
+ return {
102
+ version: transaction.version,
103
+ actorId: transaction.actorId,
104
+ kind: boardReplayActorKind(transaction.source),
105
+ at: Date.parse(transaction.createdAt),
106
+ visual: transaction.operations.some((operation) => VISUAL_OPERATION_TYPES.has(operation.type))
107
+ };
108
+ }
109
+ function appearanceOf(metadata) {
110
+ const parsed = BoardAppearanceSchema.safeParse(metadata.appearance);
111
+ return parsed.success ? parsed.data : DEFAULT_BOARD_APPEARANCE;
112
+ }
113
+ function project(state) {
114
+ const nodes = [...state.nodes.values()].sort((a, b) => (a.orderKey ?? "").localeCompare(b.orderKey ?? ""));
115
+ return parseBoardDocument({
116
+ kind: BOARD_DOCUMENT_KIND,
117
+ version: 1,
118
+ appearance: appearanceOf(state.metadata),
119
+ viewport: {
120
+ x: 0,
121
+ y: 0,
122
+ zoom: 1
123
+ },
124
+ items: nodes.map((node) => boardAuthoringItemToDocumentItem(boardNodeToAuthoringItem(node))),
125
+ connections: [...state.connections.values()]
126
+ });
127
+ }
128
+ function stateFromSnapshot(snapshot) {
129
+ return {
130
+ nodes: new Map(snapshot.nodes.map(({ boardId: _boardId, version: _version, createdAt: _c, updatedAt: _u, ...node }) => [node.nodeId, node])),
131
+ connections: new Map(snapshot.connections.map(({ boardId: _boardId, revision: _revision, createdAt: _c, updatedAt: _u, ...connection }) => [connection.id, connection])),
132
+ metadata: snapshot.board.metadata
133
+ };
134
+ }
135
+ /**
136
+ * Create a player positioned at the live version described by the first page.
137
+ *
138
+ * `transactions` are newest-first as served; the player keeps them oldest-first.
139
+ * Older pages can be added with `prepend`, and newer transactions that arrive
140
+ * live can be added with `append`, so the timeline grows in both directions
141
+ * without a reset.
142
+ */
143
+ function createBoardReplayPlayer(page) {
144
+ if (!page.snapshot) throw new Error("Board replay needs the first transactions page (with snapshot).");
145
+ const liveVersion = page.board.version;
146
+ const newest = page.transactions.reduce((max, transaction) => Math.max(max, transaction.version), 0);
147
+ if (page.transactions.length > 0 && newest !== liveVersion) throw new Error(`Board replay page is inconsistent: board is at v${liveVersion}, newest transaction is v${newest}.`);
148
+ let transactions = [...page.transactions].sort((a, b) => a.version - b.version);
149
+ let entries = transactions.map(entryOf);
150
+ const state = stateFromSnapshot(page.snapshot);
151
+ let cursor = liveVersion;
152
+ const documents = /* @__PURE__ */ new Map();
153
+ /** Oldest version reachable with the transactions loaded so far. */
154
+ let floor = transactions[0]?.baseVersion ?? liveVersion;
155
+ /**
156
+ * Index of the first transaction whose version is `>= version`. Versions are
157
+ * strictly increasing but not dense (a no-op mutation leaves a gap), so
158
+ * binary-search by value rather than indexing by offset.
159
+ */
160
+ function lowerBound(version) {
161
+ let low = 0;
162
+ let high = transactions.length;
163
+ while (low < high) {
164
+ const mid = low + high >> 1;
165
+ if (transactions[mid].version < version) low = mid + 1;
166
+ else high = mid;
167
+ }
168
+ return low;
169
+ }
170
+ function transactionProducing(version) {
171
+ const candidate = transactions[lowerBound(version)];
172
+ return candidate?.version === version ? candidate : void 0;
173
+ }
174
+ /** Undo the transaction that produced `cursor`. */
175
+ function stepBackward() {
176
+ const transaction = transactionProducing(cursor);
177
+ if (!transaction) return false;
178
+ for (let index = transaction.operations.length - 1; index >= 0; index -= 1) {
179
+ const inverse = inverseOf(transaction.operations[index]);
180
+ if (inverse) applyOperation(state, inverse.type, inverse.payload);
181
+ }
182
+ cursor = transaction.baseVersion;
183
+ return true;
184
+ }
185
+ function stepForward(next) {
186
+ for (const operation of next.operations) applyOperation(state, operation.type, operation.payload);
187
+ cursor = next.version;
188
+ }
189
+ function head() {
190
+ return transactions.at(-1)?.version ?? liveVersion;
191
+ }
192
+ /**
193
+ * Move to `version`, clamped to the loaded range. Returns the version reached,
194
+ * which is the nearest loaded version at or below the request.
195
+ */
196
+ function seek(version) {
197
+ const target = Math.max(floor, Math.min(head(), version));
198
+ while (cursor > target && stepBackward());
199
+ for (let next = transactions[lowerBound(cursor + 1)]; next && next.version <= target; next = transactions[lowerBound(cursor + 1)]) stepForward(next);
200
+ return cursor;
201
+ }
202
+ function documentAt(version) {
203
+ const reached = seek(version);
204
+ const cached = documents.get(reached);
205
+ if (cached) return cached;
206
+ const document = project(state);
207
+ documents.set(reached, document);
208
+ return document;
209
+ }
210
+ return {
211
+ /** Oldest version the loaded transactions can rewind to. */
212
+ get floor() {
213
+ return floor;
214
+ },
215
+ /** Newest version known to the player. */
216
+ get head() {
217
+ return head();
218
+ },
219
+ get entries() {
220
+ return entries;
221
+ },
222
+ /** Current position. */
223
+ get version() {
224
+ return cursor;
225
+ },
226
+ seek,
227
+ documentAt,
228
+ /** Item ids touched by the transaction that produced `version`, for camera follow. */
229
+ changedItemIds(version) {
230
+ return [...new Set(transactionProducing(version)?.operations.flatMap(operationItemIds))];
231
+ },
232
+ /** Add an older page (as served, newest-first). */
233
+ prepend(older) {
234
+ const current = floor;
235
+ const fresh = older.transactions.filter((transaction) => transaction.version <= current).sort((a, b) => a.version - b.version);
236
+ if (fresh.length === 0) return;
237
+ transactions = [...fresh, ...transactions];
238
+ entries = [...fresh.map(entryOf), ...entries];
239
+ floor = fresh[0].baseVersion;
240
+ },
241
+ /**
242
+ * Add transactions that landed after the current head. Forward payloads are
243
+ * enough to extend the timeline; the scrub position is untouched.
244
+ *
245
+ * Returns false when the page does not reach back to the head: the chain
246
+ * would have a hole, so nothing is appended and the caller must fetch an
247
+ * older page (`before` = the page's oldest version) and try again.
248
+ */
249
+ append(latest) {
250
+ const current = head();
251
+ const fresh = latest.transactions.filter((transaction) => transaction.version > current).sort((a, b) => a.version - b.version);
252
+ if (fresh.length === 0) return true;
253
+ if (fresh[0].baseVersion > current) return false;
254
+ transactions = [...transactions, ...fresh];
255
+ entries = [...entries, ...fresh.map(entryOf)];
256
+ return true;
257
+ }
258
+ };
259
+ }
260
+ //#endregion
261
+ export { boardReplayActorKind, createBoardReplayPlayer };
@@ -4,7 +4,15 @@ import { BoardDocument, BoardItem } from "../protocol/dist/board-document.js";
4
4
  import "../protocol/dist/index.js";
5
5
  //#region src/board/semantic-document.d.ts
6
6
  declare const DEFAULT_BOARD_APPEARANCE: {
7
- theme: string;
7
+ theme?: string | undefined;
8
+ motion?: {
9
+ enter?: {
10
+ kind: string;
11
+ kindVersion: number;
12
+ params: Record<string, unknown>;
13
+ } | undefined;
14
+ } | undefined;
15
+ mood?: "arcane" | "clean" | "cyber" | "natural" | "playful" | undefined;
8
16
  background: {
9
17
  kind: "custom" | "dots" | "grid" | "image" | "shader" | "solid";
10
18
  color?: string | undefined;
@@ -18,7 +26,6 @@ declare const DEFAULT_BOARD_APPEARANCE: {
18
26
  size: number;
19
27
  opacity: number;
20
28
  };
21
- mood: "arcane" | "clean" | "cyber" | "natural" | "playful";
22
29
  };
23
30
  /** Convert a public authoring Item to the renderer/editor document shape. */
24
31
  declare function boardAuthoringItemToDocumentItem(item: BoardAuthoringItem): BoardItem;
@@ -5,14 +5,12 @@ import { boardDrawPointsToWorld } from "../protocol/dist/board-geometry.js";
5
5
  import { boardAuthoringItemToNode } from "../protocol/dist/board-codec.js";
6
6
  //#region src/board/semantic-document.ts
7
7
  const DEFAULT_BOARD_APPEARANCE = BoardAppearanceSchema.parse({
8
- theme: "clean",
9
8
  background: { kind: "solid" },
10
9
  grid: {
11
10
  visible: false,
12
11
  size: 24,
13
12
  opacity: .12
14
- },
15
- mood: "clean"
13
+ }
16
14
  });
17
15
  /** Convert a public authoring Item to the renderer/editor document shape. */
18
16
  function boardAuthoringItemToDocumentItem(item) {