@bytesbybrandon/living-docs 0.2.0 → 0.3.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.
@@ -1,6 +1,8 @@
1
+ import { draftCues } from './cues.js';
2
+ import { draftDesign } from './style.js';
1
3
  import fs from 'node:fs/promises';
2
4
  import path from 'node:path';
3
- import { parseMarkdown, slug, wordCount, plainText } from '../model/markdown.js';
5
+ import { parseMarkdown, slug, wordCount } from '../model/markdown.js';
4
6
  import { htmlToMarkdown, htmlTitle } from './html.js';
5
7
  import { textToMarkdown } from './text.js';
6
8
 
@@ -79,8 +81,8 @@ function normalizeBlocks(blocks) {
79
81
  if (block.type === 'table') {
80
82
  return {
81
83
  type: 'table',
82
- columns: block.columns.map(c => plainText(c)),
83
- rows: block.rows.map(row => row.map(cell => plainText(cell)))
84
+ columns: block.columns.map(c => c.trim()),
85
+ rows: block.rows.map(row => row.map(cell => cell.trim()))
84
86
  };
85
87
  }
86
88
  if (block.type === 'list') {
@@ -197,28 +199,36 @@ export function blockToModelBlock(block) {
197
199
  * Build a valid draft model straight from a source. This is the zero-judgment path:
198
200
  * it renders, then an agent upgrades sections, figures, and callouts.
199
201
  */
200
- export function draftModel(ingested, { theme = 'paper-dark', includePreamble = true, maxBlocks = 400 } = {}) {
202
+ export function draftModel(ingested, { theme, seed, includePreamble = true, maxBlocks = 400, cues = true } = {}) {
201
203
  const { meta, sections, preamble } = ingested;
202
204
  const blocks = [];
205
+ let abstractPending = Boolean(meta.abstract);
206
+ const takeBlock = block => {
207
+ if (abstractPending && block.type === 'paragraph' && block.text === meta.abstract) {
208
+ abstractPending = false;
209
+ return null;
210
+ }
211
+ return blockToModelBlock(block);
212
+ };
203
213
  if (includePreamble && preamble.length) {
204
214
  for (const block of preamble) {
205
- const mapped = blockToModelBlock(block);
215
+ const mapped = takeBlock(block);
206
216
  if (mapped) blocks.push(mapped);
207
217
  }
208
218
  }
209
219
  for (const section of sections) {
210
220
  blocks.push({ type: 'heading', title: section.title, level: 2 });
211
221
  for (const block of section.blocks) {
212
- const mapped = blockToModelBlock(block);
222
+ const mapped = takeBlock(block);
213
223
  if (mapped) blocks.push(mapped);
214
224
  if (blocks.length >= maxBlocks) break;
215
225
  }
216
226
  if (blocks.length >= maxBlocks) break;
217
227
  }
218
228
 
219
- return {
229
+ const model = {
220
230
  schemaVersion: 1,
221
- theme,
231
+ theme: 'paper-dark',
222
232
  meta: {
223
233
  title: meta.title,
224
234
  ...(meta.subtitle ? { subtitle: meta.subtitle } : {}),
@@ -228,6 +238,10 @@ export function draftModel(ingested, { theme = 'paper-dark', includePreamble = t
228
238
  },
229
239
  blocks: blocks.length ? blocks : [{ type: 'prose', markdown: meta.title }]
230
240
  };
241
+ const design = draftDesign(model, { theme, seed });
242
+ model.theme = design.theme;
243
+ if (Object.keys(design.style).length) model.meta.style = design.style;
244
+ return cues ? draftCues(model) : model;
231
245
  }
232
246
 
233
247
  /**
@@ -0,0 +1,57 @@
1
+ import { THEMES } from '../model/schema.js';
2
+ import { toHex, hslToRgb, parseHex } from '../render/style.js';
3
+
4
+ // Design a doc from its own source: pick a look from the shape of the content and
5
+ // derive an accent from the title. Deterministic, validated tokens only, always overridable.
6
+
7
+ const BASES = new Set(['paper-light', 'paper-dark']);
8
+
9
+ function hash(text) {
10
+ let h = 2166136261;
11
+ for (const ch of text) h = Math.imul(h ^ ch.codePointAt(0), 16777619);
12
+ return h >>> 0;
13
+ }
14
+
15
+ function words(blocks) {
16
+ return blocks.reduce((sum, b) => sum + (typeof b.markdown === 'string' ? b.markdown.split(/\s+/).length : 0), 0);
17
+ }
18
+
19
+ /** Pick a theme from the content shape. */
20
+ export function chooseTheme(model) {
21
+ const body = model.blocks.filter(b => b.type !== 'heading');
22
+ const count = type => body.filter(b => b.type === type).length;
23
+ const code = count('code');
24
+ const tables = count('table');
25
+ const dense = count('table') + count('list');
26
+ if (code >= 3 || (body.length && code / body.length >= 0.15)) return 'blueprint';
27
+ if (model.meta.abstract && (tables >= 1 || model.blocks.some(b => b.type === 'heading' && /^\d/.test(b.title)))) return 'paper-light';
28
+ if (words(body) >= 1500 && tables === 0) return 'editorial';
29
+ if (body.length >= 10 && dense / body.length >= 0.4) return 'paper-dark';
30
+ if (body.length <= 14) return 'field-notes';
31
+ return 'paper-dark';
32
+ }
33
+
34
+ /** Accent seed from the title: a stable hue at a tuned saturation and lightness. */
35
+ export function seedFromTitle(title, theme) {
36
+ const hue = (hash(title) % 360) / 360;
37
+ return toHex(hslToRgb({ h: hue, s: 0.5, l: theme === 'paper-dark' ? 0.62 : 0.38 }));
38
+ }
39
+
40
+ /**
41
+ * Return { theme, style } for a drafted model.
42
+ * `theme` and `seed` are explicit overrides; curated looks keep their own accent.
43
+ */
44
+ export function draftDesign(model, { theme, seed } = {}) {
45
+ if (theme && !THEMES[theme]) throw new Error(`unknown theme "${theme}"`);
46
+ const chosen = theme || chooseTheme(model);
47
+ const base = THEMES[chosen].base || chosen;
48
+ const style = {};
49
+ const accent = seed && parseHex(seed) ? seed : BASES.has(chosen) ? seedFromTitle(model.meta.title, chosen) : null;
50
+ if (accent) style.seed = accent;
51
+ const body = model.blocks.length;
52
+ if (body <= 12) style.density = 'roomy';
53
+ else if (body >= 80) style.density = 'compact';
54
+ if (chosen === 'blueprint') style.type = 'technical';
55
+ if (chosen === 'editorial') style.figure = 'minimal';
56
+ return { theme: chosen, base, style };
57
+ }
package/src/mcp/server.js CHANGED
@@ -8,6 +8,7 @@ import { renderDoc, themeList } from '../render/index.js';
8
8
  import { validateDoc, formatErrors } from '../model/validate.js';
9
9
  import { BLOCKS, THEMES, STYLE, blockFieldSummary } from '../model/schema.js';
10
10
  import { createPreviewServer } from '../server/preview.js';
11
+ import { exportVideo, FORMATS } from '../export/video.js';
11
12
 
12
13
  const quoteCell = value => {
13
14
  const text = String(value ?? '');
@@ -33,7 +34,7 @@ function textResult(text) {
33
34
  const TOOLS = [
34
35
  {
35
36
  name: 'living_docs_themes',
36
- description: 'List the three living-doc themes and what each is best for.',
37
+ description: 'List the living-doc themes and what each is best for.',
37
38
  inputSchema: { type: 'object', properties: {}, additionalProperties: false }
38
39
  },
39
40
  {
@@ -56,7 +57,9 @@ const TOOLS = [
56
57
  text: { type: 'string', description: 'Inline source text' },
57
58
  section: { type: 'string', description: 'Return one section: 1-based index or id' },
58
59
  draft: { type: 'boolean', description: 'Include a draft document model built from the source' },
59
- theme: { type: 'string', enum: Object.keys(THEMES), description: 'Theme recorded on the draft' },
60
+ static: { type: 'boolean', description: 'Draft without the auto-generated timeline cues' },
61
+ seed: { type: 'string', description: 'Hex accent seed for the draft, overriding the one derived from the title' },
62
+ theme: { type: 'string', enum: Object.keys(THEMES), description: 'Theme for the draft; omit to let the source pick a look' },
60
63
  out: { type: 'string', description: 'Write the draft model JSON to this path' },
61
64
  max_chars: { type: 'number', description: 'Section text limit, default 12000' }
62
65
  },
@@ -65,7 +68,7 @@ const TOOLS = [
65
68
  },
66
69
  {
67
70
  name: 'living_docs_render',
68
- description: 'Validate a document model and write a self-contained HTML doc. Accepts a model object, a model path, or a source to draft from.',
71
+ description: 'Validate a document model and write a self-contained HTML doc, or an mp4, webm, or gif of its timeline. Accepts a model object, a model path, or a source to draft from.',
69
72
  inputSchema: {
70
73
  type: 'object',
71
74
  properties: {
@@ -73,7 +76,10 @@ const TOOLS = [
73
76
  model_path: { type: 'string', description: 'Path to a model JSON file' },
74
77
  source: { type: 'string', description: 'Source path or URL to ingest and draft from' },
75
78
  theme: { type: 'string', enum: Object.keys(THEMES) },
76
- out: { type: 'string', description: 'Output HTML path, default docs/<title>-<theme>.html' }
79
+ out: { type: 'string', description: 'Output path, default docs/<title>-<theme>.<format>' },
80
+ format: { type: 'string', enum: ['html', ...FORMATS], description: 'html (default) or a video of the timeline; video needs playwright and ffmpeg' },
81
+ size: { type: 'string', description: 'Video stage size, default 1080x1350' },
82
+ fps: { type: 'number', description: 'Video frames per second, default 30' }
77
83
  },
78
84
  additionalProperties: false
79
85
  }
@@ -161,8 +167,7 @@ async function handle(name, args = {}) {
161
167
  toon('sections', ['index', 'id', 'title', 'words'], outline.sections.map(section => [section.index, section.id, section.title, section.words]))
162
168
  ];
163
169
  if (args.draft) {
164
- const theme = args.theme || 'paper-dark';
165
- const model = draftModel(ingested, { theme });
170
+ const model = draftModel(ingested, { theme: args.theme, seed: args.seed, cues: !args.static });
166
171
  const validation = validateDoc(model);
167
172
  if (!validation.ok) return toolError(`draft model invalid:\n${formatErrors(validation.errors)}`);
168
173
  if (args.out) {
@@ -185,10 +190,29 @@ async function handle(name, args = {}) {
185
190
  if (!model && args.source) {
186
191
  const isUrl = /^https?:\/\//i.test(args.source);
187
192
  const ingested = await ingestSource(isUrl ? { url: args.source } : { path: args.source });
188
- model = draftModel(ingested, { theme: args.theme || 'paper-dark' });
193
+ model = draftModel(ingested, { theme: args.theme });
189
194
  }
190
195
  if (!model) return toolError('pass model, model_path, or source');
191
196
  const theme = args.theme || model.theme || 'paper-light';
197
+ if (args.format && args.format !== 'html') {
198
+ const out = args.out || path.join('docs', `${slugify(model.meta?.title || 'document')}-${theme}.${args.format}`);
199
+ fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
200
+ try {
201
+ const video = await exportVideo(model, { theme, baseDir, format: args.format, out, size: args.size, fps: args.fps });
202
+ return textResult([
203
+ 'ok: true',
204
+ `file: ${path.resolve(out)}`,
205
+ `format: ${video.format}`,
206
+ `size: ${video.width}x${video.height}`,
207
+ `frames: ${video.frames}`,
208
+ `seconds: ${video.duration}`,
209
+ `bytes: ${fs.statSync(out).size}`
210
+ ].join('\n'));
211
+ } catch (error) {
212
+ if (error.name === 'RenderError') return toolError(`model invalid:\n${formatErrors(error.errors)}`);
213
+ return toolError(`${error.message}${error.hint ? `\nhint: ${error.hint}` : ''}`);
214
+ }
215
+ }
192
216
  const result = renderDoc(model, { theme, baseDir });
193
217
  if (!result.ok) return toolError(`model invalid:\n${formatErrors(result.errors)}`);
194
218
  const out = args.out || path.join('docs', `${slugify(model.meta.title)}-${theme}.html`);
@@ -235,7 +259,7 @@ export function createServer() {
235
259
  { name: 'living-docs', version: '0.1.0' },
236
260
  {
237
261
  capabilities: { tools: {} },
238
- instructions: 'Turn source documents into designed living docs. Ingest to get an outline, author a document model against the schema, then render with paper-light, paper-dark, or hud.'
262
+ instructions: 'Turn source documents into designed living docs. Ingest to get an outline, author a document model against the schema, then render with a theme from living_docs_themes. A draft without a theme picks a look from the source.'
239
263
  }
240
264
  );
241
265
 
@@ -1,5 +1,5 @@
1
1
  // Small markdown subset: enough to ingest prose without pulling a parser stack.
2
- // Inline: `code`, **bold**, *italic*, ~~strike~~, [text](url), [[n]] footnote refs.
2
+ // Inline: `code`, **bold**, *italic*, ~~strike~~, [text](url), [[n]] footnote refs, [[mark:id|text]] cue marks.
3
3
  // Blocks: ATX headings, paragraphs, fenced code, lists, blockquotes, pipe tables, rules.
4
4
 
5
5
  const ESCAPES = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
@@ -13,6 +13,9 @@ export function safeUrl(url) {
13
13
  return null;
14
14
  }
15
15
 
16
+ // [[mark:id|text]] wraps a source sentence so a timeline cue can point at it.
17
+ export const MARK_RE = /\[\[mark:([a-z][a-z0-9-]*)\|([^\]\n]+)\]\]/g;
18
+
16
19
  export function renderInline(markdown, { allowLinks = true } = {}) {
17
20
  const source = String(markdown ?? '');
18
21
  const tokens = [];
@@ -21,7 +24,8 @@ export function renderInline(markdown, { allowLinks = true } = {}) {
21
24
  return `\u0000${tokens.length - 1}\u0000`;
22
25
  };
23
26
 
24
- let text = source.replace(/`([^`\n]+)`/g, (_, code) => stash(`<code>${escapeHtml(code)}</code>`));
27
+ let text = source.replace(MARK_RE, (_, id, body) => stash(`<span class="ld-mark" data-mark="${id}">${renderInline(body, { allowLinks })}</span>`));
28
+ text = text.replace(/`([^`\n]+)`/g, (_, code) => stash(`<code>${escapeHtml(code)}</code>`));
25
29
 
26
30
  if (allowLinks) {
27
31
  text = text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
@@ -56,7 +60,8 @@ const olRe = /^\s*\d+[.)]\s+(.*)$/;
56
60
  const hrRe = /^\s*([-*_])(?:\s*\1){2,}\s*$/;
57
61
 
58
62
  function splitRow(line) {
59
- return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(cell => cell.trim());
63
+ const body = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '');
64
+ return body.split(/(?<!\\)\|/).map(cell => cell.trim().replace(/\\\|/g, '|'));
60
65
  }
61
66
 
62
67
  /**
@@ -154,6 +159,7 @@ export function parseMarkdown(source) {
154
159
 
155
160
  export function plainText(markdown) {
156
161
  return String(markdown ?? '')
162
+ .replace(MARK_RE, '$2')
157
163
  .replace(/`([^`]+)`/g, '$1')
158
164
  .replace(/\[\[(\d+)\]\]/g, '')
159
165
  .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
@@ -300,7 +300,8 @@ export const STYLE = {
300
300
  seed: str('hex color that derives the accent family, e.g. #2f6f8f', { optional: true }),
301
301
  type: oneOf(['classic', 'editorial', 'technical', 'mono', 'hybrid', 'terminal'], 'curated type pairing', { optional: true }),
302
302
  density: oneOf(['compact', 'normal', 'roomy'], 'vertical rhythm', { optional: true }),
303
- figure: oneOf(['outline', 'solid', 'minimal'], 'diagram and chart treatment', { optional: true })
303
+ figure: oneOf(['outline', 'solid', 'minimal'], 'diagram and chart treatment', { optional: true }),
304
+ layout: oneOf(['columns', 'single'], 'paper themes only: two columns (default) or one reading column for code and wide tables', { optional: true })
304
305
  };
305
306
 
306
307
  export const META = {
@@ -326,6 +327,31 @@ export const META = {
326
327
  })
327
328
  };
328
329
 
330
+ // Any block may carry an id so timeline cues can target it.
331
+ export const BLOCK_ID = str('cue anchor: lowercase letters, digits, hyphens; unique in the document', { optional: true });
332
+
333
+ export const CUE_ACTIONS = {
334
+ highlight: 'sweep a highlighter across a [[mark:id|text]] sentence or a block, then leave a faint underline',
335
+ focus: 'light up a block or part (diagram node, panel, scene label) and dim its siblings',
336
+ reveal: 'fade and rise a block or part into view at the cue time; hidden before it',
337
+ count: 'count the numbers inside the target up from zero',
338
+ type: 'type the text of the target out character by character',
339
+ camera: 'stage video only: scroll the frame to a block or part, or back to "page" (flat at 100% unless zoom is set)',
340
+ activate: 'hud scene only: glide the crosshair to a scene label (target "<sceneId>/<label>")'
341
+ };
342
+
343
+ export const TIMELINE = {
344
+ duration: num('total seconds; default last cue end plus 1.5', { optional: true }),
345
+ loop: bool('restart when the timeline ends (live page only)', { optional: true }),
346
+ cues: arr(obj({
347
+ at: num('start time in seconds'),
348
+ dur: num('seconds the cue runs, default 1.2', { optional: true }),
349
+ do: oneOf(Object.keys(CUE_ACTIONS), 'action'),
350
+ target: str('"mark:<id>", "<blockId>", "<blockId>/<part>" (diagram node id, panel index, scene label), or "page" for camera'),
351
+ zoom: num('camera only: opt-in scale, default 1 (flat)', { optional: true })
352
+ }, 'cue'), 'cues in any order')
353
+ };
354
+
329
355
  export const THEMES = {
330
356
  'paper-light': {
331
357
  summary: 'Academic paper: serif, two justified columns, drop caps, margin stamp, print rules.',
@@ -335,6 +361,21 @@ export const THEMES = {
335
361
  summary: 'Reading notes: cream on carbon, orange accent, rules, algorithm boxes, takeaway strip.',
336
362
  best: 'paper summaries, essays, annotated readings'
337
363
  },
364
+ 'field-notes': {
365
+ base: 'paper-light',
366
+ summary: 'Warm cream paper, olive rules, italic titles.',
367
+ best: 'field reports, lab notes, reading journals'
368
+ },
369
+ editorial: {
370
+ base: 'paper-light',
371
+ summary: 'Magazine masthead: oversized left-set title, heavy rules, signal red.',
372
+ best: 'essays, features, opinion pieces'
373
+ },
374
+ blueprint: {
375
+ base: 'paper-dark',
376
+ summary: 'Drafting-table blue with a faint grid and cyan linework.',
377
+ best: 'architecture notes, system designs, specs'
378
+ },
338
379
  hud: {
339
380
  summary: 'Telemetry console: monospaced 12-panel grid, cyan accents, graph hero, live clock.',
340
381
  best: 'repo audits, skill catalogs, system snapshots, run reports'
@@ -1,4 +1,5 @@
1
- import { BLOCKS, META, THEMES } from './schema.js';
1
+ import { BLOCKS, META, THEMES, TIMELINE, BLOCK_ID, CUE_ACTIONS } from './schema.js';
2
+ import { MARK_RE } from './markdown.js';
2
3
 
3
4
  const isObj = v => v !== null && typeof v === 'object' && !Array.isArray(v);
4
5
 
@@ -55,8 +56,8 @@ export function validateDoc(doc, { defaultTheme = null } = {}) {
55
56
  }
56
57
 
57
58
  for (const name of Object.keys(doc)) {
58
- if (!['schemaVersion', 'theme', 'meta', 'blocks'].includes(name)) {
59
- err(name, 'unknown top-level field (allowed: schemaVersion, theme, meta, blocks)');
59
+ if (!['schemaVersion', 'theme', 'meta', 'blocks', 'timeline'].includes(name)) {
60
+ err(name, 'unknown top-level field (allowed: schemaVersion, theme, meta, blocks, timeline)');
60
61
  }
61
62
  }
62
63
  if (doc.schemaVersion !== undefined && doc.schemaVersion !== 1) err('schemaVersion', 'must be 1');
@@ -69,6 +70,7 @@ export function validateDoc(doc, { defaultTheme = null } = {}) {
69
70
  return { ok: false, errors, theme: doc.theme || defaultTheme };
70
71
  }
71
72
 
73
+ const ids = new Set();
72
74
  doc.blocks.forEach((block, i) => {
73
75
  const path = `blocks[${i}]`;
74
76
  if (!isObj(block)) { err(path, 'must be an object with a `type`'); return; }
@@ -77,7 +79,7 @@ export function validateDoc(doc, { defaultTheme = null } = {}) {
77
79
  const def = BLOCKS[type];
78
80
  if (!def) { err(`${path}.type`, `unknown block type "${type}" (one of: ${Object.keys(BLOCKS).join(', ')})`); return; }
79
81
  for (const name of Object.keys(block)) {
80
- if (name !== 'type' && !(name in def.fields)) {
82
+ if (name !== 'type' && name !== 'id' && !(name in def.fields)) {
81
83
  err(`${path}.${name}`, `unknown field for ${type} (allowed: ${Object.keys(def.fields).join(', ')})`);
82
84
  }
83
85
  }
@@ -85,6 +87,12 @@ export function validateDoc(doc, { defaultTheme = null } = {}) {
85
87
  checkSpec(field, block[name], `${path}.${name}`, err);
86
88
  }
87
89
  if (def.check) def.check(block, path, err);
90
+ checkSpec(BLOCK_ID, block.id, `${path}.id`, err);
91
+ if (typeof block.id === 'string') {
92
+ if (!ID_RE.test(block.id)) err(`${path}.id`, 'must match ^[a-z][a-z0-9-]*$');
93
+ else if (ids.has(block.id)) err(`${path}.id`, `duplicate block id "${block.id}"`);
94
+ ids.add(block.id);
95
+ }
88
96
  if (type === 'panels' && Array.isArray(block.items)) {
89
97
  block.items.forEach((item, j) => {
90
98
  const wanted = String(j + 1).padStart(2, '0');
@@ -95,9 +103,79 @@ export function validateDoc(doc, { defaultTheme = null } = {}) {
95
103
  }
96
104
  });
97
105
 
106
+ if (doc.timeline !== undefined) checkTimeline(doc, err);
107
+
98
108
  return { ok: errors.length === 0, errors, theme: doc.theme || defaultTheme };
99
109
  }
100
110
 
111
+ const ID_RE = /^[a-z][a-z0-9-]*$/;
112
+
113
+ // Parts a cue can address inside a block: diagram nodes, panels, scene labels.
114
+ function blockParts(block) {
115
+ if (block.type === 'figure' && block.spec === 'diagram') return (block.diagram?.nodes || []).map(node => node.id);
116
+ if (block.type === 'panels') return (block.items || []).map((item, i) => item.index || String(i + 1).padStart(2, '0'));
117
+ if (block.type === 'graphScene') return (block.graph?.labels || []).slice(0, 9); // the scene draws the first 9
118
+ return [];
119
+ }
120
+
121
+ function collectMarks(value, path, marks, err) {
122
+ if (typeof value === 'string') {
123
+ for (const match of value.matchAll(MARK_RE)) {
124
+ if (marks.has(match[1])) err(path, `duplicate mark id "${match[1]}"`);
125
+ marks.add(match[1]);
126
+ }
127
+ } else if (Array.isArray(value)) {
128
+ value.forEach((item, i) => collectMarks(item, `${path}[${i}]`, marks, err));
129
+ } else if (isObj(value)) {
130
+ for (const [key, item] of Object.entries(value)) collectMarks(item, `${path}.${key}`, marks, err);
131
+ }
132
+ }
133
+
134
+ function checkTimeline(doc, err) {
135
+ checkSpec({ kind: 'object', fields: TIMELINE }, doc.timeline, 'timeline', err);
136
+ if (!isObj(doc.timeline) || !Array.isArray(doc.timeline.cues)) return;
137
+
138
+ const blocks = new Map();
139
+ for (const block of doc.blocks) {
140
+ if (isObj(block) && typeof block.id === 'string' && !blocks.has(block.id)) blocks.set(block.id, block);
141
+ }
142
+ const marks = new Set();
143
+ collectMarks(doc.meta, 'meta', marks, err);
144
+ collectMarks(doc.blocks, 'blocks', marks, err);
145
+
146
+ doc.timeline.cues.forEach((cue, i) => {
147
+ const path = `timeline.cues[${i}]`;
148
+ if (!isObj(cue) || typeof cue.target !== 'string' || !CUE_ACTIONS[cue.do]) return;
149
+ if (typeof cue.at === 'number' && cue.at < 0) err(`${path}.at`, 'must be 0 or more');
150
+ if (typeof cue.dur === 'number' && cue.dur <= 0) err(`${path}.dur`, 'must be more than 0');
151
+ if (cue.zoom !== undefined && cue.do !== 'camera') err(`${path}.zoom`, 'only camera cues take zoom');
152
+ const target = cue.target;
153
+ if (target === 'page') {
154
+ if (cue.do !== 'camera') err(`${path}.target`, '"page" is only a camera target');
155
+ return;
156
+ }
157
+ if (target.startsWith('mark:')) {
158
+ const id = target.slice(5);
159
+ if (!marks.has(id)) err(`${path}.target`, `unknown mark "${id}" (add [[mark:${id}|text]] to a prose string)`);
160
+ else if (cue.do === 'camera' || cue.do === 'activate') err(`${path}.do`, `${cue.do} cannot target a mark`);
161
+ return;
162
+ }
163
+ const [blockId, part, extra] = target.split('/');
164
+ const block = blocks.get(blockId);
165
+ if (!block || extra !== undefined) {
166
+ err(`${path}.target`, `unknown block id "${blockId}" (known: ${[...blocks.keys()].join(', ') || 'none'})`);
167
+ return;
168
+ }
169
+ if (part !== undefined) {
170
+ const parts = blockParts(block);
171
+ if (!parts.includes(part)) err(`${path}.target`, `unknown part "${part}" in ${blockId} (known: ${parts.join(', ') || 'none'})`);
172
+ }
173
+ if (cue.do === 'activate' && (block.type !== 'graphScene' || part === undefined)) {
174
+ err(`${path}.target`, 'activate needs "<graphScene id>/<label>"');
175
+ }
176
+ });
177
+ }
178
+
101
179
  export function formatErrors(errors, limit = 25) {
102
180
  const shown = errors.slice(0, limit);
103
181
  const lines = shown.map(e => `${e.path}: ${e.message}`);
@@ -186,7 +186,7 @@ function renderGraphScene(block, ctx) {
186
186
  const left = (active.x / payload.width) * 100;
187
187
  const top = (active.y / payload.height) * 100;
188
188
  const cardRows = (card?.rows || []).map(row => `<div class="sc-row"><span>${escapeHtml(row.k)}</span><b>${escapeHtml(row.v)}</b></div>`).join('');
189
- const labels = payload.labels.map(label => `<span class="scene-label" style="left:${(label.x / payload.width) * 100}%;top:${(label.y / payload.height) * 100}%">${escapeHtml(label.text)}</span>`).join('');
189
+ const labels = payload.labels.map(label => `<span class="scene-label" data-part="${escapeHtml(label.text)}" style="left:${(label.x / payload.width) * 100}%;top:${(label.y / payload.height) * 100}%">${escapeHtml(label.text)}</span>`).join('');
190
190
  const status = block.status
191
191
  ? `<div class="scene-status"><span class="sc-seq">${escapeHtml(block.status.seq)}</span> <span class="sc-kind">${escapeHtml(block.status.kind)}</span> <span class="sc-sep">::</span> <span class="sc-text">${escapeHtml(block.status.text)}</span> <span class="sc-tail">${escapeHtml(block.status.tail)}</span></div>`
192
192
  : '';
@@ -209,8 +209,8 @@ function renderGraphScene(block, ctx) {
209
209
  <canvas class="scene-canvas" aria-hidden="true"></canvas>
210
210
  <div class="scene-crosshair" style="left:${left.toFixed(2)}%;top:${top.toFixed(2)}%"></div>
211
211
  ${labels}
212
- <span class="scene-active-label" style="left:${left.toFixed(2)}%;top:${top.toFixed(2)}%">${escapeHtml(graph.active?.id || '')}</span>
213
- ${graph.active?.badge ? `<span class="scene-badge" style="left:${left.toFixed(2)}%;top:${top.toFixed(2)}%">${escapeHtml(graph.active.badge)}</span>` : ''}
212
+ <span class="scene-active-label${left > 55 ? ' to-left' : ''}" style="left:${left.toFixed(2)}%;top:${top.toFixed(2)}%">${escapeHtml(graph.active?.id || '')}</span>
213
+ ${graph.active?.badge ? `<span class="scene-badge${left > 55 ? ' to-left' : ''}" style="left:${left.toFixed(2)}%;top:${top.toFixed(2)}%">${escapeHtml(graph.active.badge)}</span>` : ''}
214
214
  ${card ? `<div class="scene-card" style="left:${left.toFixed(2)}%;top:${top.toFixed(2)}%">
215
215
  <div class="sc-card-title">${escapeHtml(card.title)}</div>
216
216
  ${cardRows}
@@ -305,7 +305,7 @@ function renderPanels(block, ctx) {
305
305
  const columns = Math.max(1, Math.min(6, block.columns || 4));
306
306
  const items = block.items.map((panel, index) => {
307
307
  const tag = panel.tag ? `<span class="panel-tag">${escapeHtml(panel.tag)}</span>` : '';
308
- return `<article class="panel panel-${panel.kind}"${panel.span ? ` style="grid-column: span ${Math.min(columns, panel.span)}"` : ''}>
308
+ return `<article class="panel panel-${panel.kind}" data-part="${escapeHtml(panel.index || String(index + 1).padStart(2, '0'))}"${panel.span ? ` style="grid-column: span ${Math.min(columns, panel.span)}"` : ''}>
309
309
  <header class="panel-head"><span class="panel-index">${escapeHtml(panel.index || String(index + 1).padStart(2, '0'))}</span><span class="panel-title">${escapeHtml(panel.title || '')}</span>${tag}</header>
310
310
  <div class="panel-body">${renderPanelBody(panel.kind, panel.data || {}, ctx)}</div>
311
311
  </article>`;
@@ -354,8 +354,12 @@ export function renderBlock(block, ctx) {
354
354
  }
355
355
  }
356
356
 
357
+ // A block id becomes a data-cue anchor on the block's first element.
357
358
  export function renderBlocks(blocks, ctx) {
358
- return blocks.map(block => renderBlock(block, ctx)).join('\n');
359
+ return blocks.map(block => {
360
+ const html = renderBlock(block, ctx);
361
+ return block.id ? html.replace(/^(\s*<[a-z0-9]+)/i, `$1 data-cue="${escapeHtml(block.id)}"`) : html;
362
+ }).join('\n');
359
363
  }
360
364
 
361
365
  export function documentStats(model, html) {
@@ -2,14 +2,17 @@ import { validateDoc } from '../model/validate.js';
2
2
  import { createContext, renderBlocks, documentStats } from './blocks.js';
3
3
  import { THEMES as THEME_INFO } from '../model/schema.js';
4
4
  import { resolveStyle } from './style.js';
5
+ import { timelineAssets } from './timeline.js';
5
6
  import * as paperLight from './themes/paper-light.js';
6
7
  import * as paperDark from './themes/paper-dark.js';
7
8
  import * as hud from './themes/hud.js';
9
+ import { VARIANTS } from './themes/variants.js';
8
10
 
9
11
  export const THEMES = {
10
12
  'paper-light': paperLight,
11
13
  'paper-dark': paperDark,
12
- hud
14
+ hud,
15
+ ...VARIANTS
13
16
  };
14
17
 
15
18
  export class RenderError extends Error {
@@ -33,24 +36,32 @@ export function themeList() {
33
36
  * Render a document model to a complete, self-contained HTML string.
34
37
  * @returns {{ok: boolean, html?: string, stats?: object, warnings?: string[], errors?: object[], theme: string}}
35
38
  */
36
- export function renderDoc(model, { theme, baseDir, accent } = {}) {
39
+ export function renderDoc(model, { theme, baseDir, accent, stage = false } = {}) {
37
40
  const chosen = theme || model?.theme || 'paper-light';
38
41
  const themeModule = THEMES[chosen];
39
42
  if (!themeModule) {
40
43
  throw new RenderError(`Unknown theme "${chosen}". Available: ${Object.keys(THEMES).join(', ')}`);
41
44
  }
42
45
 
46
+ const base = THEME_INFO[chosen]?.base || chosen;
43
47
  const validation = validateDoc(model, { defaultTheme: chosen });
44
48
  if (!validation.ok) {
45
49
  return { ok: false, theme: chosen, errors: validation.errors, warnings: [] };
46
50
  }
47
51
 
48
- const styled = resolveStyle(chosen, themeModule.palette, model.meta.style || {}, accent || model.meta.accent);
52
+ const styled = resolveStyle(base, themeModule.palette, model.meta.style || {}, accent || model.meta.accent);
49
53
  const palette = styled.palette;
50
- const ctx = createContext({ theme: chosen, palette, baseDir, model });
54
+ const ctx = createContext({ theme: base, palette, baseDir, model });
51
55
  ctx.style = styled;
52
56
  const blocksHtml = renderBlocks(model.blocks, ctx);
53
- const html = themeModule.document({ model, ctx, palette, blocksHtml, style: styled });
57
+ let html = themeModule.document({ model, ctx, palette, blocksHtml, style: styled });
58
+ if (base !== chosen) html = html.replace(`class="theme-${base} `, () => `class="theme-${base} theme-${chosen} `);
59
+ // Timeline runtime only ships when cues exist or a video stage is rendered, so static docs keep their bytes.
60
+ if (model.timeline || stage) {
61
+ html = html
62
+ .replace('<html lang="en">', `<html lang="en" class="ld-timed${stage ? ' ld-stage' : ''}">`)
63
+ .replace('</body>', () => `${timelineAssets(model)}</body>`);
64
+ }
54
65
  const stats = documentStats(model, html);
55
66
 
56
67
  return {
@@ -86,7 +86,7 @@ function rgbToHsl({ r, g, b }) {
86
86
  return { h: hue, s: saturation, l: lightness };
87
87
  }
88
88
 
89
- function hslToRgb({ h, s, l }) {
89
+ export function hslToRgb({ h, s, l }) {
90
90
  if (s === 0) {
91
91
  const value = l * 255;
92
92
  return { r: value, g: value, b: value };
@@ -173,13 +173,15 @@ export function resolveStyle(theme, basePalette, style = {}, accentOverride) {
173
173
 
174
174
  const density = DENSITIES.has(style.density) ? style.density : 'normal';
175
175
  const figure = FIGURES.has(style.figure) ? style.figure : 'outline';
176
+ const layout = !isConsole && style.layout === 'single' ? 'single' : 'columns';
176
177
 
177
178
  return {
178
179
  palette,
179
180
  type,
180
181
  density,
181
182
  figure,
182
- className: `density-${density} type-${type} fig-${figure}`
183
+ layout,
184
+ className: `density-${density} type-${type} fig-${figure} layout-${layout}`
183
185
  };
184
186
  }
185
187
 
@@ -287,5 +289,8 @@ export function styleCss(theme, palette, resolved) {
287
289
  const overrides = theme === 'hud' ? CONSOLE_OVERRIDES : PAPER_OVERRIDES;
288
290
  const density = DENSITY[theme]?.[resolved.density] || '';
289
291
  const type = TYPE[theme]?.[resolved.type] || '';
290
- return `${palettes(palette)}${overrides}${density}${type}`;
292
+ const layout = resolved.layout === 'single'
293
+ ? '.columns{column-count:1;max-width:880px;margin-left:auto;margin-right:auto}.span-page{column-span:none}.footnotes{column-count:1}'
294
+ : '';
295
+ return `${palettes(palette)}${overrides}${density}${type}${layout}`;
291
296
  }