@neta-art/cohub 3.1.0 → 4.0.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 (94) hide show
  1. package/README.md +33 -2
  2. package/dist/{board.d.ts → board/animation.d.ts} +4 -2
  3. package/dist/{chunks/board.js → board/animation.js} +5 -44
  4. package/dist/board/codec.d.ts +25 -0
  5. package/dist/board/codec.js +243 -0
  6. package/dist/board/core/bindings.d.ts +45 -0
  7. package/dist/board/core/bindings.js +162 -0
  8. package/dist/board/core/draw-geometry.d.ts +30 -0
  9. package/dist/board/core/draw-geometry.js +175 -0
  10. package/dist/board/core/export-assets.d.ts +14 -0
  11. package/dist/board/core/export-assets.js +22 -0
  12. package/dist/board/core/export-plan.d.ts +84 -0
  13. package/dist/board/core/export-plan.js +128 -0
  14. package/dist/board/core/file-preview.d.ts +178 -0
  15. package/dist/board/core/file-preview.js +282 -0
  16. package/dist/board/core/palette.d.ts +42 -0
  17. package/dist/board/core/palette.js +166 -0
  18. package/dist/board/core/shape-definition.d.ts +35 -0
  19. package/dist/board/core/shape-definition.js +64 -0
  20. package/dist/board/core/shape-types.d.ts +140 -0
  21. package/dist/board/core/shape-types.js +27 -0
  22. package/dist/board/core/text-metrics.d.ts +18 -0
  23. package/dist/board/core/text-metrics.js +43 -0
  24. package/dist/board/core/tool-styles.d.ts +52 -0
  25. package/dist/board/core/tool-styles.js +51 -0
  26. package/dist/board/export/index.d.ts +56 -0
  27. package/dist/board/export/index.js +86 -0
  28. package/dist/board/export/scene.d.ts +35 -0
  29. package/dist/board/export/scene.js +69 -0
  30. package/dist/board/geometry.d.ts +139 -0
  31. package/dist/board/geometry.js +492 -0
  32. package/dist/board/headless/index.d.ts +69 -0
  33. package/dist/board/headless/index.js +150 -0
  34. package/dist/board/image-key.d.ts +19 -0
  35. package/dist/board/image-key.js +34 -0
  36. package/dist/board/index.d.ts +17 -0
  37. package/dist/board/index.js +17 -0
  38. package/dist/board/render/index.d.ts +6 -0
  39. package/dist/board/render/index.js +6 -0
  40. package/dist/board/render/palette.d.ts +5 -0
  41. package/dist/board/render/palette.js +30 -0
  42. package/dist/board/render/renderers/arrow-card-renderer.d.ts +5 -0
  43. package/dist/board/render/renderers/arrow-card-renderer.js +157 -0
  44. package/dist/board/render/renderers/base-card-renderer.d.ts +46 -0
  45. package/dist/board/render/renderers/base-card-renderer.js +153 -0
  46. package/dist/board/render/renderers/board-renderer-registry.d.ts +85 -0
  47. package/dist/board/render/renderers/board-renderer-registry.js +41 -0
  48. package/dist/board/render/renderers/draw-card-renderer.d.ts +5 -0
  49. package/dist/board/render/renderers/draw-card-renderer.js +82 -0
  50. package/dist/board/render/renderers/far-plate.d.ts +39 -0
  51. package/dist/board/render/renderers/far-plate.js +92 -0
  52. package/dist/board/render/renderers/file-card-renderer.d.ts +40 -0
  53. package/dist/board/render/renderers/file-card-renderer.js +350 -0
  54. package/dist/board/render/renderers/frame-card-renderer.d.ts +5 -0
  55. package/dist/board/render/renderers/frame-card-renderer.js +99 -0
  56. package/dist/board/render/renderers/geo-card-renderer.d.ts +5 -0
  57. package/dist/board/render/renderers/geo-card-renderer.js +128 -0
  58. package/dist/board/render/renderers/image-card-renderer.d.ts +8 -0
  59. package/dist/board/render/renderers/image-card-renderer.js +138 -0
  60. package/dist/board/render/renderers/text-card-renderer.d.ts +7 -0
  61. package/dist/board/render/renderers/text-card-renderer.js +94 -0
  62. package/dist/board/render/renderers/unknown-card-renderer.d.ts +5 -0
  63. package/dist/board/render/renderers/unknown-card-renderer.js +102 -0
  64. package/dist/board/render/renderers/video-card-renderer.d.ts +7 -0
  65. package/dist/board/render/renderers/video-card-renderer.js +146 -0
  66. package/dist/board/render/text-measurement.d.ts +30 -0
  67. package/dist/board/render/text-measurement.js +57 -0
  68. package/dist/board/render/text-resolution.d.ts +16 -0
  69. package/dist/board/render/text-resolution.js +43 -0
  70. package/dist/board/render/themes/board-theme-registry.d.ts +22 -0
  71. package/dist/board/render/themes/board-theme-registry.js +13 -0
  72. package/dist/board/render/themes/clean-theme.d.ts +5 -0
  73. package/dist/board/render/themes/clean-theme.js +105 -0
  74. package/dist/chunks/http.d.ts +2 -3
  75. package/dist/chunks/http.js +4 -0
  76. package/dist/chunks/websocket.d.ts +851 -353
  77. package/dist/http.d.ts +1 -2
  78. package/dist/index.d.ts +100 -5
  79. package/dist/index.js +391 -1
  80. package/dist/protocol/dist/board-constants.d.ts +23 -0
  81. package/dist/protocol/dist/board-constants.js +61 -0
  82. package/dist/protocol/dist/board-document.d.ts +1047 -0
  83. package/dist/protocol/dist/board-document.js +321 -0
  84. package/dist/protocol/dist/board.d.ts +186 -0
  85. package/dist/protocol/dist/board.js +207 -0
  86. package/dist/protocol/dist/index.d.ts +5 -0
  87. package/dist/protocol/dist/index.js +5 -0
  88. package/dist/protocol/dist/provenance.js +12 -0
  89. package/dist/protocol/dist/realtime/board-awareness.d.ts +1 -0
  90. package/dist/protocol/dist/realtime/board-awareness.js +119 -0
  91. package/dist/protocol/dist/realtime/types.d.ts +2 -0
  92. package/package.json +40 -4
  93. package/dist/board.js +0 -2
  94. package/dist/chunks/board.d.ts +0 -2589
