@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,282 @@
1
+ //#region src/board/core/file-preview.ts
2
+ /**
3
+ * File-card preview derivation — pure, renderer-agnostic, dependency-free.
4
+ *
5
+ * A board file card is a *thumbnail entry point* to a workspace file, never a
6
+ * second copy of it. So everything here derives display facts from file content
7
+ * that the caller already has, and the resulting snapshot is treated strictly as
8
+ * a cache keyed by the file's mtime: the file on disk stays the single source of
9
+ * truth, and nothing in this module ever writes back to it.
10
+ *
11
+ * Three presentation tiers fall out of which facts are available, so an
12
+ * unrecognised file degrades instead of being rejected:
13
+ *
14
+ * - `cover` — a cover image was declared in markdown frontmatter;
15
+ * - `text` — text-like content yielded a readable excerpt;
16
+ * - `blank` — anything else (binary, empty, oversized): icon, name and type.
17
+ */
18
+ /** Hard cap on a stored excerpt. Board cards show a few lines at most, and the
19
+ * snapshot rides along in every board transaction — so this stays small. */
20
+ const FILE_EXCERPT_MAX_CHARS = 480;
21
+ /** Files above this size are shown as `blank`; we never pull them for a preview. */
22
+ const FILE_EXCERPT_MAX_BYTES = 256 * 1024;
23
+ /** Frontmatter keys checked for a cover image, in precedence order. */
24
+ const COVER_KEYS = [
25
+ "cover",
26
+ "coverImage",
27
+ "cover_image",
28
+ "image",
29
+ "banner",
30
+ "thumbnail",
31
+ "ogImage",
32
+ "og:image"
33
+ ];
34
+ /**
35
+ * Which tier a card renders at. Derived rather than stored: presentation follows
36
+ * from the facts present, so there is no second piece of state to fall out of
37
+ * sync with them.
38
+ */
39
+ function filePreviewKind(snapshot) {
40
+ if (!snapshot) return "blank";
41
+ if (snapshot.coverPath || snapshot.coverUrl) return "cover";
42
+ if (snapshot.excerpt) return "text";
43
+ return "blank";
44
+ }
45
+ /** Short uppercase type label for the card's meta line (`MD`, `JSON`, `FILE`). */
46
+ function fileTypeLabel(path) {
47
+ const name = path.split("/").filter(Boolean).pop() ?? path;
48
+ const dot = name.lastIndexOf(".");
49
+ if (dot <= 0) return (name.replace(/^\./, "") || "file").toUpperCase();
50
+ return name.slice(dot + 1).toUpperCase();
51
+ }
52
+ /** Human-readable byte size for the meta line. */
53
+ function formatFileSize(bytes) {
54
+ if (bytes === void 0 || !Number.isFinite(bytes) || bytes < 0) return "";
55
+ if (bytes < 1024) return `${bytes} B`;
56
+ const units = [
57
+ "KB",
58
+ "MB",
59
+ "GB",
60
+ "TB"
61
+ ];
62
+ let value = bytes / 1024;
63
+ let unit = 0;
64
+ while (value >= 1024 && unit < units.length - 1) {
65
+ value /= 1024;
66
+ unit += 1;
67
+ }
68
+ return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
69
+ }
70
+ /** Normalise a space-relative path: resolve `.`/`..` against the file's dir. */
71
+ function resolveSpacePath(fromFilePath, ref) {
72
+ const base = ref.startsWith("/") ? [] : fromFilePath.split("/").slice(0, -1).filter(Boolean);
73
+ const segments = ref.replace(/^\//, "").split("/");
74
+ const out = [...base];
75
+ for (const segment of segments) {
76
+ if (!segment || segment === ".") continue;
77
+ if (segment === "..") {
78
+ out.pop();
79
+ continue;
80
+ }
81
+ out.push(segment);
82
+ }
83
+ return out.join("/");
84
+ }
85
+ /**
86
+ * Classify a raw cover reference from frontmatter.
87
+ *
88
+ * Remote covers are allowed on purpose — a lot of real markdown points at a CDN.
89
+ * The trade-off is that opening such a board issues a request to that third
90
+ * party; the renderer degrades silently if it fails, and nothing is retried in a
91
+ * loop.
92
+ */
93
+ function resolveCoverRef(fromFilePath, raw) {
94
+ const value = (raw ?? "").trim();
95
+ if (!value) return null;
96
+ if (value.startsWith("//")) return {
97
+ kind: "url",
98
+ url: `https:${value}`
99
+ };
100
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
101
+ if (scheme) {
102
+ if (scheme === "https") return {
103
+ kind: "url",
104
+ url: value
105
+ };
106
+ return null;
107
+ }
108
+ const path = resolveSpacePath(fromFilePath, value);
109
+ return path ? {
110
+ kind: "path",
111
+ path
112
+ } : null;
113
+ }
114
+ /**
115
+ * Split leading YAML frontmatter from a markdown source. Deliberately minimal:
116
+ * this only needs the raw block so a handful of scalar keys can be read, not a
117
+ * YAML parser.
118
+ */
119
+ function splitFrontmatter(source) {
120
+ if (!/^---[ \t]*\r?\n/.test(source)) return {
121
+ frontmatter: null,
122
+ body: source
123
+ };
124
+ const lines = source.split(/\r?\n/);
125
+ for (let index = 1; index < lines.length; index += 1) {
126
+ const line = lines[index] ?? "";
127
+ if (line === "---" || line === "...") return {
128
+ frontmatter: lines.slice(1, index).join("\n"),
129
+ body: lines.slice(index + 1).join("\n")
130
+ };
131
+ }
132
+ return {
133
+ frontmatter: null,
134
+ body: source
135
+ };
136
+ }
137
+ function unquote(value) {
138
+ const trimmed = value.trim();
139
+ if (trimmed.length < 2) return trimmed;
140
+ const first = trimmed[0];
141
+ const last = trimmed[trimmed.length - 1];
142
+ if (first === "\"" && last === "\"" || first === "'" && last === "'") return trimmed.slice(1, -1);
143
+ return trimmed;
144
+ }
145
+ /** Read non-empty top-level scalar values from a frontmatter block. */
146
+ function readFrontmatterScalars(frontmatter) {
147
+ const found = /* @__PURE__ */ new Map();
148
+ if (!frontmatter) return found;
149
+ for (const line of frontmatter.split(/\r?\n/)) {
150
+ if (!line.trim() || /^\s/.test(line) || line.trimStart().startsWith("#")) continue;
151
+ const match = /^([A-Za-z0-9_:.-]+):(.*)$/.exec(line);
152
+ if (!match) continue;
153
+ const key = match[1];
154
+ const value = unquote(match[2] ?? "");
155
+ if (key && value && !found.has(key)) found.set(key, value);
156
+ }
157
+ return found;
158
+ }
159
+ /** Read the document title declared by top-level frontmatter. */
160
+ function readTitleFromFrontmatter(frontmatter) {
161
+ return readFrontmatterScalars(frontmatter).get("title") ?? null;
162
+ }
163
+ /**
164
+ * Read the first cover-ish scalar from a frontmatter block.
165
+ *
166
+ * Only top-level keys are considered (a leading-space line is nested and skipped)
167
+ * so `cover:` under some other mapping cannot be mistaken for the file's own.
168
+ */
169
+ function readCoverFromFrontmatter(frontmatter) {
170
+ const found = readFrontmatterScalars(frontmatter);
171
+ for (const key of COVER_KEYS) {
172
+ const value = found.get(key);
173
+ if (value) return value;
174
+ }
175
+ return null;
176
+ }
177
+ /**
178
+ * Reduce source text to a short, readable excerpt.
179
+ *
180
+ * The goal is a card-sized hint of what the file is about, so markdown
181
+ * decoration is flattened rather than rendered: fenced code is dropped whole
182
+ * (it reads as noise at thumbnail size), headings and emphasis lose their
183
+ * markers, links keep their text, and blank runs collapse.
184
+ */
185
+ function buildFileExcerpt(source, limit = 480) {
186
+ if (!source) return "";
187
+ const cleaned = source.replace(/```[\s\S]*?(?:```|$)/g, " ").replace(/<!--[\s\S]*?-->/g, " ").replace(/<\/?[a-z][^>]*>/gi, " ").replace(/!\[[^\]]*\]\([^)]*\)/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/^[ \t]*#{1,6}[ \t]+/gm, "").replace(/^[ \t]*>[ \t]?/gm, "").replace(/^[ \t]*[-*+][ \t]+/gm, "").replace(/^[ \t]*\d+\.[ \t]+/gm, "").replace(/^[ \t]*([-*_])(?:[ \t]*\1){2,}[ \t]*$/gm, " ").replace(/\|/g, " ").replace(/[*_`~]/g, "").replace(/\r\n?/g, "\n").replace(/[ \t]+/g, " ").replace(/\n{2,}/g, "\n").replace(/[ \t]*\n[ \t]*/g, "\n").trim();
188
+ if (cleaned.length <= limit) return cleaned;
189
+ const slice = cleaned.slice(0, limit);
190
+ const lastSpace = slice.lastIndexOf(" ");
191
+ return `${(lastSpace > limit * .6 ? slice.slice(0, lastSpace) : slice).trimEnd()}…`;
192
+ }
193
+ /** Basename of a path, used as the default card title. */
194
+ function fileBaseName(path) {
195
+ return path.split("/").filter(Boolean).pop() ?? path;
196
+ }
197
+ /**
198
+ * Build the cached display facts for a file node.
199
+ *
200
+ * Content is optional by design: a snapshot built without it still produces a
201
+ * usable `blank` card, so a node can be created the instant a file is dropped
202
+ * and enriched later without blocking on a read.
203
+ */
204
+ function buildFileSnapshot(input) {
205
+ const snapshot = { title: input.title?.trim() || fileBaseName(input.path) };
206
+ if (input.mimeType) snapshot.mimeType = input.mimeType;
207
+ if (typeof input.size === "number" && Number.isFinite(input.size)) snapshot.size = input.size;
208
+ if (typeof input.mtimeMs === "number" && Number.isFinite(input.mtimeMs)) snapshot.mtimeMs = input.mtimeMs;
209
+ const content = input.content;
210
+ if (typeof content !== "string" || content.length === 0) return snapshot;
211
+ const { frontmatter, body } = splitFrontmatter(content);
212
+ const frontmatterTitle = readTitleFromFrontmatter(frontmatter);
213
+ if (frontmatterTitle) snapshot.title = frontmatterTitle;
214
+ const cover = resolveCoverRef(input.path, readCoverFromFrontmatter(frontmatter));
215
+ if (cover?.kind === "url") snapshot.coverUrl = cover.url;
216
+ else if (cover?.kind === "path") snapshot.coverPath = cover.path;
217
+ const excerpt = buildFileExcerpt(body);
218
+ if (excerpt) snapshot.excerpt = excerpt;
219
+ return snapshot;
220
+ }
221
+ /** Whether a snapshot's cached facts still describe the file on disk. */
222
+ function isFileSnapshotFresh(snapshot, file) {
223
+ if (!snapshot) return false;
224
+ if (snapshot.mtimeMs === void 0 || file.mtimeMs === void 0) return false;
225
+ if (snapshot.mtimeMs !== file.mtimeMs) return false;
226
+ if (snapshot.size !== void 0 && file.size !== void 0 && snapshot.size !== file.size) return false;
227
+ return true;
228
+ }
229
+ /** Whether a file is small enough that fetching a text preview is worthwhile. */
230
+ function shouldFetchFileExcerpt(input) {
231
+ if (input.size !== void 0 && input.size > 262144) return false;
232
+ return true;
233
+ }
234
+ /**
235
+ * Classify a failed read.
236
+ *
237
+ * Only a 404 (or 410 Gone) is treated as the file being absent. Everything else
238
+ * — offline, 5xx, timeout, 401/403 — is `unavailable`: the file may well exist and
239
+ * simply not be reachable by this client right now.
240
+ */
241
+ function availabilityFromError(error) {
242
+ const status = typeof error === "object" && error !== null ? error.status : void 0;
243
+ if (typeof status !== "number") return "unavailable";
244
+ if (status === 404 || status === 410) return "missing";
245
+ return "unavailable";
246
+ }
247
+ /**
248
+ * Cache key for a file within a space.
249
+ *
250
+ * A path only means anything relative to its space, and identical paths across
251
+ * spaces are the norm ("README.md"), so every preview cache is keyed by both. The
252
+ * separator is NUL because it cannot appear in a space id or a path, so no path can
253
+ * be crafted to collide with another space's entry.
254
+ */
255
+ function filePreviewScope(spaceId, path) {
256
+ return `${spaceId}\u0000${path}`;
257
+ }
258
+ /**
259
+ * Cache key for one *version* of a file. The mtime is part of the key so a changed
260
+ * file misses the cache instead of serving a stale excerpt or cover.
261
+ */
262
+ function filePreviewMemoKey(spaceId, path, mtimeMs) {
263
+ return `${filePreviewScope(spaceId, path)}@${mtimeMs ?? 0}`;
264
+ }
265
+ /**
266
+ * Fold a freshly read snapshot into the cached one.
267
+ *
268
+ * `complete` decides whether the incoming facts supersede the cached ones or are
269
+ * merged over them, and the difference is not cosmetic. A snapshot omits fields the
270
+ * file does not have, so merging a complete read would resurrect a cover or an
271
+ * excerpt the file no longer contains — permanently, since the stale value is then
272
+ * committed back. Merging is right only for an incomplete read, where an absent
273
+ * field means "could not establish", not "not there".
274
+ */
275
+ function mergeFileSnapshot(cached, incoming, complete) {
276
+ return complete ? incoming : {
277
+ ...cached,
278
+ ...incoming
279
+ };
280
+ }
281
+ //#endregion
282
+ export { FILE_EXCERPT_MAX_BYTES, FILE_EXCERPT_MAX_CHARS, availabilityFromError, buildFileExcerpt, buildFileSnapshot, fileBaseName, filePreviewKind, filePreviewMemoKey, filePreviewScope, fileTypeLabel, formatFileSize, isFileSnapshotFresh, mergeFileSnapshot, readCoverFromFrontmatter, readTitleFromFrontmatter, resolveCoverRef, resolveSpacePath, shouldFetchFileExcerpt, splitFrontmatter };
@@ -0,0 +1,42 @@
1
+ //#region src/board/core/palette.d.ts
2
+ /**
3
+ * Board color palette — a small, named set of shape colors shared by text,
4
+ * geo, draw and arrow shapes. Colors are stored by id in shape props (never
5
+ * raw hex), so themes and space `.cohub/theme.css` can remap them via CSS
6
+ * tokens while persisted data stays compact and forward-compatible.
7
+ *
8
+ * Concrete values are resolved from CSS variables at render time:
9
+ * --board-color-{id}-stroke | -fill | -label
10
+ * with hard-coded light/dark tables as offline / export fallbacks.
11
+ */
12
+ type BoardColorId = "brand" | "neutral" | "black" | "white" | "blue" | "green" | "amber" | "violet" | "rose";
13
+ type BoardColorValue = {
14
+ /** Stroke / accent color (sRGB hex). */
15
+ stroke: number;
16
+ /** Translucent fill used behind shapes (sRGB hex). */
17
+ fill: number;
18
+ /** Readable label color on top of the fill. */
19
+ label: number;
20
+ };
21
+ type BoardColorEntry = {
22
+ id: BoardColorId;
23
+ label: string;
24
+ dark: BoardColorValue;
25
+ light: BoardColorValue;
26
+ };
27
+ declare const BOARD_COLORS: readonly BoardColorEntry[];
28
+ declare const DEFAULT_BOARD_COLOR: BoardColorId;
29
+ declare function isBoardColorId(value: unknown): value is BoardColorId;
30
+ declare function boardColorCssVar(id: BoardColorId, part: keyof BoardColorValue): string;
31
+ /** Resolve a color id to concrete values for a color mode. Unknown → brand. */
32
+ declare function resolveBoardColor(id: unknown, mode: "dark" | "light"): BoardColorValue;
33
+ type BoardShapeColors = Record<BoardColorId, BoardColorValue>;
34
+ /** Build a full shape-color table from hard-coded fallbacks (export / SSR). */
35
+ declare function buildFallbackShapeColors(mode: "dark" | "light"): BoardShapeColors;
36
+ /**
37
+ * Pick a concrete color from a live shape-color table, falling back to the
38
+ * default brand entry when the id is unknown.
39
+ */
40
+ declare function pickBoardColor(colors: BoardShapeColors | null | undefined, id: unknown, mode?: "dark" | "light"): BoardColorValue;
41
+ //#endregion
42
+ export { BOARD_COLORS, BoardColorEntry, BoardColorId, BoardColorValue, BoardShapeColors, DEFAULT_BOARD_COLOR, boardColorCssVar, buildFallbackShapeColors, isBoardColorId, pickBoardColor, resolveBoardColor };
@@ -0,0 +1,166 @@
1
+ //#region src/board/core/palette.ts
2
+ /**
3
+ * Label color fallbacks mirror `--board-color-*-label`, which every theme maps
4
+ * to `--text-primary`: labels sit on a *translucent* fill over the page, not on
5
+ * a saturated swatch, so a tinted label would be dark-on-dark. Keeping these in
6
+ * step with `theme.css` is what makes a headless export match the screen.
7
+ */
8
+ const LABEL_DARK = 16053492;
9
+ const LABEL_LIGHT = 1579035;
10
+ const BOARD_COLORS = [
11
+ {
12
+ id: "brand",
13
+ label: "Brand",
14
+ dark: {
15
+ stroke: 16734751,
16
+ fill: 16734751,
17
+ label: LABEL_DARK
18
+ },
19
+ light: {
20
+ stroke: 15222030,
21
+ fill: 15222030,
22
+ label: LABEL_LIGHT
23
+ }
24
+ },
25
+ {
26
+ id: "neutral",
27
+ label: "Neutral",
28
+ dark: {
29
+ stroke: 10133670,
30
+ fill: 10133670,
31
+ label: LABEL_DARK
32
+ },
33
+ light: {
34
+ stroke: 6251368,
35
+ fill: 6251368,
36
+ label: LABEL_LIGHT
37
+ }
38
+ },
39
+ {
40
+ id: "black",
41
+ label: "Black",
42
+ dark: {
43
+ stroke: 0,
44
+ fill: 0,
45
+ label: LABEL_DARK
46
+ },
47
+ light: {
48
+ stroke: 0,
49
+ fill: 0,
50
+ label: LABEL_LIGHT
51
+ }
52
+ },
53
+ {
54
+ id: "white",
55
+ label: "White",
56
+ dark: {
57
+ stroke: 16777215,
58
+ fill: 16777215,
59
+ label: LABEL_DARK
60
+ },
61
+ light: {
62
+ stroke: 16777215,
63
+ fill: 16777215,
64
+ label: LABEL_LIGHT
65
+ }
66
+ },
67
+ {
68
+ id: "blue",
69
+ label: "Blue",
70
+ dark: {
71
+ stroke: 3718648,
72
+ fill: 3718648,
73
+ label: LABEL_DARK
74
+ },
75
+ light: {
76
+ stroke: 2450411,
77
+ fill: 2450411,
78
+ label: LABEL_LIGHT
79
+ }
80
+ },
81
+ {
82
+ id: "green",
83
+ label: "Green",
84
+ dark: {
85
+ stroke: 3462041,
86
+ fill: 3462041,
87
+ label: LABEL_DARK
88
+ },
89
+ light: {
90
+ stroke: 1483594,
91
+ fill: 1483594,
92
+ label: LABEL_LIGHT
93
+ }
94
+ },
95
+ {
96
+ id: "amber",
97
+ label: "Amber",
98
+ dark: {
99
+ stroke: 16096779,
100
+ fill: 16096779,
101
+ label: LABEL_DARK
102
+ },
103
+ light: {
104
+ stroke: 14251782,
105
+ fill: 14251782,
106
+ label: LABEL_LIGHT
107
+ }
108
+ },
109
+ {
110
+ id: "violet",
111
+ label: "Violet",
112
+ dark: {
113
+ stroke: 10980346,
114
+ fill: 10980346,
115
+ label: LABEL_DARK
116
+ },
117
+ light: {
118
+ stroke: 8141549,
119
+ fill: 8141549,
120
+ label: LABEL_LIGHT
121
+ }
122
+ },
123
+ {
124
+ id: "rose",
125
+ label: "Rose",
126
+ dark: {
127
+ stroke: 16478597,
128
+ fill: 16478597,
129
+ label: LABEL_DARK
130
+ },
131
+ light: {
132
+ stroke: 14753096,
133
+ fill: 14753096,
134
+ label: LABEL_LIGHT
135
+ }
136
+ }
137
+ ];
138
+ const DEFAULT_BOARD_COLOR = "brand";
139
+ const COLOR_INDEX = new Map(BOARD_COLORS.map((entry) => [entry.id, entry]));
140
+ function isBoardColorId(value) {
141
+ return typeof value === "string" && COLOR_INDEX.has(value);
142
+ }
143
+ function boardColorCssVar(id, part) {
144
+ return `--board-color-${id}-${part}`;
145
+ }
146
+ /** Resolve a color id to concrete values for a color mode. Unknown → brand. */
147
+ function resolveBoardColor(id, mode) {
148
+ return ((isBoardColorId(id) ? COLOR_INDEX.get(id) : void 0) ?? COLOR_INDEX.get("brand"))[mode];
149
+ }
150
+ /** Build a full shape-color table from hard-coded fallbacks (export / SSR). */
151
+ function buildFallbackShapeColors(mode) {
152
+ const out = {};
153
+ for (const entry of BOARD_COLORS) out[entry.id] = entry[mode];
154
+ return out;
155
+ }
156
+ /**
157
+ * Pick a concrete color from a live shape-color table, falling back to the
158
+ * default brand entry when the id is unknown.
159
+ */
160
+ function pickBoardColor(colors, id, mode = "dark") {
161
+ if (colors && isBoardColorId(id) && colors[id]) return colors[id];
162
+ if (colors) return colors[DEFAULT_BOARD_COLOR];
163
+ return resolveBoardColor(id, mode);
164
+ }
165
+ //#endregion
166
+ export { BOARD_COLORS, DEFAULT_BOARD_COLOR, boardColorCssVar, buildFallbackShapeColors, isBoardColorId, pickBoardColor, resolveBoardColor };
@@ -0,0 +1,35 @@
1
+ import { BoardFrame, BoardItem } from "../../protocol/dist/board-document.js";
2
+ import { Rect, WorldPoint } from "../geometry.js";
3
+ import { ShapeCapabilities, ShapeGeometry, ShapeHandle, ShapeResizeMode } from "./shape-types.js";
4
+ //#region src/board/core/shape-definition.d.ts
5
+ type ShapeDefinition = {
6
+ /** The item type this definition handles. */
7
+ type: string;
8
+ capabilities: ShapeCapabilities;
9
+ /** Rotation-aware world bounds (culling, marquee). Defaults to itemBounds. */
10
+ getBounds?: (item: BoardItem) => Rect;
11
+ /** Exact world-space containment (hit testing). Defaults to rotated rect. */
12
+ hitTest?: (item: BoardItem, point: WorldPoint) => boolean;
13
+ /** Interaction handles in the shape's local space. Defaults to none. */
14
+ getHandles?: (item: BoardItem) => ShapeHandle[];
15
+ /**
16
+ * Local-space geometry outline, used for precise hit tests and snapping.
17
+ * Optional; box shapes rely on the frame directly.
18
+ */
19
+ getGeometry?: (item: BoardItem) => ShapeGeometry;
20
+ };
21
+ declare function registerShapeDefinition(definition: ShapeDefinition): void;
22
+ declare function getShapeDefinition(type: string): ShapeDefinition | undefined;
23
+ /**
24
+ * The generic fallback for unknown shape types: a plain movable/resizable box.
25
+ * This guarantees a shape authored by a newer client is still interactive here.
26
+ */
27
+ declare const unknownShapeDefinition: ShapeDefinition;
28
+ declare function definitionForItem(item: BoardItem): ShapeDefinition;
29
+ declare function shapeBounds(item: BoardItem): Rect;
30
+ declare function shapeHitTest(item: BoardItem, point: WorldPoint): boolean;
31
+ declare function shapeHandles(item: BoardItem): ShapeHandle[];
32
+ declare function shapeCapabilities(item: BoardItem): ShapeCapabilities;
33
+ declare function shapeResizeMode(item: BoardItem): ShapeResizeMode;
34
+ //#endregion
35
+ export { type BoardFrame, ShapeDefinition, definitionForItem, getShapeDefinition, registerShapeDefinition, shapeBounds, shapeCapabilities, shapeHandles, shapeHitTest, shapeResizeMode, unknownShapeDefinition };
@@ -0,0 +1,64 @@
1
+ import { isUnknownItem } from "../../protocol/dist/board-document.js";
2
+ import { frameContainsPoint, itemBounds } from "../geometry.js";
3
+ import { resizeModeForCapabilities } from "./shape-types.js";
4
+ //#region src/board/core/shape-definition.ts
5
+ /**
6
+ * ShapeDefinition protocol + registry.
7
+ *
8
+ * A ShapeDefinition describes everything the editor needs to *behave* correctly
9
+ * around a shape — bounds, hit testing, handles, capabilities, snap targets —
10
+ * with no knowledge of how the shape is drawn. The Pixi renderers are a separate
11
+ * concern (board-renderer-registry). This separation is what lets us add a shape
12
+ * by writing one definition + one renderer, and keeps geometry fully unit-testable
13
+ * without a GPU.
14
+ *
15
+ * Definitions are looked up by `item.type`. Unknown types fall back to a generic
16
+ * box definition so unrecognised shapes still select, move and resize.
17
+ */
18
+ const definitions = /* @__PURE__ */ new Map();
19
+ function registerShapeDefinition(definition) {
20
+ definitions.set(definition.type, definition);
21
+ }
22
+ function getShapeDefinition(type) {
23
+ return definitions.get(type);
24
+ }
25
+ /**
26
+ * The generic fallback for unknown shape types: a plain movable/resizable box.
27
+ * This guarantees a shape authored by a newer client is still interactive here.
28
+ */
29
+ const unknownShapeDefinition = {
30
+ type: "__unknown__",
31
+ capabilities: {
32
+ canMove: true,
33
+ canResize: true,
34
+ aspectLocked: false,
35
+ canRotate: true,
36
+ canEdit: false,
37
+ canBind: true,
38
+ canSnap: true,
39
+ canLock: true
40
+ }
41
+ };
42
+ function definitionForItem(item) {
43
+ if (isUnknownItem(item)) return unknownShapeDefinition;
44
+ return definitions.get(item.type) ?? unknownShapeDefinition;
45
+ }
46
+ function shapeBounds(item) {
47
+ return definitionForItem(item).getBounds?.(item) ?? itemBounds(item.frame);
48
+ }
49
+ function shapeHitTest(item, point) {
50
+ const definition = definitionForItem(item);
51
+ if (definition.hitTest) return definition.hitTest(item, point);
52
+ return frameContainsPoint(item.frame, point);
53
+ }
54
+ function shapeHandles(item) {
55
+ return definitionForItem(item).getHandles?.(item) ?? [];
56
+ }
57
+ function shapeCapabilities(item) {
58
+ return definitionForItem(item).capabilities;
59
+ }
60
+ function shapeResizeMode(item) {
61
+ return resizeModeForCapabilities(shapeCapabilities(item));
62
+ }
63
+ //#endregion
64
+ export { definitionForItem, getShapeDefinition, registerShapeDefinition, shapeBounds, shapeCapabilities, shapeHandles, shapeHitTest, shapeResizeMode, unknownShapeDefinition };