flowsvg 0.0.0-stage → 0.1.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.
@@ -0,0 +1,554 @@
1
+ const DEFAULT_PADDING = 16;
2
+ const ROUNDED_RECT_INSET = 4;
3
+ const MAX_CYLINDER_CAP_RADIUS = 14;
4
+ const DEFAULT_BLOCK_GAP = 14;
5
+ const DEFAULT_ITEM_GAP = 12;
6
+ const DEFAULT_TAG_GAP = 6;
7
+ const TAG_SIZE = 11;
8
+ const TAG_PADDING_X = 6;
9
+ const TAG_PADDING_Y = 2;
10
+ const LIST_MARKER_GAP = 8;
11
+ const OVERFLOW_TOLERANCE = 0.000001;
12
+ const MIN_LINE_HEIGHT = 20;
13
+ const LINE_HEIGHT_RATIO = 1.35;
14
+ const TITLE_SIZE = 20;
15
+ const BODY_SIZE = 14;
16
+ const AUXILIARY_SIZE = 12;
17
+ const TITLE_WEIGHT = 700;
18
+ const NORMAL_WEIGHT = 400;
19
+ const MIN_FONT_WEIGHT = 100;
20
+ const MAX_FONT_WEIGHT = 900;
21
+ const NARROW_GLYPH_WIDTH_RATIO = 0.6;
22
+ const GRAPH_NODE_RADIUS = 4;
23
+ const GRAPH_LABEL_GAP = 7;
24
+ const GRAPH_LABEL_SIZE = 12;
25
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter("en", { granularity: "grapheme" });
26
+ /** Supported content shapes; node validators share this restriction. */
27
+ export function supportsNodeContent(kind) {
28
+ return kind === "rect" || kind === "roundedRect" || kind === "cylinder";
29
+ }
30
+ /** Derive the clip from current absolute geometry, reserving shape contours before authored padding. */
31
+ function contentSafeArea(bounds, padding, kind) {
32
+ const cornerInset = kind === "roundedRect" ? ROUNDED_RECT_INSET : 0;
33
+ // Match the current SVG cylinder cap; reserve its full diameter above and below the content.
34
+ const capInset = kind === "cylinder" ? Math.min(MAX_CYLINDER_CAP_RADIUS, bounds.height / 4) * 2 : 0;
35
+ const horizontalInset = padding + cornerInset;
36
+ const verticalInset = horizontalInset + capInset;
37
+ return {
38
+ x: bounds.x + horizontalInset,
39
+ y: bounds.y + verticalInset,
40
+ width: Math.max(0, bounds.width - horizontalInset * 2),
41
+ height: Math.max(0, bounds.height - verticalInset * 2)
42
+ };
43
+ }
44
+ /** Lay out current content from the top left, without DOM font measurement or authored-size changes. */
45
+ export function layoutNodeContent(content, bounds, palette, kind) {
46
+ const safeArea = contentSafeArea(bounds, content.style?.padding ?? DEFAULT_PADDING, kind);
47
+ let cursorY = safeArea.y;
48
+ let widthOverflow = false;
49
+ let heightOverflow = false;
50
+ const tags = [];
51
+ const graphs = [];
52
+ const lines = content.blocks.flatMap((block, blockIndex) => {
53
+ const result = block.type === "graph"
54
+ ? layoutContentGraph(block, safeArea, cursorY, palette, blockIndex, graphs)
55
+ : block.type === "list"
56
+ ? layoutContentList(block, safeArea, cursorY, palette, blockIndex)
57
+ : layoutTextPart(block.text, block.type === "title" ? "title" : block.role ?? "body", block.style, safeArea, cursorY, palette, blockIndex);
58
+ widthOverflow ||= result.widthOverflow;
59
+ heightOverflow ||= result.heightOverflow ?? false;
60
+ tags.push(...result.tags);
61
+ cursorY = result.bottom + (blockIndex < content.blocks.length - 1 ? content.style?.blockGap ?? DEFAULT_BLOCK_GAP : 0);
62
+ return result.lines;
63
+ });
64
+ return { safeArea, lines, tags, ...(graphs.length ? { graphs } : {}), widthOverflow: widthOverflow || lines.some((line) => contentLineExceedsWidth(line, safeArea)),
65
+ heightOverflow: heightOverflow || cursorY > safeArea.y + safeArea.height };
66
+ }
67
+ /** Graph labels reuse text metrics; authored graph geometry is clipped instead of resized. */
68
+ function layoutContentGraph(block, area, top, palette, blockIndex, graphs) {
69
+ const points = new Map(block.nodes.map((node) => [node.id, { x: area.x + node.x, y: top + node.y }]));
70
+ const nodes = block.nodes.map((node) => ({ ...points.get(node.id), radius: GRAPH_NODE_RADIUS,
71
+ fill: node.highlighted ? palette.accentText : palette.mutedText, highlighted: node.highlighted ?? false }));
72
+ const edges = block.edges.map((edge) => {
73
+ const from = points.get(edge.from), to = points.get(edge.to);
74
+ const highlighted = [edge.from, edge.to].every((id) => block.nodes.find((node) => node.id === id)?.highlighted);
75
+ return { x1: from.x, y1: from.y, x2: to.x, y2: to.y, color: highlighted ? palette.accentText : palette.mutedText, highlighted };
76
+ });
77
+ const parts = block.nodes.map((node) => {
78
+ const halfWidth = Math.max(0, Math.min(node.x, area.width - node.x));
79
+ const labelArea = { ...area, x: area.x + node.x - halfWidth, width: halfWidth * 2 };
80
+ return layoutTextPart(node.label, "body", { fontSize: GRAPH_LABEL_SIZE, fontWeight: node.highlighted ? 700 : 400, align: "center" }, labelArea, top + node.y - GRAPH_NODE_RADIUS - GRAPH_LABEL_GAP - GRAPH_LABEL_SIZE, palette, blockIndex);
81
+ });
82
+ graphs.push({ blockIndex, nodes, edges });
83
+ return { lines: parts.flatMap((part) => part.lines), tags: [],
84
+ bottom: top + block.height,
85
+ heightOverflow: parts.some((part) => part.lines.some((line) => line.y - line.fontSize < top) || part.bottom > top + block.height)
86
+ || block.nodes.some((node) => node.y - GRAPH_NODE_RADIUS < 0 || node.y + GRAPH_NODE_RADIUS > block.height),
87
+ widthOverflow: parts.some((part) => part.widthOverflow) || block.nodes.some((node) => node.x < GRAPH_NODE_RADIUS
88
+ || node.x + GRAPH_NODE_RADIUS > area.width) };
89
+ }
90
+ /** Include list indentation when checking the clip; a single oversized grapheme may exceed the body column. */
91
+ function contentLineExceedsWidth(line, area) {
92
+ return line.width > area.width || line.spans.some((span) => span.x < area.x - OVERFLOW_TOLERANCE
93
+ || span.x + span.width > area.x + area.width + OVERFLOW_TOLERANCE);
94
+ }
95
+ /**
96
+ * Resolve role defaults once and reuse baseline/wrapping rules for every text part.
97
+ * A marked item's first line also reserves its marker size, so smaller source spans cannot lift the marker above the clip.
98
+ */
99
+ function layoutTextPart(text, role, style, area, top, palette, blockIndex, itemIndex, minimumFirstLineSize = 0) {
100
+ const fontSize = style?.fontSize ?? (role === "title" ? TITLE_SIZE : role === "auxiliary" || role === "status" ? AUXILIARY_SIZE : role === "tag" ? TAG_SIZE : BODY_SIZE);
101
+ const fontWeight = style?.fontWeight ?? (role === "title" ? TITLE_WEIGHT : NORMAL_WEIGHT);
102
+ const color = style?.color ?? (role === "auxiliary" ? palette.mutedText : role === "tag" ? palette.tagText : role === "status" ? palette.accentText : palette.text);
103
+ const align = style?.align ?? "left";
104
+ const x = area.x + (align === "right" ? area.width : align === "center" ? area.width / 2 : 0);
105
+ const wrapped = wrapContentText(text, area.width, { fontSize, fontWeight, color, explicitColor: style?.color !== undefined });
106
+ let cursorY = top;
107
+ const lines = wrapped.map((glyphs, lineIndex) => {
108
+ const textSize = glyphs.length > 0 ? glyphs.reduce((largest, glyph) => Math.max(largest, glyph.style.fontSize), 0) : fontSize;
109
+ const size = Math.max(textSize, lineIndex === 0 ? minimumFirstLineSize : 0);
110
+ const width = glyphs.reduce((sum, glyph) => sum + glyph.width, 0);
111
+ const y = cursorY + size;
112
+ const startX = x - (align === "right" ? width : align === "center" ? width / 2 : 0);
113
+ const spans = collectContentSpans(glyphs, startX, y);
114
+ cursorY += Math.max(MIN_LINE_HEIGHT, size * LINE_HEIGHT_RATIO);
115
+ return { text: glyphs.map((glyph) => glyph.text).join(""), spans, x, y, width,
116
+ fontSize: size, fontWeight, color, align, role, blockIndex, ...(itemIndex === undefined ? {} : { itemIndex }) };
117
+ });
118
+ return { lines, tags: [], bottom: cursorY, widthOverflow: lines.some((line) => line.width > area.width) };
119
+ }
120
+ function layoutContentList(block, area, top, palette, blockIndex) {
121
+ let cursorY = top;
122
+ const lines = [];
123
+ const tags = [];
124
+ let widthOverflow = false;
125
+ const markerWidth = contentMarkerWidth(block);
126
+ const indent = markerWidth > 0 ? markerWidth + LIST_MARKER_GAP : 0;
127
+ const bodyArea = { ...area, x: area.x + indent, width: Math.max(0, area.width - indent) };
128
+ block.items.forEach((item, itemIndex) => {
129
+ if (!hasContentItem(item))
130
+ return;
131
+ if (lines.length > 0)
132
+ cursorY += block.style?.itemGap ?? DEFAULT_ITEM_GAP;
133
+ const itemStart = lines.length;
134
+ const markerSize = markerWidth === 0 ? 0 : item.body === undefined ? item.auxiliaryStyle?.fontSize ?? AUXILIARY_SIZE
135
+ : item.style?.fontSize ?? block.style?.fontSize ?? BODY_SIZE;
136
+ const header = layoutItemHeader(item, bodyArea, cursorY, palette, blockIndex, itemIndex, block.style?.tagGap ?? DEFAULT_TAG_GAP, markerSize);
137
+ lines.push(...header.lines);
138
+ tags.push(...header.tags);
139
+ widthOverflow ||= header.widthOverflow;
140
+ cursorY = header.bottom;
141
+ if (item.body !== undefined) {
142
+ const body = layoutTextPart(item.body, "body", inheritContentStyle(block.style, item.style), bodyArea, cursorY, palette, blockIndex, itemIndex, markerSize);
143
+ lines.push(...body.lines);
144
+ widthOverflow ||= body.widthOverflow;
145
+ cursorY = body.bottom;
146
+ }
147
+ if (item.auxiliary !== undefined) {
148
+ const auxiliary = layoutTextPart(item.auxiliary, "auxiliary", item.auxiliaryStyle, bodyArea, cursorY, palette, blockIndex, itemIndex, item.body === undefined ? markerSize : 0);
149
+ lines.push(...auxiliary.lines);
150
+ widthOverflow ||= auxiliary.widthOverflow;
151
+ cursorY = auxiliary.bottom;
152
+ }
153
+ if (markerWidth > 0) {
154
+ const first = lines[itemStart];
155
+ const style = item.body === undefined ? item.auxiliaryStyle : inheritContentStyle(block.style, item.style);
156
+ const marker = layoutTextPart(block.marker === "bullet" ? "•" : `${itemIndex + 1}.`, "marker", { ...style, fontSize: style?.fontSize ?? (item.body === undefined ? AUXILIARY_SIZE : BODY_SIZE), align: "left" }, { ...area, width: markerWidth }, top, palette, blockIndex, itemIndex).lines[0];
157
+ lines.splice(itemStart, 0, { ...marker, y: first.y, spans: marker.spans.map((span) => ({ ...span, y: first.y })) });
158
+ }
159
+ });
160
+ return { lines, tags, bottom: cursorY, widthOverflow };
161
+ }
162
+ /** Tags occupy rows first. Status shares a single row only when both natural widths fit. */
163
+ function layoutItemHeader(item, area, top, palette, blockIndex, itemIndex, gap, markerSize) {
164
+ const header = layoutContentTags(item.tags ?? [], area, top, palette, blockIndex, itemIndex, gap, markerSize);
165
+ if (item.status === undefined)
166
+ return header;
167
+ const statusWidth = naturalContentWidth(item.status, item.statusStyle, AUXILIARY_SIZE);
168
+ const lastTag = header.tags.at(-1);
169
+ const occupied = lastTag ? lastTag.x + lastTag.width - area.x : 0;
170
+ const singleTagRow = header.tags.every((tag) => tag.y === top) && header.lines.length === header.tags.length;
171
+ const singleStatusLine = !contentTextString(item.status).match(/[\r\n]/);
172
+ const sameRow = singleTagRow && singleStatusLine && occupied + (lastTag ? gap : 0) + statusWidth <= area.width;
173
+ const statusTop = sameRow ? top : header.bottom;
174
+ let status = layoutTextPart(item.status, "status", { ...item.statusStyle, align: "right" }, area, statusTop, palette, blockIndex, itemIndex, markerSize);
175
+ let tags = header;
176
+ if (sameRow && header.lines.length > 0) {
177
+ const baseline = Math.max(...header.lines.map((line) => line.y), status.lines[0].y);
178
+ tags = shiftTagText(header, baseline - header.lines[0].y);
179
+ status = shiftContentPart(status, baseline - status.lines[0].y);
180
+ }
181
+ return { lines: [...tags.lines, ...status.lines], tags: tags.tags,
182
+ bottom: Math.max(tags.bottom, status.bottom), widthOverflow: tags.widthOverflow || status.widthOverflow };
183
+ }
184
+ /** Pack tag boxes in authored order; a long tag wraps inside its own padded full-width box. */
185
+ function layoutContentTags(source, area, top, palette, blockIndex, itemIndex, gap, markerSize) {
186
+ let row = [];
187
+ let right = area.x;
188
+ let result = { lines: [], tags: [], bottom: top, widthOverflow: false };
189
+ const flush = () => {
190
+ if (row.length === 0)
191
+ return;
192
+ const rowTop = result.tags.length > 0 ? result.bottom + gap : top;
193
+ const parts = row.map(({ tag, tagIndex, width, x }) => layoutContentTag(tag, { ...area, x, width }, rowTop, palette, blockIndex, itemIndex, tagIndex, markerSize));
194
+ const baseline = Math.max(...parts.map((part) => part.lines[0].y));
195
+ const aligned = parts.map((part) => shiftTagText(part, baseline - part.lines[0].y));
196
+ result = { lines: [...result.lines, ...aligned.flatMap((part) => part.lines)],
197
+ tags: [...result.tags, ...aligned.flatMap((part) => part.tags)], bottom: Math.max(...aligned.map((part) => part.bottom)),
198
+ widthOverflow: result.widthOverflow || aligned.some((part) => part.widthOverflow) };
199
+ row = [];
200
+ right = area.x;
201
+ };
202
+ source.forEach((tag, tagIndex) => {
203
+ const width = Math.min(area.width, naturalContentWidth(tag.text, tag.style, TAG_SIZE) + TAG_PADDING_X * 2);
204
+ if (row.length > 0 && right + width > area.x + area.width)
205
+ flush();
206
+ row.push({ tag, tagIndex, width, x: right });
207
+ right += width + gap;
208
+ });
209
+ flush();
210
+ return result;
211
+ }
212
+ function layoutContentTag(tag, area, top, palette, blockIndex, itemIndex, tagIndex, markerSize) {
213
+ const textArea = { ...area, x: area.x + TAG_PADDING_X, width: Math.max(0, area.width - TAG_PADDING_X * 2) };
214
+ const result = layoutTextPart(tag.text, "tag", tag.style, textArea, top + TAG_PADDING_Y, palette, blockIndex, itemIndex, markerSize);
215
+ return { ...result, widthOverflow: result.widthOverflow || area.width < TAG_PADDING_X * 2,
216
+ lines: result.lines.map((line) => ({ ...line, tagIndex })), bottom: result.bottom + TAG_PADDING_Y,
217
+ tags: [{ x: area.x, y: top, width: area.width, height: result.bottom - top + TAG_PADDING_Y,
218
+ fill: tag.style?.fill ?? palette.tagFill, explicitFill: tag.style?.fill !== undefined, blockIndex, itemIndex, tagIndex }] };
219
+ }
220
+ /** Baseline shifts preserve line spacing and expand tag backgrounds instead of moving the row boundary. */
221
+ function shiftTagText(part, offset) {
222
+ return { ...shiftContentPart(part, offset), tags: part.tags.map((tag) => ({ ...tag, height: tag.height + offset })) };
223
+ }
224
+ function shiftContentPart(part, offset) {
225
+ return { ...part, bottom: part.bottom + offset,
226
+ lines: part.lines.map((line) => ({ ...line, y: line.y + offset, spans: line.spans.map((span) => ({ ...span, y: span.y + offset })) })) };
227
+ }
228
+ function contentTextString(text) {
229
+ return typeof text === "string" ? text : text.map((span) => span.text).join("");
230
+ }
231
+ function naturalContentWidth(text, style, defaultSize) {
232
+ const wrapped = wrapContentText(text, Number.MAX_VALUE, { fontSize: style?.fontSize ?? defaultSize,
233
+ fontWeight: style?.fontWeight ?? NORMAL_WEIGHT, color: style?.color ?? "#000000", explicitColor: style?.color !== undefined });
234
+ return Math.max(0, ...wrapped.map((glyphs) => glyphs.reduce((sum, glyph) => sum + glyph.width, 0)));
235
+ }
236
+ function hasContentItem(item) {
237
+ return item.body !== undefined || item.auxiliary !== undefined || item.status !== undefined || (item.tags?.length ?? 0) > 0;
238
+ }
239
+ /** Every item uses the same marker column, including multi-digit numbers and authored marker sizes. */
240
+ function contentMarkerWidth(block) {
241
+ if (block.marker === undefined || block.marker === "none")
242
+ return 0;
243
+ return block.items.reduce((width, item, index) => {
244
+ if (!hasContentItem(item))
245
+ return width;
246
+ const size = item.body === undefined ? item.auxiliaryStyle?.fontSize ?? AUXILIARY_SIZE
247
+ : item.style?.fontSize ?? block.style?.fontSize ?? BODY_SIZE;
248
+ return Math.max(width, estimateContentWidth(block.marker === "bullet" ? "•" : `${index + 1}.`, size));
249
+ }, 0);
250
+ }
251
+ function inheritContentStyle(base, override) {
252
+ return { fontSize: override?.fontSize ?? base?.fontSize, fontWeight: override?.fontWeight ?? base?.fontWeight,
253
+ color: override?.color ?? base?.color, align: override?.align ?? base?.align };
254
+ }
255
+ /** Wrap combined source text, so a style boundary never creates an extra break opportunity. */
256
+ function wrapContentText(text, maxWidth, baseStyle) {
257
+ const sources = typeof text === "string" ? [{ text }] : text;
258
+ const glyphs = segmentContentText(sources, baseStyle);
259
+ const explicitLines = [[]];
260
+ glyphs.forEach((glyph) => {
261
+ if (/^\r\n$|^[\r\n]$/.test(glyph.text))
262
+ explicitLines.push([]);
263
+ else
264
+ explicitLines.at(-1).push(glyph);
265
+ });
266
+ return explicitLines.flatMap((line) => wrapContentGlyphs(line, maxWidth));
267
+ }
268
+ /** Segment concatenated text before styling: a cluster crossing spans uses its first character's style. */
269
+ function segmentContentText(sources, baseStyle) {
270
+ const combined = sources.map((source) => source.text).join("");
271
+ let sourceIndex = 0;
272
+ let sourceEnd = sources[0]?.text.length ?? 0;
273
+ return Array.from(GRAPHEME_SEGMENTER.segment(combined), ({ segment: text, index }) => {
274
+ while (index >= sourceEnd && sourceIndex < sources.length - 1) {
275
+ sourceIndex += 1;
276
+ sourceEnd += sources[sourceIndex].text.length;
277
+ }
278
+ const sourceStyle = sources[sourceIndex]?.style;
279
+ const style = { ...baseStyle, ...sourceStyle, explicitColor: sourceStyle?.color !== undefined || baseStyle.explicitColor };
280
+ return { text, sourceIndex, style, width: estimateContentWidth(text, style.fontSize) };
281
+ });
282
+ }
283
+ /** Prefer English spaces; fallback always consumes one character even when the safe area is empty. */
284
+ function wrapContentGlyphs(glyphs, maxWidth) {
285
+ let remaining = glyphs;
286
+ const lines = [];
287
+ while (remaining.length > 0) {
288
+ let count = 0;
289
+ let width = 0;
290
+ while (count < remaining.length && width + remaining[count].width <= maxWidth) {
291
+ width += remaining[count].width;
292
+ count += 1;
293
+ }
294
+ if (count >= remaining.length) {
295
+ lines.push(remaining);
296
+ break;
297
+ }
298
+ const space = remaining.slice(0, count + 1).map((glyph) => glyph.text).lastIndexOf(" ");
299
+ const cut = space > 0 ? space : Math.max(1, count);
300
+ lines.push(remaining.slice(0, cut));
301
+ remaining = remaining.slice(cut);
302
+ while (remaining[0]?.text === " ")
303
+ remaining = remaining.slice(1);
304
+ }
305
+ return lines.length > 0 ? lines : [[]];
306
+ }
307
+ function collectContentSpans(glyphs, x, y) {
308
+ const spans = [];
309
+ let cursorX = x;
310
+ let sourceIndex = -1;
311
+ for (const glyph of glyphs) {
312
+ const previous = spans.at(-1);
313
+ if (previous && sourceIndex === glyph.sourceIndex) {
314
+ spans[spans.length - 1] = { ...previous, text: previous.text + glyph.text, width: previous.width + glyph.width };
315
+ }
316
+ else
317
+ spans.push({ text: glyph.text, x: cursorX, y, width: glyph.width, ...glyph.style });
318
+ cursorX += glyph.width;
319
+ sourceIndex = glyph.sourceIndex;
320
+ }
321
+ return spans;
322
+ }
323
+ /** Emoji sequences occupy one em; combining marks and format controls do not add independent advance. */
324
+ function estimateContentWidth(text, fontSize) {
325
+ if (text === "•")
326
+ return fontSize * NARROW_GLYPH_WIDTH_RATIO;
327
+ if (/\p{Extended_Pictographic}|\p{Regional_Indicator}|\u20e3/u.test(text))
328
+ return fontSize;
329
+ return Array.from(text).filter((character) => !/\p{Mark}|\p{Default_Ignorable_Code_Point}/u.test(character))
330
+ .reduce((width, character) => width + fontSize * (character.codePointAt(0) > 0xff || "MWmw@%".includes(character) ? 1 : NARROW_GLYPH_WIDTH_RATIO), 0);
331
+ }
332
+ /** Validate nested content separately from shape and label-mode checks at the document boundary. */
333
+ export function validateNodeContent(value, path, issues) {
334
+ if (!isRecord(value)) {
335
+ issues.push({ path, message: "must be an object" });
336
+ return;
337
+ }
338
+ rejectUnknownFields(value, ["blocks", "style"], path, issues);
339
+ if (!Array.isArray(value.blocks)) {
340
+ issues.push({ path: `${path}.blocks`, message: "must be an array" });
341
+ return;
342
+ }
343
+ validateContentSpacing(value.style, `${path}.style`, issues);
344
+ for (let index = 0; index < value.blocks.length; index += 1) {
345
+ if (!(index in value.blocks))
346
+ issues.push({ path: `${path}.blocks[${index}]`, message: "must be a value" });
347
+ }
348
+ value.blocks.forEach((block, index) => {
349
+ const blockPath = `${path}.blocks[${index}]`;
350
+ if (!isRecord(block)) {
351
+ issues.push({ path: blockPath, message: "must be an object" });
352
+ return;
353
+ }
354
+ if (block.type === "list") {
355
+ validateContentList(block, blockPath, issues);
356
+ return;
357
+ }
358
+ if (block.type === "graph") {
359
+ validateContentGraph(block, blockPath, issues);
360
+ return;
361
+ }
362
+ rejectUnknownFields(block, block.type === "paragraph" ? ["type", "text", "role", "style"] : ["type", "text", "style"], blockPath, issues);
363
+ if (block.type !== "title" && block.type !== "paragraph")
364
+ issues.push({ path: `${blockPath}.type`, message: "must be title, paragraph, list or graph" });
365
+ validateContentText(block.text, `${blockPath}.text`, issues);
366
+ validateContentTextStyle(block.style, `${blockPath}.style`, issues);
367
+ if (block.role !== undefined && block.role !== "body" && block.role !== "auxiliary")
368
+ issues.push({ path: `${blockPath}.role`, message: "must be body or auxiliary" });
369
+ });
370
+ }
371
+ function validateContentGraph(block, path, issues) {
372
+ rejectUnknownFields(block, ["type", "height", "nodes", "edges"], path, issues);
373
+ validateSpacingNumber(block.height, `${path}.height`, issues, true);
374
+ const ids = new Set();
375
+ if (!Array.isArray(block.nodes))
376
+ issues.push({ path: `${path}.nodes`, message: "must be an array" });
377
+ else
378
+ for (let index = 0; index < block.nodes.length; index += 1) {
379
+ const node = block.nodes[index];
380
+ const nodePath = `${path}.nodes[${index}]`;
381
+ if (!isRecord(node)) {
382
+ issues.push({ path: nodePath, message: "must be an object" });
383
+ continue;
384
+ }
385
+ rejectUnknownFields(node, ["id", "label", "x", "y", "highlighted"], nodePath, issues);
386
+ if (typeof node.id !== "string" || !node.id.trim() || ids.has(node.id))
387
+ issues.push({ path: `${nodePath}.id`, message: "must be a unique nonempty local ID" });
388
+ else
389
+ ids.add(node.id);
390
+ if (typeof node.label !== "string")
391
+ issues.push({ path: `${nodePath}.label`, message: "must be a string" });
392
+ for (const field of ["x", "y"])
393
+ validateSpacingNumber(node[field], `${nodePath}.${field}`, issues);
394
+ if (node.highlighted !== undefined && typeof node.highlighted !== "boolean")
395
+ issues.push({ path: `${nodePath}.highlighted`, message: "must be a boolean" });
396
+ }
397
+ if (!Array.isArray(block.edges)) {
398
+ issues.push({ path: `${path}.edges`, message: "must be an array" });
399
+ return;
400
+ }
401
+ for (let index = 0; index < block.edges.length; index += 1) {
402
+ const edge = block.edges[index];
403
+ const edgePath = `${path}.edges[${index}]`;
404
+ if (!isRecord(edge)) {
405
+ issues.push({ path: edgePath, message: "must be an object" });
406
+ continue;
407
+ }
408
+ rejectUnknownFields(edge, ["from", "to"], edgePath, issues);
409
+ for (const field of ["from", "to"]) {
410
+ if (typeof edge[field] !== "string" || !ids.has(edge[field]))
411
+ issues.push({ path: `${edgePath}.${field}`, message: "must reference a node in this graph block" });
412
+ }
413
+ }
414
+ }
415
+ /** List entries share controlled text validation and preserve the exact nested source path. */
416
+ function validateContentList(block, path, issues) {
417
+ rejectUnknownFields(block, ["type", "marker", "items", "style"], path, issues);
418
+ if (block.marker !== undefined && block.marker !== "none" && block.marker !== "bullet" && block.marker !== "number") {
419
+ issues.push({ path: `${path}.marker`, message: "must be none, bullet or number" });
420
+ }
421
+ if (isRecord(block.style)) {
422
+ const { itemGap, tagGap, ...textStyle } = block.style;
423
+ validateContentTextStyle(textStyle, `${path}.style`, issues);
424
+ if (itemGap !== undefined)
425
+ validateSpacingNumber(itemGap, `${path}.style.itemGap`, issues);
426
+ if (tagGap !== undefined)
427
+ validateSpacingNumber(tagGap, `${path}.style.tagGap`, issues);
428
+ }
429
+ else
430
+ validateContentTextStyle(block.style, `${path}.style`, issues);
431
+ if (!Array.isArray(block.items)) {
432
+ issues.push({ path: `${path}.items`, message: "must be an array" });
433
+ return;
434
+ }
435
+ for (let index = 0; index < block.items.length; index += 1) {
436
+ const item = block.items[index];
437
+ const itemPath = `${path}.items[${index}]`;
438
+ if (!isRecord(item)) {
439
+ issues.push({ path: itemPath, message: "must be an object" });
440
+ continue;
441
+ }
442
+ rejectUnknownFields(item, ["body", "auxiliary", "style", "auxiliaryStyle", "tags", "status", "statusStyle"], itemPath, issues);
443
+ if (item.body !== undefined)
444
+ validateContentText(item.body, `${itemPath}.body`, issues);
445
+ if (item.auxiliary !== undefined)
446
+ validateContentText(item.auxiliary, `${itemPath}.auxiliary`, issues);
447
+ validateContentTags(item.tags, `${itemPath}.tags`, issues);
448
+ if (item.status !== undefined)
449
+ validateContentText(item.status, `${itemPath}.status`, issues);
450
+ validateContentTextStyle(item.statusStyle, `${itemPath}.statusStyle`, issues, true);
451
+ validateContentTextStyle(item.style, `${itemPath}.style`, issues);
452
+ validateContentTextStyle(item.auxiliaryStyle, `${itemPath}.auxiliaryStyle`, issues);
453
+ }
454
+ }
455
+ /** Validate finite tag styles while preserving the exact authored tag and inline-span paths. */
456
+ function validateContentTags(value, path, issues) {
457
+ if (value === undefined)
458
+ return;
459
+ if (!Array.isArray(value)) {
460
+ issues.push({ path, message: "must be an array" });
461
+ return;
462
+ }
463
+ for (let index = 0; index < value.length; index += 1) {
464
+ const tag = value[index];
465
+ const tagPath = `${path}[${index}]`;
466
+ if (!isRecord(tag)) {
467
+ issues.push({ path: tagPath, message: "must be an object" });
468
+ continue;
469
+ }
470
+ rejectUnknownFields(tag, ["text", "style"], tagPath, issues);
471
+ validateContentText(tag.text, `${tagPath}.text`, issues);
472
+ if (!isRecord(tag.style)) {
473
+ validateContentTextStyle(tag.style, `${tagPath}.style`, issues, true);
474
+ continue;
475
+ }
476
+ const { fill, ...textStyle } = tag.style;
477
+ validateContentTextStyle(textStyle, `${tagPath}.style`, issues, true);
478
+ if (fill !== undefined)
479
+ validateColor(fill, `${tagPath}.style.fill`, issues);
480
+ }
481
+ }
482
+ /** Validate inline source values before evaluation; unknown fields keep their exact source position. */
483
+ export function validateContentText(value, path, issues) {
484
+ if (typeof value === "string")
485
+ return;
486
+ if (!Array.isArray(value)) {
487
+ issues.push({ path, message: "must be a string or an array of text spans" });
488
+ return;
489
+ }
490
+ for (let index = 0; index < value.length; index += 1) {
491
+ const span = value[index];
492
+ const spanPath = `${path}[${index}]`;
493
+ if (!isRecord(span)) {
494
+ issues.push({ path: spanPath, message: "must be an object" });
495
+ continue;
496
+ }
497
+ rejectUnknownFields(span, ["text", "style"], spanPath, issues);
498
+ if (typeof span.text !== "string")
499
+ issues.push({ path: `${spanPath}.text`, message: "must be a string" });
500
+ validateContentTextStyle(span.style, `${spanPath}.style`, issues, true);
501
+ }
502
+ }
503
+ /** Validate the controlled style shared by content blocks; no CSS expression or external resource is accepted. */
504
+ export function validateContentTextStyle(value, path, issues, inline = false) {
505
+ if (value === undefined)
506
+ return;
507
+ if (!isRecord(value)) {
508
+ issues.push({ path, message: "must be an object" });
509
+ return;
510
+ }
511
+ rejectUnknownFields(value, inline ? ["fontSize", "fontWeight", "color"] : ["fontSize", "fontWeight", "color", "align"], path, issues);
512
+ if (value.fontSize !== undefined)
513
+ validateSpacingNumber(value.fontSize, `${path}.fontSize`, issues, true);
514
+ if (value.fontWeight !== undefined && (typeof value.fontWeight !== "number" || !Number.isInteger(value.fontWeight) || value.fontWeight < MIN_FONT_WEIGHT || value.fontWeight > MAX_FONT_WEIGHT))
515
+ issues.push({ path: `${path}.fontWeight`, message: "must be an integer from 100 through 900" });
516
+ if (value.align !== undefined && value.align !== "left" && value.align !== "center" && value.align !== "right")
517
+ issues.push({ path: `${path}.align`, message: "must be left, center or right" });
518
+ if (value.color !== undefined)
519
+ validateColor(value.color, `${path}.color`, issues);
520
+ }
521
+ function validateColor(value, path, issues) {
522
+ if (typeof value !== "string" || !/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(value))
523
+ issues.push({ path, message: "must be a hexadecimal color" });
524
+ }
525
+ function validateContentSpacing(value, path, issues) {
526
+ if (value === undefined)
527
+ return;
528
+ if (!isRecord(value)) {
529
+ issues.push({ path, message: "must be an object" });
530
+ return;
531
+ }
532
+ rejectUnknownFields(value, ["padding", "blockGap"], path, issues);
533
+ for (const field of ["padding", "blockGap"]) {
534
+ if (value[field] !== undefined)
535
+ validateSpacingNumber(value[field], `${path}.${field}`, issues);
536
+ }
537
+ }
538
+ function validateSpacingNumber(value, path, issues, positive = false) {
539
+ if (typeof value !== "number" || !Number.isFinite(value))
540
+ issues.push({ path, message: "must be a finite number" });
541
+ else if (positive ? value <= 0 : value < 0)
542
+ issues.push({ path, message: positive ? "must be greater than zero" : "must be zero or greater" });
543
+ }
544
+ function isRecord(value) {
545
+ if (typeof value !== "object" || value === null || Array.isArray(value))
546
+ return false;
547
+ const prototype = Object.getPrototypeOf(value);
548
+ return prototype === Object.prototype || prototype === null;
549
+ }
550
+ function rejectUnknownFields(value, allowed, path, issues) {
551
+ for (const key of Object.keys(value))
552
+ if (!allowed.includes(key))
553
+ issues.push({ path: `${path}.${key}`, message: "unknown field" });
554
+ }
@@ -0,0 +1,16 @@
1
+ /** Translate keyboard input and reduced-motion preferences to player actions without changing core timeline semantics. */
2
+ interface KeyboardActions {
3
+ toggle(): void;
4
+ step(direction: 1 | -1): void;
5
+ }
6
+ /** Explicit values need no subscription; otherwise return the system preference with change notifications and cleanup. */
7
+ export declare function observeReducedMotion(owner: Document, explicit: boolean | undefined, onChange: (reducedMotion: boolean) => void): {
8
+ value: boolean;
9
+ cleanup(): void;
10
+ };
11
+ /**
12
+ * Make the canvas focusable and handle Space/Arrow keys, returning a listener cleanup callback.
13
+ * Leave editable areas, native controls, modifier chords and repeated keys to their existing handlers.
14
+ */
15
+ export declare function observeKeyboard(canvas: HTMLElement, actions: KeyboardActions): () => void;
16
+ export {};
@@ -0,0 +1,36 @@
1
+ const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
2
+ /** Explicit values need no subscription; otherwise return the system preference with change notifications and cleanup. */
3
+ export function observeReducedMotion(owner, explicit, onChange) {
4
+ if (explicit !== undefined)
5
+ return { value: explicit, cleanup: () => { } };
6
+ const query = owner.defaultView?.matchMedia(REDUCED_MOTION_QUERY);
7
+ const handleChange = (event) => onChange(event.matches);
8
+ query?.addEventListener("change", handleChange);
9
+ return { value: query?.matches ?? false, cleanup: () => query?.removeEventListener("change", handleChange) };
10
+ }
11
+ /**
12
+ * Make the canvas focusable and handle Space/Arrow keys, returning a listener cleanup callback.
13
+ * Leave editable areas, native controls, modifier chords and repeated keys to their existing handlers.
14
+ */
15
+ export function observeKeyboard(canvas, actions) {
16
+ canvas.tabIndex = 0;
17
+ canvas.setAttribute("aria-label", "Diagram playback");
18
+ canvas.setAttribute("aria-keyshortcuts", "Space ArrowLeft ArrowRight");
19
+ const handleKey = (event) => {
20
+ if (event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
21
+ return;
22
+ const target = event.target;
23
+ if (target instanceof HTMLElement && (target.isContentEditable || target.closest("button,input,select,textarea,a[href]")))
24
+ return;
25
+ if (event.key === " ") {
26
+ event.preventDefault();
27
+ actions.toggle();
28
+ }
29
+ else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
30
+ event.preventDefault();
31
+ actions.step(event.key === "ArrowLeft" ? -1 : 1);
32
+ }
33
+ };
34
+ canvas.addEventListener("keydown", handleKey);
35
+ return () => canvas.removeEventListener("keydown", handleKey);
36
+ }
@@ -0,0 +1,41 @@
1
+ /** Built-in controls consume state and forward actions; the browser player owns evaluation and scheduling. */
2
+ /** Scene selection metadata with its global start time in milliseconds. */
3
+ interface SceneChoice {
4
+ id: string;
5
+ label: string;
6
+ startMs: number;
7
+ }
8
+ /** Controls forward user intent; the player remains the owner of time state. */
9
+ interface ControlActions {
10
+ toggle(): void;
11
+ step(direction: 1 | -1): void;
12
+ seek(timeMs: number): void;
13
+ setSpeed(speed: number): void;
14
+ }
15
+ /** Player-state projection for controls; the scene list is replaced on document load. */
16
+ interface ControlState {
17
+ timeMs: number;
18
+ totalDuration: number;
19
+ playing: boolean;
20
+ speed: number;
21
+ sceneId: string | null;
22
+ scenes: readonly SceneChoice[];
23
+ }
24
+ /**
25
+ * Persistent control DOM translates input to commands; render synchronizes labels, progress and disabled states.
26
+ * Own no clock or independent position; time-related controls are disabled for static documents.
27
+ */
28
+ export declare class PlayerControls {
29
+ readonly element: HTMLDivElement;
30
+ private readonly play;
31
+ private readonly previous;
32
+ private readonly next;
33
+ private readonly progress;
34
+ private readonly scene;
35
+ private readonly speed;
36
+ private sceneChoices;
37
+ constructor(owner: Document, actions: ControlActions);
38
+ /** Update existing controls; scene choices change by reference and custom speeds are added as options. */
39
+ render(state: ControlState): void;
40
+ }
41
+ export type { SceneChoice };