@@ -0,0 +1,138 @@
1
+ import { drawFarPlate } from "./far-plate.js";
2
+ import { positionShell } from "./base-card-renderer.js";
3
+ import { Container, Graphics, Sprite, Texture } from "pixi.js";
4
+ //#region src/board/render/renderers/image-card-renderer.ts
5
+ const partsByContainer = /* @__PURE__ */ new WeakMap();
6
+ const RADIUS = 2;
7
+ /**
8
+ * Lay the sprite out inside the frame.
9
+ *
10
+ * The frame normally already matches the image's pixel aspect (see the editor's
11
+ * media size adoption), so this fills it exactly. Until that correction lands
12
+ * for a freshly dropped file, `contain` keeps the whole image visible rather
13
+ * than cropping it — the brief letterbox is self-correcting.
14
+ */
15
+ function layoutContain(sprite, width, height) {
16
+ const texture = sprite.texture;
17
+ const tw = texture.width;
18
+ const th = texture.height;
19
+ if (!tw || !th) return;
20
+ const scale = Math.min(width / tw, height / th);
21
+ sprite.width = tw * scale;
22
+ sprite.height = th * scale;
23
+ sprite.x = (width - sprite.width) / 2;
24
+ sprite.y = (height - sprite.height) / 2;
25
+ }
26
+ function sync(container, item, context) {
27
+ const parts = partsByContainer.get(container);
28
+ if (!parts) return;
29
+ positionShell(parts.root, item);
30
+ const { width, height } = item.frame;
31
+ const selected = context.selectedIds.has(item.id);
32
+ const hovered = context.hoveredId === item.id;
33
+ const key = context.assetKey(item);
34
+ const texture = key ? context.getTexture(key) : null;
35
+ const failed = Boolean(key && !texture && context.hasError(key));
36
+ const texId = texture ? `${texture.width}x${texture.height}` : "none";
37
+ const sig = [
38
+ key ?? "",
39
+ width,
40
+ height,
41
+ selected,
42
+ hovered,
43
+ texId,
44
+ failed,
45
+ context.colorScheme
46
+ ].join("|");
47
+ if (sig === parts.sig) return;
48
+ parts.sig = sig;
49
+ if (texture && parts.sprite.texture !== texture) parts.sprite.texture = texture;
50
+ parts.sprite.visible = Boolean(texture);
51
+ if (texture) layoutContain(parts.sprite, width, height);
52
+ parts.placeholder.clear();
53
+ if (!texture) {
54
+ parts.placeholder.roundRect(0, 0, width, height, RADIUS).fill({
55
+ color: context.palette.surface,
56
+ alpha: .85
57
+ }).roundRect(0, 0, width, height, RADIUS).stroke({
58
+ color: failed ? context.palette.border : context.palette.hover,
59
+ width: 1,
60
+ alpha: .9
61
+ });
62
+ const cx = width / 2;
63
+ const cy = height / 2;
64
+ if (failed) parts.placeholder.moveTo(cx - 10, cy - 10).lineTo(cx + 10, cy + 10).moveTo(cx + 10, cy - 10).lineTo(cx - 10, cy + 10).stroke({
65
+ color: context.palette.muted,
66
+ width: 1.5,
67
+ alpha: .8
68
+ });
69
+ else parts.placeholder.circle(cx, cy, 6).stroke({
70
+ color: context.palette.muted,
71
+ width: 1.25,
72
+ alpha: .55
73
+ });
74
+ }
75
+ parts.placeholder.visible = !texture;
76
+ if (selected || hovered) {
77
+ const hasPixels = Boolean(texture) && parts.sprite.width > 0;
78
+ const ox = hasPixels ? parts.sprite.x : 0;
79
+ const oy = hasPixels ? parts.sprite.y : 0;
80
+ const ow = hasPixels ? parts.sprite.width : width;
81
+ const oh = hasPixels ? parts.sprite.height : height;
82
+ parts.placeholder.visible = true;
83
+ parts.placeholder.roundRect(ox, oy, ow, oh, RADIUS).stroke({
84
+ color: selected ? context.palette.brand : context.palette.muted,
85
+ width: selected ? 2 : 1,
86
+ alpha: selected ? .95 : .7
87
+ });
88
+ }
89
+ }
90
+ const imageCardRenderer = {
91
+ id: "image-card",
92
+ canRender: (item) => item.type === "image",
93
+ create: (item, context) => {
94
+ const root = new Container();
95
+ const sprite = new Sprite(Texture.EMPTY);
96
+ const placeholder = new Graphics();
97
+ const mask = new Graphics();
98
+ const maskBox = () => {
99
+ mask.clear();
100
+ if (item.type === "image") mask.roundRect(0, 0, item.frame.width, item.frame.height, RADIUS).fill({ color: 16777215 });
101
+ };
102
+ maskBox();
103
+ root.addChild(placeholder, sprite, mask);
104
+ sprite.mask = mask;
105
+ partsByContainer.set(root, {
106
+ root,
107
+ sprite,
108
+ placeholder,
109
+ sig: ""
110
+ });
111
+ if (item.type === "image") sync(root, item, context);
112
+ return root;
113
+ },
114
+ update: (container, item, context) => {
115
+ if (item.type !== "image") return;
116
+ const parts = partsByContainer.get(container);
117
+ if (!parts) return;
118
+ const mask = parts.root.children.find((child) => child !== parts.sprite && child !== parts.placeholder);
119
+ if (mask) mask.clear().roundRect(0, 0, item.frame.width, item.frame.height, RADIUS).fill({ color: 16777215 });
120
+ sync(container, item, context);
121
+ },
122
+ renderFar: (graphics, item, context) => {
123
+ drawFarPlate(graphics, item.frame, {
124
+ fill: context.palette.hover,
125
+ fillAlpha: .9
126
+ });
127
+ },
128
+ destroy: (container) => {
129
+ partsByContainer.get(container)?.root.destroy({ children: true });
130
+ partsByContainer.delete(container);
131
+ }
132
+ };
133
+ /** Helper kept for type narrowing in tests. */
134
+ function isImageItem(item) {
135
+ return item.type === "image";
136
+ }
137
+ //#endregion
138
+ export { imageCardRenderer, isImageItem };
@@ -0,0 +1,7 @@
1
+ import { BoardItem, BoardTextItem } from "../../../protocol/dist/board-document.js";
2
+ import { BoardCardRenderer } from "./board-renderer-registry.js";
3
+ //#region src/board/render/renderers/text-card-renderer.d.ts
4
+ declare const textCardRenderer: BoardCardRenderer;
5
+ declare function isTextItem(item: BoardItem): item is BoardTextItem;
6
+ //#endregion
7
+ export { isTextItem, textCardRenderer };
@@ -0,0 +1,94 @@
1
+ import { BOARD_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
+ import { TEXT_FONT_SIZE, boardTextLineHeight, clampBoardTextFontSize, measureBoardText } from "../../core/text-metrics.js";
3
+ import { pickBoardColor } from "../../core/palette.js";
4
+ import { syncTextResolution, textResolutionForZoom } from "../text-resolution.js";
5
+ import { drawFarPlate } from "./far-plate.js";
6
+ import { positionShell } from "./base-card-renderer.js";
7
+ import { Container, Text } from "pixi.js";
8
+ //#region src/board/render/renderers/text-card-renderer.ts
9
+ const partsByContainer = /* @__PURE__ */ new WeakMap();
10
+ function sync(container, item, context) {
11
+ const parts = partsByContainer.get(container);
12
+ if (!parts) return;
13
+ positionShell(parts.root, item);
14
+ const fontSize = clampBoardTextFontSize(item.fontSize);
15
+ const measured = measureBoardText(item.text, fontSize);
16
+ const previewScale = Math.max(1e-4, item.frame.width / Math.max(1e-4, measured.width));
17
+ parts.body.scale.set(previewScale);
18
+ parts.body.position.set(item.frame.width / 2, item.frame.height / 2);
19
+ syncTextResolution(parts.body, parts, context.zoom * Math.max(1, previewScale));
20
+ const color = pickBoardColor(context.colors, item.color || "neutral", context.colorScheme);
21
+ const ink = item.color === "neutral" || !item.color ? context.palette.text : color.stroke;
22
+ const contentSig = [
23
+ item.text,
24
+ item.color,
25
+ fontSize,
26
+ context.colorScheme,
27
+ context.colors.brand.stroke,
28
+ context.palette.text
29
+ ].join("|");
30
+ if (contentSig === parts.contentSig) return;
31
+ parts.contentSig = contentSig;
32
+ if (parts.body.text !== item.text) parts.body.text = item.text || "";
33
+ parts.body.style.fill = ink;
34
+ parts.body.style.fontSize = fontSize;
35
+ parts.body.style.lineHeight = boardTextLineHeight(fontSize);
36
+ }
37
+ const textCardRenderer = {
38
+ id: "text-card",
39
+ canRender: (item) => item.type === "text",
40
+ create: (item, context) => {
41
+ const root = new Container();
42
+ const resolution = textResolutionForZoom(context.zoom);
43
+ const color = pickBoardColor(context.colors, item.type === "text" ? item.color || "neutral" : "neutral", context.colorScheme);
44
+ const body = new Text({
45
+ text: "",
46
+ style: {
47
+ fill: item.type === "text" && (item.color === "neutral" || !item.color) ? context.palette.text : color.stroke,
48
+ fontFamily: BOARD_FONT_STACK,
49
+ fontSize: TEXT_FONT_SIZE,
50
+ fontWeight: "500",
51
+ lineHeight: boardTextLineHeight(TEXT_FONT_SIZE),
52
+ wordWrap: false
53
+ },
54
+ resolution,
55
+ roundPixels: true
56
+ });
57
+ body.anchor.set(.5);
58
+ root.addChild(body);
59
+ partsByContainer.set(root, {
60
+ root,
61
+ body,
62
+ resolution,
63
+ contentSig: ""
64
+ });
65
+ if (item.type === "text") sync(root, item, context);
66
+ return root;
67
+ },
68
+ update: (container, item, context) => {
69
+ if (item.type === "text") sync(container, item, context);
70
+ },
71
+ /**
72
+ * Far LOD: a low-alpha ink bar. Body text is well under a pixel tall at these
73
+ * zooms, so a rasterised glyph texture would only ever resolve to a smudge —
74
+ * this approximates that smudge for free and skips the texture entirely.
75
+ */
76
+ renderFar: (graphics, item, context) => {
77
+ if (item.type !== "text") return;
78
+ const color = pickBoardColor(context.colors, item.color || "neutral", context.colorScheme);
79
+ const ink = item.color === "neutral" || !item.color ? context.palette.text : color.stroke;
80
+ drawFarPlate(graphics, item.frame, {
81
+ fill: ink,
82
+ fillAlpha: .35
83
+ });
84
+ },
85
+ destroy: (container) => {
86
+ partsByContainer.get(container)?.root.destroy({ children: true });
87
+ partsByContainer.delete(container);
88
+ }
89
+ };
90
+ function isTextItem(item) {
91
+ return item.type === "text";
92
+ }
93
+ //#endregion
94
+ export { isTextItem, textCardRenderer };
@@ -0,0 +1,5 @@
1
+ import { BoardCardRenderer } from "./board-renderer-registry.js";
2
+ //#region src/board/render/renderers/unknown-card-renderer.d.ts
3
+ declare const unknownCardRenderer: BoardCardRenderer;
4
+ //#endregion
5
+ export { unknownCardRenderer };
@@ -0,0 +1,102 @@
1
+ import { BOARD_MONO_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
+ import { unknownRealType } from "../../../protocol/dist/board-document.js";
3
+ import { syncTextResolution, textResolutionForZoom } from "../text-resolution.js";
4
+ import { drawFarPlate } from "./far-plate.js";
5
+ import { positionShell } from "./base-card-renderer.js";
6
+ import { Container, Graphics, Text } from "pixi.js";
7
+ //#region src/board/render/renderers/unknown-card-renderer.ts
8
+ const RADIUS = 10;
9
+ const partsByContainer = /* @__PURE__ */ new WeakMap();
10
+ /**
11
+ * Neutral placeholder for shape types this client does not recognise. The shape
12
+ * stays fully interactive (select/move/resize via the generic box definition) and
13
+ * its data is preserved elsewhere — this is only its display.
14
+ */
15
+ function sync(container, item, context) {
16
+ const parts = partsByContainer.get(container);
17
+ if (!parts) return;
18
+ positionShell(parts.root, item);
19
+ const { width, height } = item.frame;
20
+ const selected = context.selectedIds.has(item.id);
21
+ const realType = unknownRealType(item);
22
+ syncTextResolution(parts.label, parts, context.zoom);
23
+ const visualSig = [
24
+ width,
25
+ height,
26
+ selected,
27
+ context.palette.surface,
28
+ context.palette.brand,
29
+ context.palette.border,
30
+ context.palette.muted
31
+ ].join("|");
32
+ if (visualSig !== parts.visualSig) {
33
+ parts.visualSig = visualSig;
34
+ parts.box.clear();
35
+ parts.box.roundRect(0, 0, width, height, RADIUS).fill({
36
+ color: context.palette.surface,
37
+ alpha: .7
38
+ }).roundRect(0, 0, width, height, RADIUS).stroke({
39
+ color: selected ? context.palette.brand : context.palette.border,
40
+ width: selected ? 2 : 1,
41
+ alpha: .8
42
+ });
43
+ parts.box.roundRect(6, 6, width - 12, height - 12, RADIUS - 4).stroke({
44
+ color: context.palette.muted,
45
+ width: 1,
46
+ alpha: .4
47
+ });
48
+ }
49
+ const textSig = [realType, context.palette.muted].join("|");
50
+ if (textSig !== parts.textSig) {
51
+ parts.textSig = textSig;
52
+ parts.label.text = realType;
53
+ parts.label.style.fill = context.palette.muted;
54
+ }
55
+ parts.label.position.set(width / 2, height / 2);
56
+ }
57
+ const unknownCardRenderer = {
58
+ id: "unknown-card",
59
+ canRender: (item) => "raw" in item,
60
+ create: (item, context) => {
61
+ const root = new Container();
62
+ const box = new Graphics();
63
+ const resolution = textResolutionForZoom(context.zoom);
64
+ const label = new Text({
65
+ text: "",
66
+ style: {
67
+ fontFamily: BOARD_MONO_FONT_STACK,
68
+ fontSize: 11,
69
+ fontWeight: "600"
70
+ },
71
+ resolution,
72
+ roundPixels: true
73
+ });
74
+ label.anchor.set(.5);
75
+ root.addChild(box, label);
76
+ partsByContainer.set(root, {
77
+ root,
78
+ box,
79
+ label,
80
+ visualSig: "",
81
+ textSig: "",
82
+ resolution
83
+ });
84
+ if ("raw" in item) sync(root, item, context);
85
+ return root;
86
+ },
87
+ update: (container, item, context) => {
88
+ if ("raw" in item) sync(container, item, context);
89
+ },
90
+ renderFar: (graphics, item, context) => {
91
+ drawFarPlate(graphics, item.frame, {
92
+ fill: context.palette.muted,
93
+ fillAlpha: .2
94
+ });
95
+ },
96
+ destroy: (container) => {
97
+ partsByContainer.get(container)?.root.destroy({ children: true });
98
+ partsByContainer.delete(container);
99
+ }
100
+ };
101
+ //#endregion
102
+ export { unknownCardRenderer };
@@ -0,0 +1,7 @@
1
+ import { BoardItem, BoardVideoItem } from "../../../protocol/dist/board-document.js";
2
+ import { BoardCardRenderer } from "./board-renderer-registry.js";
3
+ //#region src/board/render/renderers/video-card-renderer.d.ts
4
+ declare const videoCardRenderer: BoardCardRenderer;
5
+ declare function isVideoItem(item: BoardItem): item is BoardVideoItem;
6
+ //#endregion
7
+ export { isVideoItem, videoCardRenderer };
@@ -0,0 +1,146 @@
1
+ import { BOARD_FONT_STACK } from "../../../protocol/dist/board-constants.js";
2
+ import { syncTextResolution, syncTextWrapWidth, textResolutionForZoom } from "../text-resolution.js";
3
+ import { drawFarPlate } from "./far-plate.js";
4
+ import { positionShell } from "./base-card-renderer.js";
5
+ import { Container, Graphics, Sprite, Text, Texture } from "pixi.js";
6
+ //#region src/board/render/renderers/video-card-renderer.ts
7
+ const partsByContainer = /* @__PURE__ */ new WeakMap();
8
+ const RADIUS = 4;
9
+ function layoutContain(sprite, width, height) {
10
+ const texture = sprite.texture;
11
+ if (!texture.width || !texture.height) return;
12
+ const scale = Math.min(width / texture.width, height / texture.height);
13
+ sprite.width = texture.width * scale;
14
+ sprite.height = texture.height * scale;
15
+ sprite.x = (width - sprite.width) / 2;
16
+ sprite.y = (height - sprite.height) / 2;
17
+ }
18
+ function sync(container, item, context) {
19
+ const parts = partsByContainer.get(container);
20
+ if (!parts) return;
21
+ positionShell(parts.root, item);
22
+ const { width, height } = item.frame;
23
+ const selected = context.selectedIds.has(item.id);
24
+ const hovered = context.hoveredId === item.id;
25
+ const resizing = context.resizingIds.has(item.id);
26
+ const title = item.snapshot?.title ?? item.ref.path.split("/").pop() ?? "Video";
27
+ const key = context.assetKey(item);
28
+ const texture = key ? context.getTexture(key) : null;
29
+ const failed = Boolean(key && !texture && context.hasError(key));
30
+ syncTextResolution(parts.label, parts, context.zoom);
31
+ if (parts.label.text !== title) parts.label.text = title;
32
+ syncTextWrapWidth(parts.label, parts, Math.max(1, width - 16), resizing);
33
+ parts.label.position.set(8, Math.max(8, height - parts.label.height - 8));
34
+ const sig = [
35
+ key ?? "",
36
+ texture ? `${texture.width}x${texture.height}` : "none",
37
+ width,
38
+ height,
39
+ selected,
40
+ hovered,
41
+ failed,
42
+ title,
43
+ parts.label.height,
44
+ context.palette.surface,
45
+ context.palette.brand,
46
+ context.palette.muted,
47
+ context.palette.border,
48
+ context.palette.hover,
49
+ context.palette.text
50
+ ].join("|");
51
+ if (sig === parts.sig) return;
52
+ parts.sig = sig;
53
+ if (texture && parts.sprite.texture !== texture) parts.sprite.texture = texture;
54
+ parts.sprite.visible = Boolean(texture);
55
+ if (texture) layoutContain(parts.sprite, width, height);
56
+ parts.mask.clear().roundRect(0, 0, width, height, RADIUS).fill({ color: 16777215 });
57
+ parts.plate.clear().roundRect(0, 0, width, height, RADIUS).fill({
58
+ color: context.palette.surface,
59
+ alpha: .96
60
+ });
61
+ parts.chrome.clear();
62
+ if (texture) {
63
+ const bandHeight = Math.min(height, parts.label.height + 16);
64
+ parts.chrome.rect(0, height - bandHeight, width, bandHeight).fill({
65
+ color: context.palette.surface,
66
+ alpha: .78
67
+ });
68
+ }
69
+ const cx = width / 2;
70
+ const cy = height / 2;
71
+ const radius = Math.min(22, Math.min(width, height) * .18);
72
+ parts.chrome.circle(cx, cy, radius).fill({
73
+ color: selected ? context.palette.brand : context.palette.surface,
74
+ alpha: texture ? .88 : .96
75
+ });
76
+ const triangle = radius * .55;
77
+ parts.chrome.moveTo(cx - triangle * .35, cy - triangle * .6).lineTo(cx - triangle * .35, cy + triangle * .6).lineTo(cx + triangle * .7, cy).closePath().fill({
78
+ color: context.palette.text,
79
+ alpha: .94
80
+ });
81
+ parts.chrome.roundRect(0, 0, width, height, RADIUS).stroke({
82
+ color: selected ? context.palette.brand : hovered ? context.palette.muted : context.palette.border,
83
+ width: selected ? 2 : 1,
84
+ alpha: selected ? .95 : .85
85
+ });
86
+ parts.label.style.fill = texture ? context.palette.text : context.palette.muted;
87
+ }
88
+ const videoCardRenderer = {
89
+ id: "video-card",
90
+ canRender: (item) => item.type === "video",
91
+ create: (item, context) => {
92
+ const root = new Container();
93
+ const plate = new Graphics();
94
+ const sprite = new Sprite(Texture.EMPTY);
95
+ const mask = new Graphics();
96
+ const chrome = new Graphics();
97
+ const resolution = textResolutionForZoom(context.zoom);
98
+ const label = new Text({
99
+ text: "",
100
+ style: {
101
+ fill: context.palette.muted,
102
+ fontFamily: BOARD_FONT_STACK,
103
+ fontSize: 12,
104
+ fontWeight: "500",
105
+ wordWrap: true
106
+ },
107
+ resolution,
108
+ roundPixels: true
109
+ });
110
+ root.addChild(plate, sprite, mask, chrome, label);
111
+ sprite.mask = mask;
112
+ partsByContainer.set(root, {
113
+ root,
114
+ plate,
115
+ sprite,
116
+ mask,
117
+ chrome,
118
+ label,
119
+ sig: "",
120
+ wrapWidth: 0,
121
+ resolution
122
+ });
123
+ if (item.type === "video") sync(root, item, context);
124
+ return root;
125
+ },
126
+ update: (container, item, context) => {
127
+ if (item.type === "video") sync(container, item, context);
128
+ },
129
+ renderFar: (graphics, item, context) => {
130
+ drawFarPlate(graphics, item.frame, {
131
+ fill: context.palette.surface,
132
+ fillAlpha: .96,
133
+ accent: context.palette.brand,
134
+ accentAlpha: .75
135
+ });
136
+ },
137
+ destroy: (container) => {
138
+ partsByContainer.get(container)?.root.destroy({ children: true });
139
+ partsByContainer.delete(container);
140
+ }
141
+ };
142
+ function isVideoItem(item) {
143
+ return item.type === "video";
144
+ }
145
+ //#endregion
146
+ export { isVideoItem, videoCardRenderer };
@@ -0,0 +1,30 @@
1
+ //#region src/board/render/text-measurement.d.ts
2
+ /**
3
+ * Canvas-backed text measurement.
4
+ *
5
+ * Measurement goes through Pixi's `DOMAdapter` rather than `document`, so the
6
+ * browser and a headless export produce the same numbers. The core metrics
7
+ * module owns the layout maths and only borrows this measurer, which is what
8
+ * lets `@neta-art/cohub/board` lay text out with no PixiJS installed.
9
+ */
10
+ /**
11
+ * Route board text measurement through the active Pixi DOM adapter.
12
+ *
13
+ * Call this after swapping the adapter (see `../headless`), which drops the
14
+ * cached measuring context. Anything that goes through `getBoardCardRenderer`
15
+ * is already covered by `ensureBoardTextMeasurement`.
16
+ */
17
+ declare function installBoardTextMeasurement(): void;
18
+ /**
19
+ * Install the canvas measurer once, unless it already is.
20
+ *
21
+ * The core metrics module falls back to a per-character estimate when no
22
+ * measurer is set, which is off by ~90% for real text — wrong enough to
23
+ * misplace every label. Rather than make that a step consumers can forget, the
24
+ * renderer registry calls this before handing a renderer out. It cannot be an
25
+ * import-time side effect: the package is marked side-effect free, so a bundler
26
+ * is entitled to drop a module whose only job happens at load time.
27
+ */
28
+ declare function ensureBoardTextMeasurement(): void;
29
+ //#endregion
30
+ export { ensureBoardTextMeasurement, installBoardTextMeasurement };
@@ -0,0 +1,57 @@
1
+ import { BOARD_FONT_STACK } from "../../protocol/dist/board-constants.js";
2
+ import { setBoardTextMeasurer } from "../core/text-metrics.js";
3
+ import { DOMAdapter } from "pixi.js";
4
+ //#region src/board/render/text-measurement.ts
5
+ /**
6
+ * Canvas-backed text measurement.
7
+ *
8
+ * Measurement goes through Pixi's `DOMAdapter` rather than `document`, so the
9
+ * browser and a headless export produce the same numbers. The core metrics
10
+ * module owns the layout maths and only borrows this measurer, which is what
11
+ * lets `@neta-art/cohub/board` lay text out with no PixiJS installed.
12
+ */
13
+ let measureContext;
14
+ let installed = false;
15
+ function getMeasureContext() {
16
+ if (measureContext !== void 0) return measureContext;
17
+ try {
18
+ measureContext = DOMAdapter.get().createCanvas(1, 1).getContext("2d") ?? null;
19
+ } catch {
20
+ measureContext = null;
21
+ }
22
+ return measureContext;
23
+ }
24
+ function measureText(text, fontSize) {
25
+ const context = getMeasureContext();
26
+ if (!context) return null;
27
+ context.font = `500 ${fontSize}px ${BOARD_FONT_STACK}`;
28
+ return context.measureText(text).width;
29
+ }
30
+ /**
31
+ * Route board text measurement through the active Pixi DOM adapter.
32
+ *
33
+ * Call this after swapping the adapter (see `../headless`), which drops the
34
+ * cached measuring context. Anything that goes through `getBoardCardRenderer`
35
+ * is already covered by `ensureBoardTextMeasurement`.
36
+ */
37
+ function installBoardTextMeasurement() {
38
+ measureContext = void 0;
39
+ installed = true;
40
+ setBoardTextMeasurer(measureText);
41
+ }
42
+ /**
43
+ * Install the canvas measurer once, unless it already is.
44
+ *
45
+ * The core metrics module falls back to a per-character estimate when no
46
+ * measurer is set, which is off by ~90% for real text — wrong enough to
47
+ * misplace every label. Rather than make that a step consumers can forget, the
48
+ * renderer registry calls this before handing a renderer out. It cannot be an
49
+ * import-time side effect: the package is marked side-effect free, so a bundler
50
+ * is entitled to drop a module whose only job happens at load time.
51
+ */
52
+ function ensureBoardTextMeasurement() {
53
+ if (installed) return;
54
+ installBoardTextMeasurement();
55
+ }
56
+ //#endregion
57
+ export { ensureBoardTextMeasurement, installBoardTextMeasurement };
@@ -0,0 +1,16 @@
1
+ import { Text } from "pixi.js";
2
+ //#region src/board/render/text-resolution.d.ts
3
+ declare function getBoardResolution(): number;
4
+ declare function textZoomBucket(zoom: number): number;
5
+ /** Effective text resolution for a given camera zoom. */
6
+ declare function textResolutionForZoom(zoom: number): number;
7
+ /** Update a Pixi text texture only when zoom crosses a resolution bucket. */
8
+ declare function syncTextResolution(text: Text, state: {
9
+ resolution: number;
10
+ }, zoom: number): void;
11
+ /** Defer expensive Pixi word-wrap rasterisation during a live resize. */
12
+ declare function syncTextWrapWidth(text: Text, state: {
13
+ wrapWidth: number;
14
+ }, width: number, defer: boolean): void;
15
+ //#endregion
16
+ export { getBoardResolution, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket };
@@ -0,0 +1,43 @@
1
+ //#region src/board/render/text-resolution.ts
2
+ const MAX_BOARD_RESOLUTION = 2;
3
+ function getBoardResolution() {
4
+ return Math.min(globalThis.devicePixelRatio || 1, MAX_BOARD_RESOLUTION);
5
+ }
6
+ /**
7
+ * Zoom buckets for text re-rasterisation. Pixi Text is a bitmap: at high zoom it
8
+ * looks soft unless we raise its resolution. We quantise zoom into a few
9
+ * buckets so small zoom changes don't thrash texture regeneration.
10
+ */
11
+ const TEXT_ZOOM_BUCKETS = [
12
+ .5,
13
+ 1,
14
+ 1.5,
15
+ 2,
16
+ 3,
17
+ 4
18
+ ];
19
+ function textZoomBucket(zoom) {
20
+ const clamped = Math.max(.1, Math.min(4, zoom));
21
+ for (const bucket of TEXT_ZOOM_BUCKETS) if (clamped <= bucket) return bucket;
22
+ return TEXT_ZOOM_BUCKETS[TEXT_ZOOM_BUCKETS.length - 1] ?? 4;
23
+ }
24
+ /** Effective text resolution for a given camera zoom. */
25
+ function textResolutionForZoom(zoom) {
26
+ const bucket = textZoomBucket(zoom);
27
+ return Math.min(getBoardResolution() * Math.max(1, bucket), MAX_BOARD_RESOLUTION * 3);
28
+ }
29
+ /** Update a Pixi text texture only when zoom crosses a resolution bucket. */
30
+ function syncTextResolution(text, state, zoom) {
31
+ const resolution = textResolutionForZoom(zoom);
32
+ if (resolution === state.resolution) return;
33
+ text.resolution = resolution;
34
+ state.resolution = resolution;
35
+ }
36
+ /** Defer expensive Pixi word-wrap rasterisation during a live resize. */
37
+ function syncTextWrapWidth(text, state, width, defer) {
38
+ if (defer || width === state.wrapWidth) return;
39
+ text.style.wordWrapWidth = width;
40
+ state.wrapWidth = width;
41
+ }
42
+ //#endregion
43
+ export { getBoardResolution, syncTextResolution, syncTextWrapWidth, textResolutionForZoom, textZoomBucket };
@@ -0,0 +1,22 @@
1
+ import { BoardDocument, BoardViewport } from "../../../protocol/dist/board-document.js";
2
+ import { BoardRenderPalette } from "../renderers/board-renderer-registry.js";
3
+ import { Application, Container } from "pixi.js";
4
+ //#region src/board/render/themes/board-theme-registry.d.ts
5
+ type BoardThemeContext = {
6
+ app: Application;
7
+ document: BoardDocument;
8
+ viewport: BoardViewport;
9
+ palette: BoardRenderPalette;
10
+ /** The host renders an image backdrop below the transparent Pixi canvas. */
11
+ hasImageBackground?: boolean;
12
+ };
13
+ type BoardThemeRenderer = {
14
+ id: string;
15
+ canRender: (document: BoardDocument) => boolean;
16
+ createBackground: (context: BoardThemeContext) => Container;
17
+ updateBackground?: (container: Container, context: BoardThemeContext) => void;
18
+ };
19
+ declare function getBoardThemeRenderer(document: BoardDocument): BoardThemeRenderer;
20
+ declare function registerBoardThemeRenderer(renderer: BoardThemeRenderer): void;
21
+ //#endregion
22
+ export { BoardThemeContext, BoardThemeRenderer, getBoardThemeRenderer, registerBoardThemeRenderer };