@bytesbybrandon/living-docs 0.2.1 → 0.3.1

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.
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) => {
@@ -155,6 +159,7 @@ export function parseMarkdown(source) {
155
159
 
156
160
  export function plainText(markdown) {
157
161
  return String(markdown ?? '')
162
+ .replace(MARK_RE, '$2')
158
163
  .replace(/`([^`]+)`/g, '$1')
159
164
  .replace(/\[\[(\d+)\]\]/g, '')
160
165
  .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
@@ -327,6 +327,31 @@ export const META = {
327
327
  })
328
328
  };
329
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
+
330
355
  export const THEMES = {
331
356
  'paper-light': {
332
357
  summary: 'Academic paper: serif, two justified columns, drop caps, margin stamp, print rules.',
@@ -336,25 +361,52 @@ export const THEMES = {
336
361
  summary: 'Reading notes: cream on carbon, orange accent, rules, algorithm boxes, takeaway strip.',
337
362
  best: 'paper summaries, essays, annotated readings'
338
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
+ },
339
379
  hud: {
340
380
  summary: 'Telemetry console: monospaced 12-panel grid, cyan accents, graph hero, live clock.',
341
381
  best: 'repo audits, skill catalogs, system snapshots, run reports'
342
382
  }
343
383
  };
344
384
 
385
+ function describeField(name, spec) {
386
+ return {
387
+ name,
388
+ kind: spec.kind,
389
+ required: !spec.optional,
390
+ desc: spec.desc,
391
+ ...(spec.values ? { values: spec.values } : {}),
392
+ ...(spec.of ? { of: spec.of.kind } : {})
393
+ };
394
+ }
395
+
396
+ // Nested object fields are listed under dotted paths and array items under `[]`,
397
+ // so `diagram.nodes[].x` is discoverable without reading the source.
398
+ function flattenFields(fields, prefix = '') {
399
+ return Object.entries(fields).flatMap(([name, spec]) => {
400
+ const path = `${prefix}${name}`;
401
+ const row = describeField(path, spec);
402
+ const inner = spec.kind === 'object' ? spec : spec.kind === 'array' && spec.of?.kind === 'object' ? spec.of : null;
403
+ if (!inner) return [row];
404
+ return [row, ...flattenFields(inner.fields, `${path}${spec.kind === 'array' ? '[].' : '.'}`)];
405
+ });
406
+ }
407
+
345
408
  export function blockFieldSummary(type) {
346
409
  const block = BLOCKS[type];
347
410
  if (!block) return null;
348
- return {
349
- type,
350
- summary: block.summary,
351
- fields: Object.entries(block.fields).map(([name, spec]) => ({
352
- name,
353
- kind: spec.kind,
354
- required: !spec.optional,
355
- desc: spec.desc,
356
- ...(spec.values ? { values: spec.values } : {}),
357
- ...(spec.of ? { of: spec.of.kind } : {})
358
- }))
359
- };
411
+ return { type, summary: block.summary, fields: flattenFields(block.fields) };
360
412
  }
@@ -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 };
package/src/render/svg.js CHANGED
@@ -38,23 +38,19 @@ function anchor(node, px, py) {
38
38
  return { x: cx + dx * s, y: cy + dy * s };
39
39
  }
40
40
 
41
- function iconMarkup(node, palette) {
42
- const cx = node.x + node.w / 2;
43
- const cy = node.y + node.h / 2;
41
+ // Icons sit in a 13-unit band at the top of the node's text block, so they never touch the label.
42
+ function iconMarkup(node, palette, top) {
43
+ const at = `transform="translate(${round(node.x + node.w / 2)} ${round(top)})"`;
44
44
  const stroke = palette.accent;
45
45
  switch (node.icon) {
46
46
  case 'file':
47
- return `<g class="d-icon" fill="none" stroke="${stroke}" stroke-width="1.2">
48
- <path d="M ${cx - 7} ${cy - 18} h 10 l 4 4 v 13 h -14 z"/><path d="M ${cx + 3} ${cy - 18} v 4 h 4"/>
49
- </g>`;
47
+ return `<g class="d-icon" ${at} fill="none" stroke="${stroke}" stroke-width="1.2"><path d="M -6 0 h 8 l 4 4 v 9 h -12 z"/><path d="M 2 0 v 4 h 4"/></g>`;
50
48
  case 'clock':
51
- return `<g class="d-icon" fill="none" stroke="${stroke}" stroke-width="1.2">
52
- <circle cx="${cx}" cy="${cy - 10}" r="7"/><path d="M ${cx} ${cy - 14} v 4 l 3 2"/>
53
- </g>`;
49
+ return `<g class="d-icon" ${at} fill="none" stroke="${stroke}" stroke-width="1.2"><circle cx="0" cy="6.5" r="6.3"/><path d="M 0 3 v 3.5 l 2.6 1.8"/></g>`;
54
50
  case 'star':
55
- return `<path class="d-icon" d="M ${cx} ${cy - 20} l 2.4 5.6 6 .7 -4.4 4.2 1.2 5.9 -5.2 -2.9 -5.2 2.9 1.2 -5.9 -4.4 -4.2 6 -.7 z" fill="${stroke}" stroke="none"/>`;
51
+ return `<path class="d-icon" ${at} d="M 0 0 l 2 4.6 5 .6 -3.7 3.4 1 4.9 -4.3 -2.4 -4.3 2.4 1 -4.9 -3.7 -3.4 5 -.6 z" fill="${stroke}" stroke="none"/>`;
56
52
  case 'badge':
57
- return `<g class="d-icon"><rect x="${cx - 9}" y="${cy - 16}" width="18" height="11" rx="1.5" fill="none" stroke="${stroke}" stroke-width="1.1"/><path d="M ${cx - 5} ${cy - 10.5} h 10" stroke="${stroke}" stroke-width="1.1"/></g>`;
53
+ return `<g class="d-icon" ${at} fill="none" stroke="${stroke}" stroke-width="1.1"><rect x="-9" y="1" width="18" height="11" rx="1.5"/><path d="M -5 6.5 h 10"/></g>`;
58
54
  default:
59
55
  return '';
60
56
  }
@@ -113,7 +109,7 @@ export function diagramSvg(spec, palette, { figure = 'outline', motion = 'none'
113
109
  if (edge.route === 'elbow' || (edge.style === 'dashed' && Math.abs(toCenter.y - fromCenter.y) > 40)) {
114
110
  const midX = round((start.x + end.x) / 2);
115
111
  d = `M ${round(start.x)} ${round(start.y)} H ${midX} V ${round(end.y)} H ${round(end.x)}`;
116
- labelPoint = { x: midX, y: (start.y + end.y) / 2, anchor: 'middle' };
112
+ labelPoint = { x: midX + 8, y: (start.y + end.y) / 2 + 4, anchor: 'start' }; // beside the vertical run, not across it
117
113
  } else {
118
114
  d = `M ${round(start.x)} ${round(start.y)} L ${round(end.x)} ${round(end.y)}`;
119
115
  labelPoint = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2, anchor: 'middle' };
@@ -150,18 +146,19 @@ export function diagramSvg(spec, palette, { figure = 'outline', motion = 'none'
150
146
  const strokeWidth = node.kind === 'accent' ? 1.5 : minimal ? 0.8 : solid ? 1.3 : 1;
151
147
  const dash = node.kind === 'dashed' ? ' stroke-dasharray="5 4"' : '';
152
148
  const opacity = node.kind === 'dashed' ? ' stroke-opacity="0.75"' : '';
153
- const iconSpace = node.icon && node.icon !== 'none' ? 14 : 0;
149
+ const iconSpace = node.icon && node.icon !== 'none' ? 18 : 0;
154
150
  const lines = wrapLabel(node.label, Math.max(8, Math.floor((node.w - 16) / 6.5)), 3);
155
151
  const lineHeight = 15;
156
152
  const blockHeight = lines.length * lineHeight + (node.sub ? 13 : 0) + iconSpace;
157
- let textY = node.y + (node.h - blockHeight) / 2 + lineHeight - 4 + iconSpace;
153
+ const blockTop = node.y + (node.h - blockHeight) / 2;
154
+ let textY = blockTop + lineHeight - 4 + iconSpace;
158
155
 
159
- nodeMarkup.push(`<g class="d-node d-node-${node.kind}" style="--i:${nodeIndex++}">`);
156
+ nodeMarkup.push(`<g class="d-node d-node-${node.kind}" data-part="${escapeHtml(node.id)}" style="--i:${nodeIndex++}">`);
160
157
  if (node.kind === 'stack') {
161
158
  nodeMarkup.push(`<rect x="${node.x + 5}" y="${node.y + 5}" width="${node.w}" height="${node.h}" rx="2" fill="${palette.surface}" stroke="${palette.muted}" stroke-opacity="0.5"/>`);
162
159
  }
163
160
  nodeMarkup.push(`<rect x="${node.x}" y="${node.y}" width="${node.w}" height="${node.h}" rx="2" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}"${dash}${opacity}/>`);
164
- nodeMarkup.push(iconMarkup(node, palette));
161
+ nodeMarkup.push(iconMarkup(node, palette, blockTop));
165
162
  for (const line of lines) {
166
163
  nodeMarkup.push(`<text x="${round(node.x + node.w / 2)}" y="${round(textY)}" text-anchor="middle" font-size="13" fill="${palette.ink}" font-weight="${node.kind === 'accent' ? 600 : 400}">${escapeHtml(line)}</text>`);
167
164
  textY += lineHeight;
@@ -50,7 +50,9 @@ body{margin:0;background:#020409;color:var(--ink);font-family:var(--mono);font-s
50
50
  .scene-crosshair::before{content:"";position:absolute;left:50%;top:-80px;bottom:-80px;border-left:1px solid rgba(94,200,229,.14)}
51
51
  .scene-crosshair::after{content:"";position:absolute;top:50%;left:-80px;right:-80px;border-top:1px solid rgba(94,200,229,.14)}
52
52
  .scene-active-label{position:absolute;transform:translate(16px,-150%);padding:3px 9px;border:1px solid #3a7188;background:rgba(4,10,16,.86);font-size:11.5px;letter-spacing:.05em;color:#e6f7ff;white-space:nowrap}
53
+ .scene-active-label.to-left{transform:translate(calc(-100% - 16px),-150%)}
53
54
  .scene-badge{position:absolute;transform:translate(16px,-50%);padding:2px 7px;border:1px solid #2a5568;background:#06222e;font-size:9px;letter-spacing:.14em;color:#8fd8ee}
55
+ .scene-badge.to-left{transform:translate(calc(-100% - 16px),-50%)}
54
56
  .scene-card{position:absolute;transform:translate(26px,26px);min-width:236px;max-width:310px;border:1px solid #2a5568;background:rgba(4,12,19,.94);padding:12px 13px;font-size:10px;color:#9fc0cf;box-shadow:0 0 34px rgba(0,0,0,.65)}
55
57
  .sc-card-title{font-size:13.5px;color:#e6f7ff;margin-bottom:8px;letter-spacing:.03em}
56
58
  .sc-row{display:flex;justify-content:space-between;gap:14px;padding:2px 0;border-bottom:1px solid #0e1f2a}
@@ -217,7 +219,7 @@ tbody td{padding:5px 6px;color:#b7d4e1;border-bottom:1px solid #0a1822}
217
219
  .hud-head{flex-direction:column;gap:8px}
218
220
  .live{font-size:8px}
219
221
  .scene-stage{min-height:240px}
220
- .scene-card{left:12px!important;top:auto!important;bottom:10px;transform:none;min-width:0;max-width:none;width:calc(100% - 24px)}
222
+ .scene-card{display:none}
221
223
  .scene-label{font-size:8px}
222
224
  .sc-tail{margin-left:0}
223
225
  .footnotes{column-count:1}
@@ -251,11 +253,14 @@ function head(model) {
251
253
 
252
254
  const RUNTIME = `
253
255
  (function(){
256
+ // With a timeline (ld-timed) the page clock drives everything through window.__ldHooks.
257
+ var timed = document.documentElement.classList.contains('ld-timed');
258
+ var hooks = window.__ldHooks = window.__ldHooks || [];
254
259
  var clocks = document.querySelectorAll('[data-clock]');
255
260
  var started = Date.now();
256
261
  function pad(n){ return String(n).padStart(2,'0'); }
257
- function tick(){
258
- var ms = Date.now() - started;
262
+ function tick(at){
263
+ var ms = timed ? at : Date.now() - started;
259
264
  var cs = Math.floor(ms / 10) % 100;
260
265
  var s = Math.floor(ms / 1000) % 60;
261
266
  var m = Math.floor(ms / 60000) % 60;
@@ -263,8 +268,8 @@ const RUNTIME = `
263
268
  var text = pad(h) + ':' + pad(m) + ':' + pad(s) + '.' + pad(cs);
264
269
  for (var i = 0; i < clocks.length; i++) clocks[i].textContent = text;
265
270
  }
266
- tick();
267
- setInterval(tick, 90);
271
+ if (timed) hooks.push(tick);
272
+ else { tick(); setInterval(tick, 90); }
268
273
 
269
274
  var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
270
275
  var scenes = document.querySelectorAll('.scene');
@@ -321,8 +326,10 @@ const RUNTIME = `
321
326
  }
322
327
 
323
328
  var active = nodes[data.active];
324
- if (active) {
325
- var ax = active.x * kx, ay = active.y * ky;
329
+ var moved = scene.__ldActive;
330
+ if (active || moved) {
331
+ var ax = moved ? moved.x / 100 * width : active.x * kx;
332
+ var ay = moved ? moved.y / 100 * height : active.y * ky;
326
333
  var pulse = reduce ? 1 : 0.5 + 0.5 * Math.sin(phase * 0.0014);
327
334
  ctx.beginPath();
328
335
  ctx.fillStyle = 'rgba(220,248,255,0.95)';
@@ -339,6 +346,10 @@ const RUNTIME = `
339
346
  }
340
347
  }
341
348
 
349
+ if (timed) {
350
+ hooks.push(draw);
351
+ return;
352
+ }
342
353
  if (reduce) {
343
354
  draw(0);
344
355
  return;
@@ -0,0 +1,43 @@
1
+ import * as paperLight from './paper-light.js';
2
+ import * as paperDark from './paper-dark.js';
3
+
4
+ // A curated look is a base theme plus its own palette and a short CSS layer.
5
+ // Blocks, figures, and the timeline all come from the base, so every look stays flat and printable.
6
+ function variant(base, palette, css) {
7
+ return {
8
+ ...base,
9
+ palette: { ...base.palette, ...palette },
10
+ document: args => base.document(args).replace('</head>', () => `<style>${css}</style>\n</head>`)
11
+ };
12
+ }
13
+
14
+ export const VARIANTS = {
15
+ 'field-notes': variant(paperLight, {
16
+ bg: '#f7f1e1', surface: '#f1e9d3', ink: '#25261c', muted: '#5c5a46', hair: '#d9cfb2', rule: '#33401f',
17
+ accent: '#486a1f', accentSoft: '#e5ebcf', box: '#f0e8d1', boxStroke: '#8f8a6a'
18
+ }, `
19
+ html,body{background:#e6dcc0}
20
+ .sheet{border-left:7px solid var(--ld-accent)}
21
+ .doc-title{font-style:italic;font-weight:600}
22
+ h2.sec-head{border-color:var(--ld-accent)}
23
+ .abstract{border-color:var(--ld-accent)}`),
24
+
25
+ editorial: variant(paperLight, {
26
+ bg: '#fbf8f2', surface: '#f4efe5', ink: '#131313', muted: '#555048', hair: '#dcd5c8', rule: '#131313',
27
+ accent: '#b3122b', accentSoft: '#f7e0e1', box: '#f4efe5', boxStroke: '#8c857a'
28
+ }, `
29
+ html,body{background:#ece7dd}
30
+ .masthead{text-align:left;max-width:none;border-bottom:5px solid var(--ld-ink);padding-bottom:20px;margin-bottom:26px}
31
+ .doc-title{font-size:clamp(38px,7vw,76px);line-height:.98;letter-spacing:-.034em}
32
+ .doc-subtitle{margin-left:0;font-style:italic}
33
+ h2.sec-head{border-top-width:3px}`),
34
+
35
+ blueprint: variant(paperDark, {
36
+ bg: '#0b1f3a', surface: '#102a4c', ink: '#e6f0ff', muted: '#9db8dd', hair: '#254a7c', rule: '#5b8fd0',
37
+ accent: '#7fe0ff', accentSoft: '#10335a', box: '#0f2745', boxStroke: '#4a79b5'
38
+ }, `
39
+ html,body{background:#081729}
40
+ body{background-image:linear-gradient(rgba(127,224,255,.06) 1px,transparent 1px),linear-gradient(90deg,rgba(127,224,255,.06) 1px,transparent 1px);background-size:32px 32px}
41
+ .doc-title{letter-spacing:.01em}
42
+ @media print{body{background-image:none}}`)
43
+ };