@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.
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;
@@ -43,14 +43,16 @@ body{margin:0;background:#020409;color:var(--ink);font-family:var(--mono);font-s
43
43
  .scene-head{padding:14px 16px 0}
44
44
  .scene-head .brand-row{padding-bottom:6px}
45
45
  .scene-top{display:flex;justify-content:space-between;padding:12px 16px 6px;font-size:9px;letter-spacing:.12em;color:#5f8090;text-transform:uppercase}
46
- .scene-stage{position:relative;aspect-ratio:16/8.6;min-height:300px;margin-top:2px;background:radial-gradient(ellipse at 55% 45%,rgba(30,90,120,.35),rgba(4,8,14,0) 66%),#03060b}
46
+ .scene-stage{position:relative;width:100%;min-width:0;aspect-ratio:16/8.6;min-height:300px;margin-top:2px;background:radial-gradient(ellipse at 55% 45%,rgba(30,90,120,.35),rgba(4,8,14,0) 66%),#03060b}
47
47
  .scene-canvas{position:absolute;inset:0;width:100%;height:100%}
48
48
  .scene-label{position:absolute;transform:translate(-50%,-50%);font-size:9.5px;letter-spacing:.04em;color:#7fa6b8;white-space:nowrap;text-shadow:0 0 6px #03060b;pointer-events:none}
49
49
  .scene-crosshair{position:absolute;transform:translate(-50%,-50%);width:190px;height:190px;border:1px solid rgba(94,200,229,.22);border-radius:50%;pointer-events:none}
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{transform:translate(12px,18px);min-width:200px;max-width:230px}
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
+ };
@@ -0,0 +1,304 @@
1
+ // Timeline cues: one clock, every frame a pure function of t.
2
+ // The live page plays the cues once (or loops); video export calls window.__ld.seek(t) per frame.
3
+
4
+ const DEFAULT_DUR = 1.2;
5
+ const STAGE_ONLY_SECONDS = 6;
6
+
7
+ export function timelineDuration(model) {
8
+ const timeline = model.timeline;
9
+ if (!timeline) return STAGE_ONLY_SECONDS;
10
+ if (timeline.duration) return timeline.duration;
11
+ const end = Math.max(0, ...timeline.cues.map(cue => cue.at + (cue.dur || DEFAULT_DUR)));
12
+ return Math.round((end + 1.5) * 1000) / 1000;
13
+ }
14
+
15
+ function payload(model) {
16
+ const cues = (model.timeline?.cues || [])
17
+ .map(cue => ({ at: cue.at, dur: cue.dur || DEFAULT_DUR, do: cue.do, target: cue.target, ...(cue.zoom ? { zoom: cue.zoom } : {}) }))
18
+ .sort((a, b) => a.at - b.at);
19
+ return { duration: timelineDuration(model), loop: !!model.timeline?.loop, cues };
20
+ }
21
+
22
+ const PROPS = [['r', 1], ['f', 0], ['dim', 0], ['hl', 0], ['ha', 0], ['hr', 0]];
23
+
24
+ const CSS = `
25
+ ${PROPS.map(([name, initial]) => `@property --ld-${name}{syntax:'<number>';inherits:false;initial-value:${initial}}`).join('\n')}
26
+ .ld-mark{--ld-hlc:color-mix(in srgb,var(--ld-accent) calc(var(--ld-ha)*42%),transparent);background-image:linear-gradient(var(--ld-hlc),var(--ld-hlc));background-repeat:no-repeat;background-size:calc(var(--ld-hl)*100%) 100%;text-decoration-line:underline;text-decoration-color:color-mix(in srgb,var(--ld-accent) calc(var(--ld-hr)*75%),transparent);text-decoration-thickness:1.5px;text-underline-offset:.24em;border-radius:2px}
27
+ .ld-t:not(svg *):not(.ld-mark){opacity:calc(var(--ld-r)*(1 - var(--ld-dim)*.4 + var(--ld-f)*.4));outline:calc(var(--ld-f)*2px) solid color-mix(in srgb,var(--ld-accent) calc(var(--ld-f)*100%),transparent);outline-offset:-2px;box-shadow:inset 0 0 calc(var(--ld-f)*30px) color-mix(in srgb,var(--ld-accent) calc(var(--ld-f)*26%),transparent),inset 0 0 0 9999px color-mix(in srgb,var(--ld-accent) calc(var(--ld-ha)*9%),transparent)}
28
+ svg .ld-t{filter:opacity(calc(var(--ld-r)*(1 - var(--ld-dim)*.4 + var(--ld-f)*.4))) drop-shadow(0 0 calc(var(--ld-f)*5px) color-mix(in srgb,var(--ld-accent) calc(var(--ld-f)*100%),transparent))}
29
+ .ld-count{font-variant-numeric:tabular-nums}
30
+ ::highlight(ld-untyped){color:transparent}
31
+ .scene-label.is-matched{color:var(--ld-accent)}
32
+ html.ld-stage,html.ld-stage body{overflow:hidden!important}
33
+ html.ld-stage ::-webkit-scrollbar{display:none}
34
+ .ld-cam{transform-origin:0 0}
35
+ @media print{.ld-t{opacity:1!important;filter:none!important;outline:none!important;box-shadow:none!important}.ld-mark{background:none!important}.ld-cam{transform:none!important}}`;
36
+
37
+ const RUNTIME = `
38
+ (function(){
39
+ var dataEl = document.getElementById('ld-timeline');
40
+ if (!dataEl) return;
41
+ var data = JSON.parse(dataEl.textContent);
42
+ var root = document.documentElement;
43
+ var stage = root.classList.contains('ld-stage');
44
+ var duration = data.duration;
45
+ var PROPS = ${JSON.stringify(PROPS.map(([name]) => name))};
46
+ function clamp(x){ return x < 0 ? 0 : x > 1 ? 1 : x; }
47
+ function ease(x){ x = clamp(x); return x < .5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; }
48
+ function lerp(a, b, p){ return a + (b - a) * p; }
49
+ function resolve(target){
50
+ if (target === 'page') return null;
51
+ if (target.indexOf('mark:') === 0) return document.querySelector('[data-mark="' + target.slice(5) + '"]');
52
+ var parts = target.split('/');
53
+ var block = document.querySelector('[data-cue="' + parts[0] + '"]');
54
+ if (!block || parts.length < 2) return block;
55
+ var list = block.querySelectorAll('[data-part]');
56
+ for (var i = 0; i < list.length; i++) if (list[i].getAttribute('data-part') === parts[1]) return list[i];
57
+ return null;
58
+ }
59
+ function textNodes(el){
60
+ var out = [], walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT), node;
61
+ while ((node = walker.nextNode())) out.push({ node: node, text: node.nodeValue });
62
+ return out;
63
+ }
64
+ function siblingsOf(el){
65
+ var block = el.closest('[data-cue]');
66
+ return block ? block.querySelectorAll('[data-part]') : [];
67
+ }
68
+
69
+ var items = [], cams = [], scenes = [], managed = [];
70
+ function manage(el){ if (!el.__ld) { el.__ld = {}; managed.push(el); el.classList.add('ld-t'); } }
71
+ data.cues.forEach(function(cue){
72
+ var el = resolve(cue.target);
73
+ if (cue.do === 'camera') { cams.push({ cue: cue, el: el }); return; }
74
+ if (!el) return;
75
+ if (cue.do === 'activate') {
76
+ var scene = el.closest('.scene');
77
+ if (!scene) return;
78
+ if (scenes.indexOf(scene) < 0) {
79
+ scenes.push(scene);
80
+ scene.__ldMoves = [];
81
+ scene.__ldParts = ['.scene-crosshair', '.scene-active-label', '.scene-badge', '.scene-card'].map(function(sel){ return scene.querySelector(sel); });
82
+ var cross = scene.__ldParts[0];
83
+ scene.__ldHome = cross ? { x: parseFloat(cross.style.left), y: parseFloat(cross.style.top) } : { x: 50, y: 50 };
84
+ scene.__ldText = scene.__ldParts[1] ? scene.__ldParts[1].textContent : '';
85
+ }
86
+ scene.__ldMoves.push({ cue: cue, el: el });
87
+ return;
88
+ }
89
+ manage(el);
90
+ if (el.hasAttribute('data-part')) Array.prototype.forEach.call(siblingsOf(el), manage);
91
+ if (cue.do === 'reveal') { el.classList.add('ld-rv'); el.__ldAt = el.__ldAt == null ? cue.at : Math.min(el.__ldAt, cue.at); }
92
+ // Panels count and type their body only, so the panel number and title stay put.
93
+ var body = el.querySelector('.panel-body') || el;
94
+ if (cue.do === 'count') { el.classList.add('ld-count'); el.__ldText = el.__ldText || textNodes(body); }
95
+ if (cue.do === 'type') el.__ldText = el.__ldText || textNodes(body);
96
+ items.push({ cue: cue, el: el });
97
+ });
98
+
99
+ var cam = null;
100
+ if (stage) {
101
+ cam = document.createElement('div');
102
+ cam.className = 'ld-cam';
103
+ while (document.body.firstChild) cam.appendChild(document.body.firstChild);
104
+ document.body.appendChild(cam);
105
+ }
106
+
107
+ function put(el, name, value){ if (!(el.__ld[name] >= value)) el.__ld[name] = value; }
108
+ function countText(src, p){
109
+ return src.replace(/\\d[\\d,]*(?:\\.\\d+)?/g, function(m){
110
+ var dec = (m.split('.')[1] || '').length;
111
+ var v = parseFloat(m.replace(/,/g, '')) * p;
112
+ return m.indexOf(',') >= 0
113
+ ? v.toLocaleString('en-US', { minimumFractionDigits: dec, maximumFractionDigits: dec })
114
+ : v.toFixed(dec);
115
+ });
116
+ }
117
+
118
+ var ONCE = { reveal: 1, count: 1, type: 1 };
119
+ // settled: a looping live page keeps reveals, counts, and typing at their end state after the first pass.
120
+ // shift(item): live-only delay for a block that was still below the fold when its cue came up.
121
+ function applyCues(t0, settled, shift){
122
+ var i, el;
123
+ for (i = 0; i < managed.length; i++) managed[i].__ld = {};
124
+ for (i = 0; i < items.length; i++) {
125
+ var cue = items[i].cue, at = cue.at, dur = cue.dur;
126
+ el = items[i].el;
127
+ var t = shift ? t0 - shift(items[i]) : t0;
128
+ var ct = settled && ONCE[cue.do] ? Infinity : t;
129
+ var p = clamp((ct - at) / dur);
130
+ if (cue.do === 'highlight') {
131
+ put(el, 'hl', ease((t - at) / Math.min(.9, dur * .6)));
132
+ put(el, 'ha', t < at ? 0 : clamp((at + dur - t) / .45));
133
+ put(el, 'hr', clamp((t - (at + dur - .45)) / .45));
134
+ } else if (cue.do === 'focus') {
135
+ var env = Math.min(clamp((t - at) / .35), clamp((at + dur - t) / .45));
136
+ put(el, 'f', env);
137
+ if (el.hasAttribute('data-part')) Array.prototype.forEach.call(siblingsOf(el), function(part){ put(part, 'dim', env); });
138
+ } else if (cue.do === 'reveal') {
139
+ put(el, 'r', ease((ct - at) / Math.min(dur, .9)));
140
+ } else if (cue.do === 'count') {
141
+ put(el, 'count', ease(p));
142
+ } else if (cue.do === 'type') {
143
+ put(el, 'type', p);
144
+ }
145
+ }
146
+ var untyped = [];
147
+ for (i = 0; i < managed.length; i++) {
148
+ el = managed[i];
149
+ var state = el.__ld;
150
+ for (var k = 0; k < PROPS.length; k++) {
151
+ var name = PROPS[k];
152
+ if (state[name] == null) el.style.removeProperty('--ld-' + name);
153
+ else el.style.setProperty('--ld-' + name, state[name].toFixed(4));
154
+ }
155
+ if (state.count != null) el.__ldText.forEach(function(n){ n.node.nodeValue = countText(n.text, state.count); });
156
+ if (state.type != null && window.Highlight && window.CSS && CSS.highlights) {
157
+ var left = Math.round(state.type * el.__ldText.reduce(function(sum, n){ return sum + n.text.length; }, 0));
158
+ el.__ldText.forEach(function(n){
159
+ if (left < n.text.length) { var r = document.createRange(); r.setStart(n.node, Math.max(0, left)); r.setEnd(n.node, n.text.length); untyped.push(r); }
160
+ left -= n.text.length;
161
+ });
162
+ }
163
+ }
164
+ if (window.Highlight && window.CSS && CSS.highlights) {
165
+ if (untyped.length) CSS.highlights.set('ld-untyped', new Highlight(...untyped)); else CSS.highlights.delete('ld-untyped');
166
+ }
167
+ scenes.forEach(function(scene){ moveScene(scene, t0, shift); });
168
+ if (cam) moveCamera(t0);
169
+ }
170
+
171
+ function moveScene(scene, t0, shift){
172
+ var pos = { x: scene.__ldHome.x, y: scene.__ldHome.y }, text = scene.__ldText, away = 0, current = null;
173
+ scene.__ldMoves.forEach(function(move){
174
+ var t = shift ? t0 - shift(move) : t0;
175
+ if (t < move.cue.at) return;
176
+ var p = ease((t - move.cue.at) / move.cue.dur);
177
+ var to = { x: parseFloat(move.el.style.left), y: parseFloat(move.el.style.top) };
178
+ pos = { x: lerp(pos.x, to.x, p), y: lerp(pos.y, to.y, p) };
179
+ if (p >= .5) text = move.el.getAttribute('data-part');
180
+ away = Math.max(away, p);
181
+ current = move.el;
182
+ });
183
+ scene.__ldMoves.forEach(function(move){ move.el.classList.toggle('is-matched', move.el === current); });
184
+ var parts = scene.__ldParts;
185
+ for (var i = 0; i < 3; i++) if (parts[i]) { parts[i].style.left = pos.x.toFixed(3) + '%'; parts[i].style.top = pos.y.toFixed(3) + '%'; }
186
+ if (parts[1]) parts[1].classList.toggle('to-left', pos.x > 55);
187
+ if (parts[2]) parts[2].classList.toggle('to-left', pos.x > 55);
188
+ if (parts[1]) parts[1].textContent = text;
189
+ if (parts[3]) parts[3].style.opacity = String(1 - away);
190
+ scene.__ldActive = pos;
191
+ }
192
+
193
+ function rectOf(el){
194
+ var r = el.getBoundingClientRect(), c = cam.getBoundingClientRect();
195
+ return { x: r.left - c.left, y: r.top - c.top, w: r.width, h: r.height };
196
+ }
197
+ function moveCamera(t){
198
+ cam.style.transform = 'none';
199
+ var W = window.innerWidth, H = window.innerHeight, pageW = cam.scrollWidth, pageH = cam.scrollHeight;
200
+ var view = { z: 1, x: 0, y: 0 };
201
+ cams.forEach(function(item){
202
+ if (t < item.cue.at) return;
203
+ var goal = { z: 1, x: 0, y: 0 };
204
+ if (item.el) {
205
+ var r = rectOf(item.el);
206
+ var z = Math.max(1, Math.min(3, item.cue.zoom || 1));
207
+ goal = { z: z, x: W / 2 - (r.x + r.w / 2) * z, y: r.h * z > H * .9 ? 24 - r.y * z : H / 2 - (r.y + r.h / 2) * z };
208
+ goal.x = Math.min(0, Math.max(W - pageW * z, goal.x));
209
+ goal.y = Math.min(0, Math.max(H - pageH * z, goal.y));
210
+ }
211
+ var p = ease((t - item.cue.at) / item.cue.dur);
212
+ view = { z: lerp(view.z, goal.z, p), x: lerp(view.x, goal.x, p), y: lerp(view.y, goal.y, p) };
213
+ });
214
+ cam.style.transform = 'translate(' + view.x.toFixed(2) + 'px,' + view.y.toFixed(2) + 'px) scale(' + view.z.toFixed(4) + ')';
215
+ }
216
+
217
+ // Ambient motion (theme canvases, CSS figure motion) runs on the same clock.
218
+ function applyAmbient(t, live){
219
+ var hooks = window.__ldHooks || [];
220
+ for (var i = 0; i < hooks.length; i++) hooks[i](t * 1000);
221
+ if (!document.getAnimations) return;
222
+ var list = document.getAnimations();
223
+ for (var j = 0; j < list.length; j++) {
224
+ // Live pages leave hover and focus transitions to the browser.
225
+ if (live && window.CSSTransition && list[j] instanceof CSSTransition) continue;
226
+ var target = list[j].effect && list[j].effect.target;
227
+ var owner = target && target.closest ? target.closest('.ld-rv') : null;
228
+ var offset = owner && owner.__ldAt != null ? owner.__ldAt : 0;
229
+ list[j].pause();
230
+ list[j].currentTime = Math.max(0, t - offset) * 1000;
231
+ }
232
+ }
233
+
234
+ var shown = 0;
235
+ function seek(t){ shown = t; applyCues(t); applyAmbient(t); }
236
+ window.__ld = { duration: duration, seek: seek };
237
+ window.addEventListener('beforeprint', function(){ seek(duration); });
238
+
239
+ if (window.__ldCapture) { seek(0); return; }
240
+ var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
241
+ if (reduce) { seek(duration); return; }
242
+ // Live playback waits for the reader: a block whose cue comes up while it is still below the fold
243
+ // defers its own cues, in order, until it scrolls into view. Everything else keeps playing.
244
+ var gates = items.slice();
245
+ scenes.forEach(function(scene){ gates = gates.concat(scene.__ldMoves); });
246
+ gates.forEach(function(g){ g.root = g.el.closest('[data-cue]') || g.el; g.root.__ldDelay = 0; });
247
+ function shift(g){ return g.root.__ldDelay; }
248
+ function belowFold(el){ return el.getBoundingClientRect().top > window.innerHeight; }
249
+ var last = null, wall = 0, cueT = 0, cycle = 0, running = true, paused = false;
250
+ function frame(now){
251
+ if (paused) { running = false; return; }
252
+ var dt = last === null ? 0 : Math.min(.1, (now - last) / 1000);
253
+ last = now;
254
+ wall += dt;
255
+ var waiting = [], maxDelay = 0;
256
+ cueT += dt;
257
+ gates.forEach(function(g){
258
+ var local = cueT - g.root.__ldDelay;
259
+ if (local >= g.cue.at && local < g.cue.at + g.cue.dur && waiting.indexOf(g.root) < 0 && belowFold(g.root)) {
260
+ waiting.push(g.root);
261
+ g.root.__ldDelay = cueT - g.cue.at; // hold exactly at the cue start
262
+ }
263
+ });
264
+ gates.forEach(function(g){ maxDelay = Math.max(maxDelay, g.root.__ldDelay); });
265
+ if (data.loop && cueT >= duration + maxDelay) {
266
+ cueT -= duration + maxDelay;
267
+ cycle++;
268
+ gates.forEach(function(g){ g.root.__ldDelay = 0; });
269
+ }
270
+ applyCues(Math.min(cueT, duration + maxDelay), cycle > 0, shift);
271
+ applyAmbient(wall, true);
272
+ shown = cueT;
273
+ if (data.loop || cueT < duration + maxDelay || (window.__ldHooks || []).length) requestAnimationFrame(frame); else running = false;
274
+ }
275
+ function start(){ if (!running) { running = true; last = null; requestAnimationFrame(frame); } }
276
+ // Live controls for a host page: pause() hands the clock to seek(t), play() resumes from the last shown time.
277
+ window.__ld.time = function(){ return shown; };
278
+ window.__ld.pause = function(){ paused = true; };
279
+ window.__ld.play = function(){
280
+ if (!paused && running) return;
281
+ cueT = Math.min(shown, duration);
282
+ gates.forEach(function(g){ g.root.__ldDelay = 0; });
283
+ paused = false;
284
+ start();
285
+ };
286
+ window.__ld.replay = function(){
287
+ cueT = 0; cycle = 0; shown = 0;
288
+ gates.forEach(function(g){ g.root.__ldDelay = 0; });
289
+ paused = false;
290
+ start();
291
+ };
292
+ seek(0);
293
+ requestAnimationFrame(frame);
294
+ })();
295
+ `;
296
+
297
+ /** Markup appended before </body> when a model has a timeline or renders as a video stage. */
298
+ export function timelineAssets(model) {
299
+ const json = JSON.stringify(payload(model)).replace(/</g, '\\u003c');
300
+ return `<style>${CSS}</style>
301
+ <script type="application/json" id="ld-timeline">${json}</script>
302
+ <script>${RUNTIME}</script>
303
+ `;
304
+ }