@lutrin/core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1599 @@
1
+ /**
2
+ * Layout engine: IR → scenes.
3
+ *
4
+ * Three passes, like a compiler:
5
+ * 1. analysis — infer each slide's layout from its content (title alone
6
+ * → section; bullets + diagram → split; table → table;
7
+ * etc.);
8
+ * 2. placement — assign each block to a slot (a region in px on the
9
+ * 1280 × 720 surface, aligned to the 8 px grid);
10
+ * 3. pagination — estimate heights and split what overflows into
11
+ * "(cont.)" continuation slides.
12
+ *
13
+ * The scene produced is purely geometric: the renderer has no decision left
14
+ * to make.
15
+ */
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import {
21
+ CHROME,
22
+ COLORS,
23
+ LAYER_SHADES,
24
+ PAGE,
25
+ SEMANTIC,
26
+ SPACE,
27
+ TYPE,
28
+ LINE_HEIGHT,
29
+ contentArea,
30
+ } from './tokens.mjs';
31
+ import { ALERT_BLOCK_TYPES, animateFlag, animatePreset, runsToText } from './parse.mjs';
32
+ import { closest } from './suggest.mjs';
33
+
34
+ const PT_TO_PX = 96 / 72;
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Height estimation (px)
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function textHeight(text, widthPx, pt, lineHeight = LINE_HEIGHT) {
41
+ const avgChar = pt * PT_TO_PX * 0.52;
42
+ const cpl = Math.max(8, Math.floor(widthPx / avgChar));
43
+ const lines = Math.max(1, Math.ceil((text.length || 1) / cpl));
44
+ return lines * pt * PT_TO_PX * lineHeight;
45
+ }
46
+
47
+ export function blockHeight(block, widthPx) {
48
+ switch (block.type) {
49
+ case 'para':
50
+ return textHeight(runsToText(block.runs), widthPx, TYPE.body) + SPACE.xs;
51
+ case 'bullets':
52
+ return block.items.reduce(
53
+ (h, it) =>
54
+ h +
55
+ textHeight(
56
+ runsToText(it.runs),
57
+ widthPx - 32 - it.level * 24,
58
+ it.level ? TYPE.bulletNested : TYPE.bullet,
59
+ ) +
60
+ 6,
61
+ SPACE.xs,
62
+ );
63
+ case 'code': {
64
+ const lines = block.source.split('\n').length;
65
+ return lines * TYPE.code * PT_TO_PX * 1.35 + SPACE.lg;
66
+ }
67
+ case 'table': {
68
+ const rowH = (cells) =>
69
+ Math.max(
70
+ ...cells.map((c) =>
71
+ textHeight(runsToText(c), (widthPx - 16 * cells.length) / cells.length, TYPE.tableBody),
72
+ ),
73
+ TYPE.tableBody * PT_TO_PX * LINE_HEIGHT,
74
+ ) + 14;
75
+ return (
76
+ rowH(block.header.length ? block.header : [[]]) +
77
+ block.rows.reduce((h, r) => h + rowH(r), 0) +
78
+ SPACE.xs
79
+ );
80
+ }
81
+ case 'alert': {
82
+ // only the blocks the renderers actually render count: reserving the
83
+ // height of an ignored block would dig a visual hole in the callout
84
+ const inner = block.blocks
85
+ .filter((b) => ALERT_BLOCK_TYPES.has(b.type))
86
+ .reduce((h, b) => h + blockHeight(b, widthPx - 2 * SPACE.sm), 0);
87
+ return inner + 2 * SPACE.sm + TYPE.small * PT_TO_PX * LINE_HEIGHT;
88
+ }
89
+ case 'metric':
90
+ return 160;
91
+ case 'quote':
92
+ return textHeight(runsToText(block.runs), widthPx - 2 * SPACE.xl, TYPE.quote) + SPACE.xl;
93
+ case 'image':
94
+ case 'mermaid':
95
+ case 'chart':
96
+ return 280; // visuals adapt to their slot; default flow value
97
+ case 'math':
98
+ // one equation "line" per \\ separator (multiline environments)
99
+ return block.source.split('\\\\').length * 56 + SPACE.sm;
100
+ case 'icon':
101
+ return 112;
102
+ case 'heading':
103
+ // imposed size (key message of the focus layout): the text may flow
104
+ // over several lines — the estimate follows; otherwise one title line
105
+ if (block.size) return textHeight(runsToText(block.runs), widthPx, block.size) + SPACE.xs;
106
+ return TYPE.sectionHeading * PT_TO_PX * LINE_HEIGHT + SPACE.xs;
107
+ default:
108
+ return 0;
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Pass 1 — analysis: layout inference
114
+ // ---------------------------------------------------------------------------
115
+
116
+ const flat = (slide) => slide.sections.flatMap((s) => s.blocks);
117
+ const count = (blocks, type) => blocks.filter((b) => b.type === type).length;
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Layout registry (review §3.3, step 2)
121
+ //
122
+ // The built-in layouts are registered here at load time; USER layouts
123
+ // (`layouts/*.json` files next to the deck) are added to it per compilation
124
+ // via loadUserLayouts(). A user layout is a named ALIAS of a built-in
125
+ // layout: `{ "name": "before-after", "base": "comparison", "sections":
126
+ // { "min": 2, "max": 2 } }` — it inherits the placement of its `base`, may
127
+ // tighten the section bounds, and validation ("did you mean",
128
+ // LAYOUT_SECTIONS) as well as capabilities() know about it for free, since
129
+ // they read the registry.
130
+ //
131
+ // LAYOUTS and LAYOUT_SECTIONS stay exported: they are LIVE VIEWS of the
132
+ // registry (same object references — consumers bound through ESM follow the
133
+ // registrations without changing).
134
+ // ---------------------------------------------------------------------------
135
+
136
+ const REGISTRY = new Map(); // name → { name, base?, sections?, description?, params?, paramSchema?, builtin?, official? }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Generator parameters (review §3.3, step 3, phase A)
140
+ //
141
+ // Every built-in layout is a GENERATOR: its registry def declares a
142
+ // `paramSchema` (name → { type, domain, default, description }) — the single
143
+ // source of truth. A layouts/*.json file (user) or design/layouts/ file
144
+ // (official) sets parameters AT THE TOP LEVEL of the JSON, exactly like
145
+ // `sections`; registerLayout validates them (types, domains, "did you mean"),
146
+ // capabilities() publishes them. Semantic values reference design TOKENS
147
+ // (panelStyle variants, SEMANTIC tints, LAYER_SHADES shades), never raw
148
+ // colors; the defaults reproduce the historical behaviour exactly (an alias
149
+ // with no parameters stays a pure alias).
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /** Admissible values of the `panels` parameters: the neutral variants of
153
+ * panelStyle() + the four semantic tints — the layout picks the variant,
154
+ * the theme defines its color. */
155
+ const PANEL_VARIANTS = ['muted', 'highlight', 'pillar', 'info', 'success', 'warning', 'danger'];
156
+ const SEMANTIC_KINDS = ['info', 'success', 'warning', 'danger'];
157
+
158
+ const panelsParam = (dflt, what) => ({
159
+ type: 'enum-list',
160
+ values: PANEL_VARIANTS,
161
+ default: dflt,
162
+ description: `panel variant per ${what}, cycling: muted, highlight, pillar or a semantic tint`,
163
+ });
164
+
165
+ /** Validates a parameter value against its spec; returns the value (copied
166
+ * if a list), throws an Error otherwise. */
167
+ function checkParam(layoutName, key, spec, value) {
168
+ const fail = (why) => {
169
+ throw new Error(`parameter "${key}" of "${layoutName}": ${why}`);
170
+ };
171
+ switch (spec.type) {
172
+ case 'boolean':
173
+ if (typeof value !== 'boolean') fail('true or false expected');
174
+ return value;
175
+ case 'number':
176
+ if (typeof value !== 'number' || !Number.isFinite(value)) fail('number expected');
177
+ if (spec.integer && !Number.isInteger(value)) fail('integer expected');
178
+ if (value < spec.min || value > spec.max)
179
+ fail(`${value} outside the domain ${spec.min}–${spec.max}`);
180
+ return value;
181
+ case 'enum': {
182
+ if (typeof value !== 'string' || !spec.values.includes(value)) {
183
+ const hint = typeof value === 'string' ? closest(value, spec.values) : null;
184
+ fail(
185
+ `"${value}" invalid${hint ? ` — did you mean "${hint}"?` : ''} (values: ${spec.values.join(', ')})`,
186
+ );
187
+ }
188
+ return value;
189
+ }
190
+ case 'enum-list': {
191
+ if (!Array.isArray(value) || !value.length)
192
+ fail(`non-empty list expected (values: ${spec.values.join(', ')})`);
193
+ if (value.length > 16) fail('list too long (16 values maximum)');
194
+ for (const v of value) {
195
+ if (typeof v !== 'string' || !spec.values.includes(v)) {
196
+ const hint = typeof v === 'string' ? closest(v, spec.values) : null;
197
+ fail(
198
+ `"${v}" invalid${hint ? ` — did you mean "${hint}"?` : ''} (values: ${spec.values.join(', ')})`,
199
+ );
200
+ }
201
+ }
202
+ return [...value];
203
+ }
204
+ case 'number-list': {
205
+ if (!Array.isArray(value) || !value.length) fail('non-empty list of numbers expected');
206
+ if (value.length > 16) fail('list too long (16 values maximum)');
207
+ if (spec.items && value.length !== spec.items) fail(`exactly ${spec.items} values expected`);
208
+ for (const v of value) {
209
+ if (typeof v !== 'number' || !Number.isFinite(v)) fail('numbers expected');
210
+ if (spec.integer && !Number.isInteger(v)) fail('integers expected');
211
+ if (v < spec.min || v > spec.max) fail(`${v} outside the domain ${spec.min}–${spec.max}`);
212
+ }
213
+ if (spec.sumMax && value.reduce((s, v) => s + v, 0) > spec.sumMax)
214
+ fail(`the sum exceeds ${spec.sumMax}`);
215
+ return [...value];
216
+ }
217
+ default:
218
+ return fail(`unknown spec type "${spec.type}" (inconsistent built-in schema)`);
219
+ }
220
+ }
221
+
222
+ /** Parameter schema of a layout's BASE (the built-in generator) — {} if the
223
+ * base has no parameters, null if the layout is unknown. */
224
+ export function layoutParamSchema(name) {
225
+ const def = REGISTRY.get(name);
226
+ if (!def) return null;
227
+ const root = def.builtin ? def : REGISTRY.get(def.base);
228
+ return root?.paramSchema ?? {};
229
+ }
230
+
231
+ /** Effective parameters of a layout: the generator's defaults, overridden by
232
+ * the def (official or user alias). {} if the layout is unknown. */
233
+ export function layoutParams(name) {
234
+ const def = REGISTRY.get(name);
235
+ if (!def) return {};
236
+ const root = def.builtin ? def : REGISTRY.get(def.base);
237
+ const out = {};
238
+ for (const [k, s] of Object.entries(root?.paramSchema ?? {})) {
239
+ out[k] = Array.isArray(s.default) ? [...s.default] : s.default;
240
+ }
241
+ return Object.assign(out, def.params);
242
+ }
243
+
244
+ /** Layouts that `<!-- layout: … -->` can impose (a live view of the
245
+ * registry, the source of truth for validation and `capabilities()`). The
246
+ * "structured" layouts (comparison, pillars, timeline, layers, swot) — and
247
+ * user layouts — are never inferred: they express an intent (to compare, to
248
+ * mark milestones, to stack…) that the content alone does not reveal — they
249
+ * are asked for explicitly. */
250
+ export const LAYOUTS = [];
251
+
252
+ /** Number of `##` sections expected by the column-based or structured
253
+ * layouts (a live view of the registry, the source of truth for validation
254
+ * AND for the placement bounds of the buildScenes switch): the surplus is
255
+ * removed without any other trace — validation is the only place to
256
+ * report it. */
257
+ export const LAYOUT_SECTIONS = {};
258
+
259
+ const LAYOUT_NAME_RE = /^[a-z][a-z0-9-]{1,31}$/;
260
+
261
+ /** Keys common to every definition — any other key name is a parameter of
262
+ * the base generator (validated against its paramSchema). */
263
+ const RESERVED_KEYS = ['name', 'base', 'sections', 'description'];
264
+
265
+ /**
266
+ * Registers a layout. Non-built-in definitions (official or user) require a
267
+ * built-in or official `base` whose placement they inherit; their `sections`
268
+ * bounds must fit inside those of the base, and their other keys are
269
+ * parameters validated against the generator's paramSchema. An alias of an
270
+ * official layout is FLATTENED at registration (base = built-in generator,
271
+ * the official's parameters merged underneath its own). Throws an Error on
272
+ * the first invalid definition — loadUserLayouts() and the official catalog
273
+ * loader turn it into a diagnostic.
274
+ */
275
+ export function registerLayout(def) {
276
+ if (!def || typeof def !== 'object' || Array.isArray(def))
277
+ throw new Error('layout definition expected (JSON object)');
278
+ const {
279
+ name,
280
+ base,
281
+ sections,
282
+ description,
283
+ builtin = false,
284
+ official = false,
285
+ origin = null,
286
+ paramSchema = null,
287
+ } = def;
288
+ if (typeof name !== 'string' || !LAYOUT_NAME_RE.test(name))
289
+ throw new Error(`name "${name}" invalid (lowercase, digits and hyphens, e.g. "before-after")`);
290
+ if (REGISTRY.has(name)) {
291
+ const prior = REGISTRY.get(name);
292
+ const what = prior.builtin
293
+ ? 'built-in layout'
294
+ : prior.official
295
+ ? 'official catalog layout'
296
+ : prior.origin
297
+ ? `layout provided by the ${prior.origin} theme`
298
+ : 'user layout already loaded';
299
+ throw new Error(
300
+ `the layout "${name}" already exists (${what})${
301
+ prior.official
302
+ ? ' — remove or rename the local definition: the official definition already applies'
303
+ : ''
304
+ }`,
305
+ );
306
+ }
307
+ let baseDef = null;
308
+ if (!builtin) {
309
+ if (typeof base !== 'string' || !REGISTRY.has(base)) {
310
+ const hint = typeof base === 'string' ? closest(base, [...REGISTRY.keys()]) : null;
311
+ throw new Error(
312
+ `unknown base "${base ?? '(missing)'}"${hint ? ` — did you mean "${hint}"?` : ''} (built-in layouts: ${[...REGISTRY.keys()].filter((n) => REGISTRY.get(n).builtin).join(', ')})`,
313
+ );
314
+ }
315
+ baseDef = REGISTRY.get(base);
316
+ if (!baseDef.builtin && !baseDef.official)
317
+ throw new Error(
318
+ `base "${base}" is a user layout — inherit from a built-in or official layout`,
319
+ );
320
+ }
321
+ // ultimate built-in generator (an official one already points at its own)
322
+ const rootDef = baseDef ? (baseDef.builtin ? baseDef : REGISTRY.get(baseDef.base)) : null;
323
+ let bounds = null;
324
+ if (sections != null) {
325
+ const { min, max } = sections;
326
+ if (!Number.isInteger(min) || !Number.isInteger(max) || min < 1 || max < min)
327
+ throw new Error(`sections of "${name}" invalid (integer min/max, 1 ≤ min ≤ max)`);
328
+ bounds = { min, max };
329
+ const ref = baseDef?.sections;
330
+ if (ref && (min < ref.min || max > ref.max))
331
+ throw new Error(
332
+ `sections of "${name}" (${min}–${max}) outside the bounds of base "${base}" (${ref.min}–${ref.max})`,
333
+ );
334
+ } else if (baseDef?.sections) {
335
+ bounds = { ...baseDef.sections }; // inherited from the base
336
+ }
337
+ let params = null;
338
+ if (!builtin) {
339
+ const schema = rootDef?.paramSchema ?? {};
340
+ const paramNames = Object.keys(schema);
341
+ for (const [key, value] of Object.entries(def)) {
342
+ if (
343
+ RESERVED_KEYS.includes(key) ||
344
+ key === 'builtin' ||
345
+ key === 'official' ||
346
+ key === 'origin'
347
+ )
348
+ continue;
349
+ const spec = schema[key];
350
+ if (!spec) {
351
+ const hint = closest(key, paramNames);
352
+ throw new Error(
353
+ `unknown parameter "${key}" for base "${rootDef?.name ?? base}"${hint ? ` — did you mean "${hint}"?` : ''}${
354
+ paramNames.length
355
+ ? ` (parameters: ${paramNames.join(', ')})`
356
+ : ' (this base has no parameters)'
357
+ }`,
358
+ );
359
+ }
360
+ (params ??= {})[key] = checkParam(name, key, spec, value);
361
+ }
362
+ // alias of an official one: its inherited settings, its own on top
363
+ if (baseDef && !baseDef.builtin && baseDef.params) params = { ...baseDef.params, ...params };
364
+ }
365
+ const entry = {
366
+ name,
367
+ ...(builtin ? { builtin: true } : { base: rootDef.name }),
368
+ ...(official ? { official: true } : {}),
369
+ ...(origin ? { origin } : {}),
370
+ ...(bounds ? { sections: bounds } : {}),
371
+ ...(params ? { params } : {}),
372
+ ...(typeof description === 'string' && description.trim()
373
+ ? { description: description.trim() }
374
+ : {}),
375
+ };
376
+ if (builtin && paramSchema) entry.paramSchema = paramSchema;
377
+ REGISTRY.set(name, entry);
378
+ LAYOUTS.push(name);
379
+ if (bounds) LAYOUT_SECTIONS[name] = bounds;
380
+ return entry;
381
+ }
382
+
383
+ // built-in layouts — placement lives in the buildScenes switch; each one's
384
+ // paramSchema exposes its settings to the JSON layouts (default = historical
385
+ // behaviour, the switch's literals turned into parameters)
386
+ [
387
+ { name: 'cover' },
388
+ { name: 'section' },
389
+ { name: 'hero' },
390
+ { name: 'quote' },
391
+ {
392
+ name: 'metrics',
393
+ paramSchema: {
394
+ max: {
395
+ type: 'number',
396
+ min: 1,
397
+ max: 6,
398
+ integer: true,
399
+ default: 4,
400
+ description: 'cap on displayed cards (the surplus is reported by METRICS_DROPPED)',
401
+ },
402
+ cardHeight: {
403
+ type: 'number',
404
+ min: 120,
405
+ max: 320,
406
+ integer: true,
407
+ default: 176,
408
+ description: 'card height (px)',
409
+ },
410
+ },
411
+ },
412
+ {
413
+ name: 'split',
414
+ paramSchema: {
415
+ ratio: {
416
+ type: 'number',
417
+ min: 0.2,
418
+ max: 0.8,
419
+ default: 0.42,
420
+ description: 'share of the width taken by the text (the visual takes the rest)',
421
+ },
422
+ side: {
423
+ type: 'enum',
424
+ values: ['right', 'left'],
425
+ default: 'right',
426
+ description: 'side of the visual (![left](…) forces it image by image)',
427
+ },
428
+ },
429
+ },
430
+ { name: 'two-columns', sections: { min: 2, max: 2 } },
431
+ { name: 'three-columns', sections: { min: 3, max: 3 } },
432
+ {
433
+ name: 'comparison',
434
+ sections: { min: 2, max: 2 },
435
+ paramSchema: {
436
+ panels: panelsParam(['muted', 'highlight'], 'column'),
437
+ pad: {
438
+ type: 'number',
439
+ min: 0,
440
+ max: 48,
441
+ integer: true,
442
+ default: 16,
443
+ description: 'inner padding of the panels (px)',
444
+ },
445
+ },
446
+ },
447
+ {
448
+ name: 'pillars',
449
+ sections: { min: 2, max: 4 },
450
+ paramSchema: {
451
+ panels: panelsParam(['pillar'], 'pillar'),
452
+ accent: { type: 'boolean', default: true, description: 'accent bar at the top of pillars' },
453
+ },
454
+ },
455
+ {
456
+ name: 'timeline',
457
+ sections: { min: 2, max: 6 },
458
+ paramSchema: {
459
+ dot: {
460
+ type: 'number',
461
+ min: 20,
462
+ max: 48,
463
+ integer: true,
464
+ default: 28,
465
+ description: 'diameter of the dots (px)',
466
+ },
467
+ arrow: { type: 'boolean', default: true, description: 'arrowhead at the end of the axis' },
468
+ numbered: {
469
+ type: 'boolean',
470
+ default: true,
471
+ description: 'numbered dots (otherwise solid)',
472
+ },
473
+ orientation: {
474
+ type: 'enum',
475
+ values: ['horizontal', 'vertical'],
476
+ default: 'horizontal',
477
+ description: 'horizontal axis, or vertical on the left (roadmap in a column)',
478
+ },
479
+ },
480
+ },
481
+ {
482
+ name: 'layers',
483
+ sections: { min: 2, max: 5 },
484
+ paramSchema: {
485
+ ratios: {
486
+ type: 'number-list',
487
+ min: 0.1,
488
+ max: 0.9,
489
+ items: 2,
490
+ sumMax: 1,
491
+ default: [0.3, 0.68],
492
+ description: 'width shares of a band, title / body',
493
+ },
494
+ shades: {
495
+ type: 'number-list',
496
+ min: 0,
497
+ max: 4,
498
+ integer: true,
499
+ default: null,
500
+ description: 'LAYER_SHADES indices per layer, cycling (default: dark to light)',
501
+ },
502
+ shape: {
503
+ type: 'enum',
504
+ values: ['stack', 'funnel', 'pyramid'],
505
+ default: 'stack',
506
+ description: 'full-width bands (stack), a funnel (narrowing) or a pyramid (widening)',
507
+ },
508
+ },
509
+ },
510
+ {
511
+ name: 'swot',
512
+ sections: { min: 4, max: 4 },
513
+ paramSchema: {
514
+ kinds: {
515
+ type: 'enum-list',
516
+ values: SEMANTIC_KINDS,
517
+ default: ['success', 'danger', 'info', 'warning'],
518
+ description: 'semantic tint per quadrant (cycling)',
519
+ },
520
+ },
521
+ },
522
+ {
523
+ name: 'grid',
524
+ sections: { min: 2, max: 8 },
525
+ paramSchema: {
526
+ cols: {
527
+ type: 'number',
528
+ min: 1,
529
+ max: 4,
530
+ integer: true,
531
+ default: 2,
532
+ description: 'number of columns in the mosaic',
533
+ },
534
+ panels: panelsParam(['muted'], 'cell'),
535
+ kinds: {
536
+ type: 'enum-list',
537
+ values: SEMANTIC_KINDS,
538
+ default: null,
539
+ description: 'semantic tints per cell, cycling (takes precedence over panels)',
540
+ },
541
+ headed: {
542
+ type: 'boolean',
543
+ default: false,
544
+ description: 'detached header per cell (title + rule)',
545
+ },
546
+ },
547
+ },
548
+ {
549
+ name: 'steps',
550
+ sections: { min: 2, max: 6 },
551
+ paramSchema: {
552
+ connector: {
553
+ type: 'enum',
554
+ values: ['arrow', 'line', 'none'],
555
+ default: 'arrow',
556
+ description: 'link between steps: arrow, line or nothing',
557
+ },
558
+ panels: panelsParam(['muted'], 'step'),
559
+ },
560
+ },
561
+ {
562
+ name: 'focus',
563
+ paramSchema: {
564
+ align: {
565
+ type: 'enum',
566
+ values: ['center', 'left'],
567
+ default: 'center',
568
+ description: 'alignment of the key message',
569
+ },
570
+ accent: {
571
+ type: 'boolean',
572
+ default: true,
573
+ description: 'accent bar above the message',
574
+ },
575
+ scale: {
576
+ type: 'number',
577
+ min: 0.5,
578
+ max: 2.5,
579
+ default: 1,
580
+ description: 'size factor of the key message',
581
+ },
582
+ },
583
+ },
584
+ { name: 'table' },
585
+ { name: 'code' },
586
+ { name: 'diagram' },
587
+ { name: 'chart' },
588
+ { name: 'content' },
589
+ ].forEach((def) => registerLayout({ ...def, builtin: true }));
590
+
591
+ // ---------------------------------------------------------------------------
592
+ // OFFICIAL layouts (review §3.3, step 3, phase C): a pure-data catalog
593
+ // shipped with the product (design/layouts/*.json), built on the bases —
594
+ // registered at load time, never reset per deck. Each file follows the user
595
+ // layout schema (base + sections + parameters): the catalog documents the
596
+ // bases by example and serves as living test fixtures.
597
+ // ---------------------------------------------------------------------------
598
+
599
+ const OFFICIAL_DIR = path.join(
600
+ path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'),
601
+ 'design',
602
+ 'layouts',
603
+ );
604
+
605
+ /** Problems loading the official catalog (a broken installation) — surfaced
606
+ * as diagnostics by prepareDeckContext on every compilation. */
607
+ export const OFFICIAL_LAYOUT_DIAGS = [];
608
+
609
+ try {
610
+ for (const f of fs
611
+ .readdirSync(OFFICIAL_DIR)
612
+ .filter((x) => x.toLowerCase().endsWith('.json'))
613
+ .sort()) {
614
+ try {
615
+ // builtin/official forced: a data file never decides its own level of
616
+ // privilege in the registry
617
+ registerLayout({
618
+ ...JSON.parse(fs.readFileSync(path.join(OFFICIAL_DIR, f), 'utf8')),
619
+ builtin: false,
620
+ official: true,
621
+ });
622
+ } catch (e) {
623
+ OFFICIAL_LAYOUT_DIAGS.push({
624
+ severity: 'warning',
625
+ code: 'LAYOUT_DEF_INVALID',
626
+ message: `Official layout design/layouts/${f}: ${e?.message ?? e} — ignored.`,
627
+ });
628
+ }
629
+ }
630
+ } catch {
631
+ // no catalog (partial installation): the built-in bases are enough
632
+ }
633
+
634
+ /** Definition of a registered layout (null if unknown). */
635
+ export const layoutDef = (name) => REGISTRY.get(name) ?? null;
636
+
637
+ /** User layouts currently registered (for capabilities()). */
638
+ export const userLayouts = () => [...REGISTRY.values()].filter((d) => !d.builtin && !d.official);
639
+
640
+ /** Official layouts of the design/layouts/ catalog (for capabilities()). */
641
+ export const officialLayouts = () => [...REGISTRY.values()].filter((d) => d.official);
642
+
643
+ /** Removes every user layout — called at the head of each compilation: a
644
+ * warm worker must never serve one deck the layouts of another. The
645
+ * built-in AND the official ones stay. */
646
+ export function resetUserLayouts() {
647
+ for (const [name, def] of REGISTRY) {
648
+ if (def.builtin || def.official) continue;
649
+ REGISTRY.delete(name);
650
+ delete LAYOUT_SECTIONS[name];
651
+ }
652
+ LAYOUTS.splice(0, LAYOUTS.length, ...REGISTRY.keys());
653
+ }
654
+
655
+ /**
656
+ * Tolerant loader for a directory of JSON layouts (one file = one
657
+ * definition). Never throws: every invalid file becomes a diagnostic and is
658
+ * ignored. `describe(f)` labels the file in the messages; `origin` (the name
659
+ * of the theme package) is set on the registry entry so collision messages
660
+ * can be attributed.
661
+ */
662
+ function loadLayoutDir(dir, describe, origin = null) {
663
+ const diags = [];
664
+ let files;
665
+ try {
666
+ files = fs
667
+ .readdirSync(dir)
668
+ .filter((f) => f.toLowerCase().endsWith('.json'))
669
+ .sort();
670
+ } catch {
671
+ return diags; // no directory: nothing to load
672
+ }
673
+ for (const f of files) {
674
+ const file = path.join(dir, f);
675
+ let def;
676
+ try {
677
+ def = JSON.parse(fs.readFileSync(file, 'utf8'));
678
+ } catch (e) {
679
+ diags.push({
680
+ severity: 'warning',
681
+ code: 'LAYOUT_DEF_INVALID',
682
+ message: `Layout ${describe(f)} could not be read (invalid JSON: ${e?.message ?? e}) — ignored.`,
683
+ });
684
+ continue;
685
+ }
686
+ if (def && typeof def === 'object' && !Array.isArray(def)) {
687
+ // accepted keys: the four common ones + the parameters of the base's
688
+ // generator — if the base does not resolve, nothing is filtered (it is
689
+ // the base itself that registerLayout will report)
690
+ const schema = typeof def.base === 'string' ? layoutParamSchema(def.base) : null;
691
+ if (schema) {
692
+ const known = new Set([...RESERVED_KEYS, ...Object.keys(schema)]);
693
+ for (const key of Object.keys(def)) {
694
+ if (known.has(key)) continue;
695
+ diags.push({
696
+ severity: 'warning',
697
+ code: 'LAYOUT_DEF_ADJUSTED',
698
+ message: `Layout ${describe(f)}: unknown key "${key}" — ignored.`,
699
+ ...(closest(key, [...known]) ? { suggestion: closest(key, [...known]) } : {}),
700
+ });
701
+ delete def[key];
702
+ }
703
+ }
704
+ }
705
+ try {
706
+ // builtin/official/origin forced: a data file cannot register a layout
707
+ // that would survive the reset (a ghost leaking between the decks of a
708
+ // warm host), pass itself off as an official one, or claim a
709
+ // provenance of its own
710
+ registerLayout({ ...def, builtin: false, official: false, origin });
711
+ } catch (e) {
712
+ diags.push({
713
+ severity: 'warning',
714
+ code: 'LAYOUT_DEF_INVALID',
715
+ message: `Layout ${describe(f)}: ${e?.message ?? e} — ignored.`,
716
+ });
717
+ }
718
+ }
719
+ return diags;
720
+ }
721
+
722
+ /**
723
+ * Loads the user layouts from the `layouts/` directory next to the deck.
724
+ *
725
+ * @returns {Array<{severity, code, message, suggestion?}>}
726
+ */
727
+ export function loadUserLayouts(baseDir) {
728
+ return loadLayoutDir(path.join(baseDir, 'layouts'), (f) => `layouts/${f}`);
729
+ }
730
+
731
+ /**
732
+ * Loads the layouts provided by a KIT (the layouts/ directory resolved by
733
+ * resolveTheme from the kit.json). Registered at user level — so reloaded on
734
+ * every compilation by prepareDeckContext, never leaking between the decks of
735
+ * a warm host — but attributed to the kit (`origin`) so collisions are
736
+ * explicit. Loaded BEFORE the deck's own layouts: on a duplicate, it is the
737
+ * deck's definition that is reported and ignored.
738
+ *
739
+ * @returns {Array<{severity, code, message, suggestion?}>}
740
+ */
741
+ export function loadThemeLayouts(dir, kitName) {
742
+ return loadLayoutDir(dir, (f) => `${kitName}/layouts/${f}`, kitName);
743
+ }
744
+
745
+ export function inferLayout(slide, index) {
746
+ if (slide.layout) return slide.layout;
747
+ const blocks = flat(slide);
748
+ const visuals = blocks.filter(
749
+ (b) =>
750
+ b.type === 'mermaid' || b.type === 'chart' || (b.type === 'image' && b.role !== 'background'),
751
+ );
752
+ const textual = blocks.filter((b) => ['bullets', 'para'].includes(b.type));
753
+
754
+ if (blocks.some((b) => b.type === 'image' && (b.role === 'cover' || b.role === 'background')))
755
+ return 'hero';
756
+ // cover/section with no content — but NOT when the slide sketches an outline
757
+ // in columns: two (or three) `##` headings without a body are content that
758
+ // the early return silently threw away (elements=0). We now let it fall
759
+ // through to two-columns/three-columns, which do render those titles. A
760
+ // single heading under a cover stays a cover (the section subtitle is
761
+ // decorative there).
762
+ if (!blocks.length && slide.sections.filter((s) => s.heading).length < 2)
763
+ return index === 0 ? 'cover' : 'section';
764
+ if (
765
+ index === 0 &&
766
+ !slide.sections.some((s) => s.heading) &&
767
+ blocks.every((b) => b.type === 'para') &&
768
+ blocks.length <= 2
769
+ )
770
+ return 'cover';
771
+ if (count(blocks, 'metric') >= 2 && count(blocks, 'metric') >= blocks.length - 1)
772
+ return 'metrics';
773
+ if (blocks.length === 1 && blocks[0].type === 'quote') return 'quote';
774
+ const sections = slide.sections.filter((s) => s.heading || s.blocks.length);
775
+ if (visuals.length && textual.length) return 'split';
776
+ if (count(blocks, 'table') && blocks.length <= 2) return 'table';
777
+ if (sections.filter((s) => s.heading).length === 2) return 'two-columns';
778
+ if (sections.filter((s) => s.heading).length === 3) return 'three-columns';
779
+ if (blocks.length === 1 && blocks[0].type === 'code') return 'code';
780
+ if (blocks.length === 1 && blocks[0].type === 'mermaid') return 'diagram';
781
+ if (blocks.length === 1 && blocks[0].type === 'chart') return 'chart';
782
+ return 'content';
783
+ }
784
+
785
+ // ---------------------------------------------------------------------------
786
+ // Passes 2 and 3 — placement + pagination
787
+ // ---------------------------------------------------------------------------
788
+
789
+ /** Flows blocks into a region; returns pages of placed elements. */
790
+ function flowBlocks(blocks, region, { paginate = true } = {}) {
791
+ const pages = [];
792
+ let page = [];
793
+ let y = region.y;
794
+
795
+ const place = (block, h) => {
796
+ page.push({ block, region: { x: region.x, y, w: region.w, h } });
797
+ y += h + SPACE.sm;
798
+ };
799
+ const breakPage = () => {
800
+ if (page.length) pages.push(page);
801
+ page = [];
802
+ y = region.y;
803
+ };
804
+ const fits = (h) => y + h <= region.y + region.h;
805
+
806
+ for (const block of blocks) {
807
+ const rawH = blockHeight(block, region.w);
808
+
809
+ if (!fits(rawH) && paginate) {
810
+ // fine-grained splitting for divisible blocks
811
+ // working copy: shift() must never empty the block held by the IR
812
+ // (the deck has to stay reusable — inspect, a second render)
813
+ if (block.type === 'bullets') {
814
+ const rest = [...block.items];
815
+ // A numbered list that gets split must carry on its numbering on the
816
+ // next slide: without a starting rank, the audience reads "1." twice.
817
+ // Only top-level items count — sub-lists have their own numbering,
818
+ // which does restart at 1 under each parent. The starting rank may
819
+ // already be set: `parse` also splits lists, at the point where a
820
+ // table or a code block comes in between. Starting from 1 here would
821
+ // shift the whole pagination of a chunk cut that way.
822
+ let rank = block.startAt ?? 1;
823
+ while (rest.length) {
824
+ const taken = [];
825
+ while (rest.length) {
826
+ const trial = { ...block, items: [...taken, rest[0]] };
827
+ if (!fits(blockHeight(trial, region.w)) && taken.length) break;
828
+ taken.push(rest.shift());
829
+ }
830
+ const chunk = { ...block, items: taken };
831
+ if (block.ordered && rank > 1) chunk.startAt = rank;
832
+ rank += taken.filter((it) => !it.level).length;
833
+ place(chunk, blockHeight(chunk, region.w));
834
+ if (rest.length) breakPage();
835
+ }
836
+ continue;
837
+ }
838
+ if (block.type === 'table') {
839
+ const rest = [...block.rows];
840
+ while (rest.length) {
841
+ const taken = [];
842
+ while (rest.length) {
843
+ const trial = { ...block, rows: [...taken, rest[0]] };
844
+ if (!fits(blockHeight(trial, region.w)) && taken.length) break;
845
+ taken.push(rest.shift());
846
+ }
847
+ place({ ...block, rows: taken }, blockHeight({ ...block, rows: taken }, region.w));
848
+ if (rest.length) breakPage();
849
+ }
850
+ continue;
851
+ }
852
+ breakPage();
853
+ }
854
+ place(block, Math.min(rawH, region.h));
855
+ }
856
+ breakPage();
857
+ return pages.length ? pages : [[]];
858
+ }
859
+
860
+ /**
861
+ * Assigns the animation steps (appear on click) to the elements of a scene.
862
+ * One step = one click. Rules:
863
+ * - elements sharing a `group` (a column, a `##` section) appear together,
864
+ * one step per group;
865
+ * - a bullet list appears point by point (one step per item);
866
+ * - any other block is a step of its own.
867
+ * The chrome (title, background hero image) is never animated.
868
+ */
869
+ function assignAnimSteps(scene) {
870
+ let step = 0;
871
+ let prevGroup = null;
872
+ for (const el of scene.elements) {
873
+ const group = el.group ?? el.block.group;
874
+ if (group != null) {
875
+ if (prevGroup === null || group !== prevGroup) step++;
876
+ prevGroup = group;
877
+ el.step = step - 1;
878
+ continue;
879
+ }
880
+ prevGroup = null;
881
+ if (el.block.type === 'bullets' && el.block.items.length > 1) {
882
+ el.step = step;
883
+ el.stepCount = el.block.items.length;
884
+ step += el.stepCount;
885
+ } else {
886
+ el.step = step++;
887
+ }
888
+ }
889
+ if (step) scene.animSteps = step;
890
+ }
891
+
892
+ /** A last visual alone at the end of the flow stretches to the region's
893
+ * bottom. */
894
+ function stretchTrailingVisual(elements, region) {
895
+ const last = elements[elements.length - 1];
896
+ if (last && ['image', 'mermaid', 'chart'].includes(last.block.type)) {
897
+ last.region.h = Math.max(last.region.h, region.y + region.h - last.region.y);
898
+ }
899
+ }
900
+
901
+ /**
902
+ * Compiles the deck into scenes ready to render.
903
+ * @returns {Array<{master:string, layout:string, title:string|null, titleRuns:any, notes:string[], elements:any[]}>}
904
+ */
905
+ export function buildScenes(deck) {
906
+ const scenes = [];
907
+ const meta = deck.meta ?? {};
908
+ const deckAnimate = meta.animate != null && animateFlag(meta.animate);
909
+ const deckPreset = meta.animate != null ? animatePreset(meta.animate) : null;
910
+
911
+ // Implicit cover slide from the frontmatter
912
+ if (meta.title) {
913
+ scenes.push({
914
+ master: 'cover',
915
+ layout: 'cover',
916
+ title: meta.title,
917
+ subtitle: meta.subtitle ?? '',
918
+ byline: [meta.author, meta.date].filter(Boolean).join(' · '),
919
+ notes: [],
920
+ elements: [],
921
+ sourceLine: 1,
922
+ });
923
+ }
924
+
925
+ deck.slides.forEach((slide, idx) => {
926
+ const layout = inferLayout(slide, scenes.length === 0 ? 0 : idx + 1);
927
+ // user layout (registry): the scene keeps its name, the placement is that
928
+ // of the built-in `base` layout; the section bounds come from the registry
929
+ // (a single source, shared with validation)
930
+ const kind = REGISTRY.get(layout)?.base ?? layout;
931
+ const bounds = LAYOUT_SECTIONS[layout] ?? null;
932
+ // effective generator parameters: the built-in layout's defaults,
933
+ // overridden by the def (official or user) — phase A of §3.3
934
+ const P = layoutParams(layout);
935
+ const area = contentArea();
936
+ const blocks = flat(slide);
937
+ const base = {
938
+ layout,
939
+ title: slide.title,
940
+ titleRuns: slide.titleRuns,
941
+ notes: slide.notes,
942
+ sourceLine: slide.line,
943
+ };
944
+ const animate = slide.animate ?? deckAnimate;
945
+ // effect imposed for the slide (otherwise anim.mjs picks by block type)
946
+ const preset = slide.animatePreset ?? deckPreset;
947
+ const push = (extra) => {
948
+ const scene = { master: 'content', ...base, ...extra };
949
+ if (animate) {
950
+ assignAnimSteps(scene);
951
+ if (scene.animSteps && preset) scene.animPreset = preset;
952
+ }
953
+ scenes.push(scene);
954
+ };
955
+
956
+ switch (kind) {
957
+ case 'cover': {
958
+ const paras = blocks.filter((b) => b.type === 'para');
959
+ scenes.push({
960
+ master: 'cover',
961
+ layout,
962
+ title: slide.title ?? meta.title ?? '',
963
+ subtitle: paras[0] ? runsToText(paras[0].runs) : (meta.subtitle ?? ''),
964
+ byline: paras[1]
965
+ ? runsToText(paras[1].runs)
966
+ : [meta.author, meta.date].filter(Boolean).join(' · '),
967
+ notes: slide.notes,
968
+ elements: [],
969
+ sourceLine: slide.line,
970
+ });
971
+ break;
972
+ }
973
+ case 'section': {
974
+ scenes.push({
975
+ master: 'section',
976
+ layout,
977
+ title: slide.title ?? '',
978
+ notes: slide.notes,
979
+ elements: [],
980
+ sourceLine: slide.line,
981
+ });
982
+ break;
983
+ }
984
+ case 'hero': {
985
+ const img = blocks.find((b) => b.type === 'image');
986
+ const rest = blocks.filter((b) => b !== img);
987
+ push({
988
+ master: 'hero',
989
+ image: img,
990
+ elements: flowBlocks(rest, area, { paginate: false })[0],
991
+ });
992
+ break;
993
+ }
994
+ case 'quote': {
995
+ // a slide forced into `quote` may have no block at all (title only,
996
+ // or only content the layout does not place): we emit an EMPTY scene
997
+ // rather than an element without a `block` — both renderers
998
+ // dereference `el.block.type` without a guard and used to crash on it
999
+ push({ elements: blocks.length ? [{ block: blocks[0], region: { ...area } }] : [] });
1000
+ break;
1001
+ }
1002
+ case 'metrics': {
1003
+ const metrics = blocks.filter((b) => b.type === 'metric');
1004
+ const rest = blocks.filter((b) => b.type !== 'metric');
1005
+ const max = P.max ?? 4;
1006
+ const cols = Math.min(metrics.length, max);
1007
+ const cardW = (area.w - (cols - 1) * PAGE.gutter) / cols;
1008
+ const cardH = P.cardHeight ?? 176;
1009
+ const elements = metrics.slice(0, max).map((m, k) => ({
1010
+ block: m,
1011
+ region: {
1012
+ x: area.x + k * (cardW + PAGE.gutter),
1013
+ y: area.y + SPACE.sm,
1014
+ w: cardW,
1015
+ h: cardH,
1016
+ },
1017
+ }));
1018
+ // the content under the cards stops at the bottom of the usable area:
1019
+ // the height is DERIVED from the region's real top, it is not
1020
+ // recomputed from area.h (which forgot the SPACE.sm offsetting the
1021
+ // cards and overflowed 16 px below the footer)
1022
+ const belowY = area.y + cardH + SPACE.lg + SPACE.sm;
1023
+ const below = { x: area.x, y: belowY, w: area.w, h: Math.max(0, area.y + area.h - belowY) };
1024
+ elements.push(...flowBlocks(rest, below, { paginate: false })[0]);
1025
+ push({ elements });
1026
+ break;
1027
+ }
1028
+ case 'split': {
1029
+ const isVisual = (b) =>
1030
+ b.type === 'mermaid' ||
1031
+ b.type === 'chart' ||
1032
+ (b.type === 'image' && b.role !== 'background');
1033
+ const visuals = blocks.filter(isVisual);
1034
+ const text = blocks.filter((b) => !isVisual(b));
1035
+ const flip = P.side === 'left' || visuals.some((b) => b.role === 'left'); // visual left
1036
+ const leftW = Math.round((area.w - PAGE.gutter) * (P.ratio ?? 0.42));
1037
+ const rightW = area.w - PAGE.gutter - leftW;
1038
+ const textRegion = flip
1039
+ ? { x: area.x + rightW + PAGE.gutter, y: area.y, w: leftW, h: area.h }
1040
+ : { x: area.x, y: area.y, w: leftW, h: area.h };
1041
+ const visRegion = flip
1042
+ ? { x: area.x, y: area.y, w: rightW, h: area.h }
1043
+ : { x: area.x + leftW + PAGE.gutter, y: area.y, w: rightW, h: area.h };
1044
+ const elements = flowBlocks(text, textRegion, { paginate: false })[0];
1045
+ const visH = (visRegion.h - (visuals.length - 1) * PAGE.gutter) / visuals.length;
1046
+ visuals.forEach((v, k) =>
1047
+ elements.push({
1048
+ block: v,
1049
+ region: { ...visRegion, y: visRegion.y + k * (visH + PAGE.gutter), h: visH },
1050
+ }),
1051
+ );
1052
+ push({ elements });
1053
+ break;
1054
+ }
1055
+ case 'two-columns':
1056
+ case 'three-columns': {
1057
+ const sections = slide.sections.filter((s) => s.heading || s.blocks.length);
1058
+ // LEAD: what is written BEFORE the first "##" is not a column — it is
1059
+ // an opening. It flows full width above, and the columns start again
1060
+ // underneath it. Without this it consumed a column and the LAST
1061
+ // titled section vanished without a word: the engine only drops
1062
+ // content at the bounds the registry announces (LAYOUT_SECTIONS,
1063
+ // which validation reports), never by an accident of writing. A slide
1064
+ // forced into columns WITHOUT any "##" keeps its original placement:
1065
+ // its single anonymous section stays a column.
1066
+ const lead =
1067
+ sections.length && !sections[0].heading && sections.some((s) => s.heading)
1068
+ ? sections.shift()
1069
+ : null;
1070
+ const nCols = bounds?.max ?? (kind === 'two-columns' ? 2 : 3);
1071
+ const colW = (area.w - (nCols - 1) * PAGE.gutter) / nCols;
1072
+ const elements = [];
1073
+ let top = area.y;
1074
+ if (lead) {
1075
+ const flowed = flowBlocks(lead.blocks, { ...area }, { paginate: false })[0];
1076
+ flowed.forEach((el) => {
1077
+ el.group = 0;
1078
+ }); // animation: the lead = one step
1079
+ elements.push(...flowed);
1080
+ // the lead is NOT bounded in height: if it eats the slide, it is
1081
+ // BLOCK_OVERFLOW (validate) that says so — the engine does not
1082
+ // silently trim what the author wrote
1083
+ top = flowed.reduce((m, el) => Math.max(m, el.region.y + el.region.h), area.y) + SPACE.md;
1084
+ }
1085
+ const colH = Math.max(0, area.y + area.h - top);
1086
+ sections.slice(0, nCols).forEach((sec, k) => {
1087
+ const col = { x: area.x + k * (colW + PAGE.gutter), y: top, w: colW, h: colH };
1088
+ const colBlocks = sec.heading
1089
+ ? [{ type: 'heading', depth: 2, runs: sec.heading }, ...sec.blocks]
1090
+ : sec.blocks;
1091
+ const flowed = flowBlocks(colBlocks, col, { paginate: false })[0];
1092
+ stretchTrailingVisual(flowed, col);
1093
+ // animation: one column = one step, after the lead's
1094
+ flowed.forEach((el) => {
1095
+ el.group = lead ? k + 1 : k;
1096
+ });
1097
+ elements.push(...flowed);
1098
+ });
1099
+ push({ elements });
1100
+ break;
1101
+ }
1102
+ case 'comparison':
1103
+ case 'pillars': {
1104
+ // panels side by side: a comparison (current state understated /
1105
+ // target highlighted) or pillars (architecture principles, accent on
1106
+ // top) — per-column variants configurable (`panels`, cycling)
1107
+ const secs = slide.sections.filter((s) => s.heading || s.blocks.length);
1108
+ const nCols =
1109
+ kind === 'comparison'
1110
+ ? (bounds?.max ?? 2)
1111
+ : Math.min(Math.max(secs.length, bounds?.min ?? 2), bounds?.max ?? 4);
1112
+ const colW = (area.w - (nCols - 1) * PAGE.gutter) / nCols;
1113
+ const pad = P.pad ?? SPACE.sm;
1114
+ const panels = P.panels ?? (kind === 'comparison' ? ['muted', 'highlight'] : ['pillar']);
1115
+ const elements = [];
1116
+ secs.slice(0, nCols).forEach((sec, k) => {
1117
+ const col = {
1118
+ x: area.x + k * (colW + PAGE.gutter),
1119
+ y: area.y + SPACE.xs,
1120
+ w: colW,
1121
+ h: area.h - SPACE.xs,
1122
+ };
1123
+ const spec = panels[k % panels.length];
1124
+ const panel = SEMANTIC_KINDS.includes(spec)
1125
+ ? { variant: 'semantic', kind: spec }
1126
+ : { variant: spec };
1127
+ const accented = panel.variant === 'pillar' && P.accent !== false;
1128
+ elements.push({
1129
+ block: {
1130
+ type: 'panel',
1131
+ ...panel,
1132
+ ...(panel.variant === 'pillar' && P.accent === false ? { accent: false } : {}),
1133
+ },
1134
+ region: { ...col },
1135
+ group: k,
1136
+ });
1137
+ const padTop = accented ? SPACE.md : pad; // room for the accent
1138
+ const inner = {
1139
+ x: col.x + pad,
1140
+ y: col.y + padTop,
1141
+ w: col.w - 2 * pad,
1142
+ h: col.h - padTop - pad,
1143
+ };
1144
+ const ink = panel.variant === 'semantic' ? SEMANTIC[panel.kind].text : null;
1145
+ const colBlocks = sec.heading
1146
+ ? [
1147
+ { type: 'heading', depth: 2, runs: sec.heading, ...(ink ? { color: ink } : {}) },
1148
+ ...sec.blocks,
1149
+ ]
1150
+ : sec.blocks;
1151
+ const flowed = flowBlocks(colBlocks, inner, { paginate: false })[0];
1152
+ flowed.forEach((el) => {
1153
+ el.group = k;
1154
+ }); // animation: one panel = one step
1155
+ elements.push(...flowed);
1156
+ });
1157
+ push({ elements });
1158
+ break;
1159
+ }
1160
+ case 'timeline': {
1161
+ // milestones on an axis: each `##` section is a step (a phase, a
1162
+ // date); a dot on the axis, content beside it — horizontal axis
1163
+ // (default) or vertical on the left (roadmap in a column)
1164
+ const secs = slide.sections.filter((s) => s.heading || s.blocks.length);
1165
+ // lower bound at 1 (not the registry min): a single section still
1166
+ // renders one milestone — validation reports the shortfall, no blank
1167
+ const n = Math.min(Math.max(secs.length, 1), bounds?.max ?? 6);
1168
+ const dotR = P.dot ?? 28;
1169
+ // only add the attributes for non-default values: the scenes of decks
1170
+ // without parameters stay identical (goldens intact)
1171
+ const dotBlock = (k) => ({
1172
+ type: 'timeline-dot',
1173
+ index: k + 1,
1174
+ ...(P.numbered === false ? { numbered: false } : {}),
1175
+ });
1176
+ const axisBlock = { type: 'timeline-axis', ...(P.arrow === false ? { arrow: false } : {}) };
1177
+ const headed = (sec) =>
1178
+ sec.heading
1179
+ ? [
1180
+ { type: 'heading', depth: 2, runs: sec.heading, color: COLORS.primaryDarker },
1181
+ ...sec.blocks,
1182
+ ]
1183
+ : sec.blocks;
1184
+ const elements = [];
1185
+ if (P.orientation === 'vertical') {
1186
+ const axisX = area.x + dotR / 2;
1187
+ const rowGap = SPACE.xs;
1188
+ const rowH = (area.h - SPACE.xs - (n - 1) * rowGap) / n;
1189
+ elements.push({
1190
+ block: { ...axisBlock, vertical: true },
1191
+ region: { x: axisX - 1, y: area.y + SPACE.xs, w: 2, h: area.h - SPACE.xs },
1192
+ });
1193
+ secs.slice(0, n).forEach((sec, k) => {
1194
+ const rowY = area.y + SPACE.xs + k * (rowH + rowGap);
1195
+ const grp = [
1196
+ { block: dotBlock(k), region: { x: axisX - dotR / 2, y: rowY, w: dotR, h: dotR } },
1197
+ ];
1198
+ const rightX = axisX + dotR / 2 + SPACE.md;
1199
+ grp.push(
1200
+ ...flowBlocks(
1201
+ headed(sec),
1202
+ { x: rightX, y: rowY, w: area.x + area.w - rightX, h: rowH },
1203
+ { paginate: false },
1204
+ )[0],
1205
+ );
1206
+ grp.forEach((el) => {
1207
+ el.group = k;
1208
+ }); // animation: one milestone = one step
1209
+ elements.push(...grp);
1210
+ });
1211
+ push({ elements });
1212
+ break;
1213
+ }
1214
+ const colW = (area.w - (n - 1) * PAGE.gutter) / n;
1215
+ const axisY = area.y + SPACE.md;
1216
+ elements.push({ block: axisBlock, region: { x: area.x, y: axisY - 1, w: area.w, h: 2 } });
1217
+ secs.slice(0, n).forEach((sec, k) => {
1218
+ const colX = area.x + k * (colW + PAGE.gutter);
1219
+ const grp = [
1220
+ { block: dotBlock(k), region: { x: colX, y: axisY - dotR / 2, w: dotR, h: dotR } },
1221
+ ];
1222
+ const below = {
1223
+ x: colX,
1224
+ y: axisY + dotR / 2 + SPACE.sm,
1225
+ w: colW,
1226
+ h: area.y + area.h - axisY - dotR / 2 - SPACE.sm,
1227
+ };
1228
+ grp.push(...flowBlocks(headed(sec), below, { paginate: false })[0]);
1229
+ grp.forEach((el) => {
1230
+ el.group = k;
1231
+ }); // animation: one milestone = one step
1232
+ elements.push(...grp);
1233
+ });
1234
+ push({ elements });
1235
+ break;
1236
+ }
1237
+ case 'layers': {
1238
+ // architecture layers: full-width bands stacked, from the base (dark
1239
+ // shade) to the surface — `##` sections, or the items of a bullet
1240
+ // list if the slide has no sections
1241
+ const secs = slide.sections.filter((s) => s.heading);
1242
+ const bulletsOnly = !secs.length && blocks.length === 1 && blocks[0].type === 'bullets';
1243
+ // surplus dropped at the registry bounds — that is what validation
1244
+ // (LAYOUT_SECTIONS) promises; before, the extra bands were silently
1245
+ // crushed one on top of another
1246
+ const items = (
1247
+ bulletsOnly
1248
+ ? blocks[0].items.map((it) => ({ runs: it.runs, blocks: [] }))
1249
+ : secs.map((s) => ({ runs: s.heading, blocks: s.blocks }))
1250
+ ).slice(0, bounds?.max ?? 5);
1251
+ const n = Math.max(items.length, 1);
1252
+ const gap = SPACE.xs;
1253
+ const bandH = (area.h - SPACE.xs - (n - 1) * gap) / n;
1254
+ const headH = TYPE.sectionHeading * PT_TO_PX * LINE_HEIGHT;
1255
+ const [titleRatio, bodyRatio] = P.ratios ?? [0.3, 0.68];
1256
+ // body start: title + a little slack — historical literal 0.32 when
1257
+ // nothing overrides it (0.3 + 0.02 is not 0.32 in floating point, and
1258
+ // the goldens must stay intact to the bit)
1259
+ const bodyStart = P.ratios ? titleRatio + 0.02 : 0.32;
1260
+ // funnel / pyramid: relative band width, linear between 1 and 0.45,
1261
+ // centered — stack (default): full width
1262
+ const minW = 0.45;
1263
+ const widthAt = (k) =>
1264
+ P.shape === 'funnel'
1265
+ ? 1 - (k * (1 - minW)) / Math.max(n - 1, 1)
1266
+ : P.shape === 'pyramid'
1267
+ ? minW + (k * (1 - minW)) / Math.max(n - 1, 1)
1268
+ : 1;
1269
+ const elements = [];
1270
+ items.forEach((it, k) => {
1271
+ // shades spread over the palette: 3 layers → dark, medium, light
1272
+ // (or imposed by the `shades` parameter, cycling)
1273
+ // the index is CLAMPED to the last available shade: a kit that
1274
+ // provides fewer shades than the `shades` asked for (or than there
1275
+ // are layers) keeps a monotonic gradient instead of crashing the
1276
+ // layout on an `.ink` of undefined. It is the CLAMPED index that
1277
+ // goes into the `panel` block: panelStyle() and the ink must point
1278
+ // at the same shade, or the text is computed for a background it
1279
+ // does not have.
1280
+ const lastShade = LAYER_SHADES.length - 1;
1281
+ const wanted = P.shades
1282
+ ? P.shades[k % P.shades.length]
1283
+ : n > 1
1284
+ ? Math.round((k * Math.max(lastShade, 0)) / (n - 1))
1285
+ : 0;
1286
+ const shade = Math.min(Math.max(wanted, 0), Math.max(lastShade, 0));
1287
+ // empty palette (a theme overwriting LAYER_SHADES): readable neutral ink
1288
+ const ink = LAYER_SHADES[shade]?.ink ?? COLORS.neutralPrimary;
1289
+ const bandW = area.w * widthAt(k);
1290
+ const band = {
1291
+ x: area.x + (area.w - bandW) / 2,
1292
+ y: area.y + SPACE.xs + k * (bandH + gap),
1293
+ w: bandW,
1294
+ h: bandH,
1295
+ };
1296
+ elements.push({
1297
+ block: { type: 'panel', variant: 'layer', shade },
1298
+ region: band,
1299
+ group: k,
1300
+ });
1301
+ const hasBody = it.blocks.length > 0;
1302
+ const headW = hasBody ? band.w * titleRatio - SPACE.md : band.w - 2 * SPACE.md;
1303
+ elements.push({
1304
+ block: { type: 'heading', depth: 2, runs: it.runs, color: ink },
1305
+ region: { x: band.x + SPACE.md, y: band.y + (band.h - headH) / 2, w: headW, h: headH },
1306
+ group: k,
1307
+ });
1308
+ if (hasBody) {
1309
+ const body = {
1310
+ x: band.x + band.w * bodyStart,
1311
+ y: band.y + SPACE.xs,
1312
+ w: band.w * bodyRatio - SPACE.md,
1313
+ h: band.h - 2 * SPACE.xs,
1314
+ };
1315
+ const flowed = flowBlocks(
1316
+ it.blocks.map((b) => ({ ...b, color: ink })),
1317
+ body,
1318
+ { paginate: false },
1319
+ )[0];
1320
+ // description centered vertically in the band, like the title
1321
+ const bottom = flowed.reduce((m, el) => Math.max(m, el.region.y + el.region.h), body.y);
1322
+ const shift = Math.max(0, (body.h - (bottom - body.y)) / 2);
1323
+ flowed.forEach((el) => {
1324
+ el.region.y += shift;
1325
+ el.group = k;
1326
+ });
1327
+ elements.push(...flowed);
1328
+ }
1329
+ });
1330
+ push({ elements });
1331
+ break;
1332
+ }
1333
+ case 'swot': {
1334
+ // 2 × 2 matrix: sections in the order Strengths, Weaknesses,
1335
+ // Opportunities, Threats — panels in the semantic tints
1336
+ // (configurable through `kinds`, cycling)
1337
+ const secs = slide.sections.filter((s) => s.heading || s.blocks.length);
1338
+ const kinds = P.kinds ?? ['success', 'danger', 'info', 'warning'];
1339
+ const maxCells = bounds?.max ?? 4;
1340
+ const rows = Math.max(Math.ceil(Math.min(secs.length, maxCells) / 2), 1);
1341
+ const cellW = (area.w - PAGE.gutter) / 2;
1342
+ const cellH = (area.h - SPACE.xs - (rows - 1) * PAGE.gutter) / rows;
1343
+ const elements = [];
1344
+ secs.slice(0, maxCells).forEach((sec, k) => {
1345
+ const cell = {
1346
+ x: area.x + (k % 2) * (cellW + PAGE.gutter),
1347
+ y: area.y + SPACE.xs + Math.floor(k / 2) * (cellH + PAGE.gutter),
1348
+ w: cellW,
1349
+ h: cellH,
1350
+ };
1351
+ const kindK = kinds[k % kinds.length];
1352
+ elements.push({
1353
+ block: { type: 'panel', variant: 'semantic', kind: kindK },
1354
+ region: cell,
1355
+ group: k,
1356
+ });
1357
+ const inner = {
1358
+ x: cell.x + SPACE.sm,
1359
+ y: cell.y + SPACE.sm,
1360
+ w: cell.w - 2 * SPACE.sm,
1361
+ h: cell.h - 2 * SPACE.sm,
1362
+ };
1363
+ const colBlocks = sec.heading
1364
+ ? [
1365
+ { type: 'heading', depth: 2, runs: sec.heading, color: SEMANTIC[kindK].text },
1366
+ ...sec.blocks,
1367
+ ]
1368
+ : sec.blocks;
1369
+ const flowed = flowBlocks(colBlocks, inner, { paginate: false })[0];
1370
+ flowed.forEach((el) => {
1371
+ el.group = k;
1372
+ }); // animation: one quadrant = one step
1373
+ elements.push(...flowed);
1374
+ });
1375
+ push({ elements });
1376
+ break;
1377
+ }
1378
+ case 'grid': {
1379
+ // R × C mosaic of panels (review §3.3, phase B): a portfolio of
1380
+ // projects, offerings, a team, 2 × 2 matrices — one `##` section =
1381
+ // one cell; `kinds` (semantic) takes precedence over `panels`
1382
+ const secs = slide.sections.filter((s) => s.heading || s.blocks.length);
1383
+ const maxCells = bounds?.max ?? 8;
1384
+ const n = Math.min(Math.max(secs.length, 1), maxCells);
1385
+ const cols = Math.min(P.cols ?? 2, n);
1386
+ const rows = Math.ceil(n / cols);
1387
+ const cellW = (area.w - (cols - 1) * PAGE.gutter) / cols;
1388
+ const cellH = (area.h - SPACE.xs - (rows - 1) * PAGE.gutter) / rows;
1389
+ const headH = TYPE.sectionHeading * PT_TO_PX * LINE_HEIGHT;
1390
+ const panels = P.panels ?? ['muted'];
1391
+ const elements = [];
1392
+ secs.slice(0, n).forEach((sec, k) => {
1393
+ const cell = {
1394
+ x: area.x + (k % cols) * (cellW + PAGE.gutter),
1395
+ y: area.y + SPACE.xs + Math.floor(k / cols) * (cellH + PAGE.gutter),
1396
+ w: cellW,
1397
+ h: cellH,
1398
+ };
1399
+ const spec = P.kinds ? P.kinds[k % P.kinds.length] : panels[k % panels.length];
1400
+ const panel = SEMANTIC_KINDS.includes(spec)
1401
+ ? { variant: 'semantic', kind: spec }
1402
+ : { variant: spec };
1403
+ elements.push({ block: { type: 'panel', ...panel }, region: cell, group: k });
1404
+ const inner = {
1405
+ x: cell.x + SPACE.sm,
1406
+ y: cell.y + SPACE.sm,
1407
+ w: cell.w - 2 * SPACE.sm,
1408
+ h: cell.h - 2 * SPACE.sm,
1409
+ };
1410
+ const ink = panel.variant === 'semantic' ? SEMANTIC[panel.kind].text : null;
1411
+ const heading = sec.heading
1412
+ ? { type: 'heading', depth: 2, runs: sec.heading, ...(ink ? { color: ink } : {}) }
1413
+ : null;
1414
+ let flowRegion = inner;
1415
+ let cellBlocks = sec.blocks;
1416
+ if (P.headed && heading) {
1417
+ // detached header: title at the top of the cell, rule, content below
1418
+ elements.push({ block: heading, region: { ...inner, h: headH }, group: k });
1419
+ elements.push({
1420
+ block: { type: 'timeline-axis', arrow: false },
1421
+ region: { x: inner.x, y: inner.y + headH + SPACE.xs, w: inner.w, h: 2 },
1422
+ group: k,
1423
+ });
1424
+ const contentY = inner.y + headH + SPACE.xs + SPACE.sm;
1425
+ // height clamped at 0: a dense mosaic (cols: 1 × 8 rows) must
1426
+ // never emit a negative region — the overflow stays visible
1427
+ // through BLOCK_OVERFLOW
1428
+ flowRegion = {
1429
+ x: inner.x,
1430
+ y: contentY,
1431
+ w: inner.w,
1432
+ h: Math.max(0, inner.h - (contentY - inner.y)),
1433
+ };
1434
+ } else if (heading) {
1435
+ cellBlocks = [heading, ...sec.blocks];
1436
+ }
1437
+ const flowed = flowBlocks(cellBlocks, flowRegion, { paginate: false })[0];
1438
+ flowed.forEach((el) => {
1439
+ el.group = k;
1440
+ }); // animation: one cell = one step
1441
+ elements.push(...flowed);
1442
+ });
1443
+ push({ elements });
1444
+ break;
1445
+ }
1446
+ case 'steps': {
1447
+ // sequential process (review §3.3, phase B): step panels joined by
1448
+ // connectors (arrow, line or nothing) — a citizen journey, the path
1449
+ // of a request, a "how it works"
1450
+ const secs = slide.sections.filter((s) => s.heading || s.blocks.length);
1451
+ const n = Math.min(Math.max(secs.length, 1), bounds?.max ?? 6);
1452
+ const connector = P.connector ?? 'arrow';
1453
+ const gap = connector === 'none' ? PAGE.gutter : 40;
1454
+ const stepW = (area.w - (n - 1) * gap) / n;
1455
+ const panels = P.panels ?? ['muted'];
1456
+ const elements = [];
1457
+ secs.slice(0, n).forEach((sec, k) => {
1458
+ const col = {
1459
+ x: area.x + k * (stepW + gap),
1460
+ y: area.y + SPACE.xs,
1461
+ w: stepW,
1462
+ h: area.h - SPACE.xs,
1463
+ };
1464
+ if (k && connector !== 'none') {
1465
+ // the connector appears with the step it introduces (group k)
1466
+ elements.push({
1467
+ block: { type: 'timeline-axis', ...(connector === 'line' ? { arrow: false } : {}) },
1468
+ region: { x: col.x - gap + (gap - 28) / 2, y: col.y + col.h / 2 - 1, w: 28, h: 2 },
1469
+ group: k,
1470
+ });
1471
+ }
1472
+ const spec = panels[k % panels.length];
1473
+ const panel = SEMANTIC_KINDS.includes(spec)
1474
+ ? { variant: 'semantic', kind: spec }
1475
+ : { variant: spec };
1476
+ elements.push({ block: { type: 'panel', ...panel }, region: { ...col }, group: k });
1477
+ const padTop = panel.variant === 'pillar' ? SPACE.md : SPACE.sm; // room for the accent
1478
+ const inner = {
1479
+ x: col.x + SPACE.sm,
1480
+ y: col.y + padTop,
1481
+ w: col.w - 2 * SPACE.sm,
1482
+ h: col.h - padTop - SPACE.sm,
1483
+ };
1484
+ const ink = panel.variant === 'semantic' ? SEMANTIC[panel.kind].text : null;
1485
+ const stepBlocks = sec.heading
1486
+ ? [
1487
+ { type: 'heading', depth: 2, runs: sec.heading, ...(ink ? { color: ink } : {}) },
1488
+ ...sec.blocks,
1489
+ ]
1490
+ : sec.blocks;
1491
+ const flowed = flowBlocks(stepBlocks, inner, { paginate: false })[0];
1492
+ flowed.forEach((el) => {
1493
+ el.group = k;
1494
+ }); // animation: one step at a time
1495
+ elements.push(...flowed);
1496
+ });
1497
+ push({ elements });
1498
+ break;
1499
+ }
1500
+ case 'focus': {
1501
+ // ONE message (review §3.3, phase B): a large figure or key sentence
1502
+ // filling the frame (the slide's first paragraph), context
1503
+ // underneath — the weapon against the overloaded slide. Any `##`
1504
+ // titles remain content (heading blocks), never thrown away.
1505
+ const withHeads = slide.sections.flatMap((s) =>
1506
+ s.heading ? [{ type: 'heading', depth: 2, runs: s.heading }, ...s.blocks] : s.blocks,
1507
+ );
1508
+ const msgIdx = withHeads.findIndex((b) => b.type === 'para');
1509
+ if (msgIdx < 0) {
1510
+ // no paragraph: plain flow, no pagination
1511
+ push({ elements: flowBlocks(withHeads, area, { paginate: false })[0] });
1512
+ break;
1513
+ }
1514
+ const msg = withHeads[msgIdx];
1515
+ const rest = withHeads.filter((_, i) => i !== msgIdx);
1516
+ const align = P.align ?? 'center';
1517
+ const size = Math.round(TYPE.coverTitle * (P.scale ?? 1));
1518
+ const msgBlock = {
1519
+ type: 'heading',
1520
+ depth: 1,
1521
+ runs: msg.runs,
1522
+ size,
1523
+ ...(align === 'center' ? { align } : {}),
1524
+ };
1525
+ const msgH = blockHeight(msgBlock, area.w);
1526
+ // message area: the whole content area, or its upper half if context
1527
+ // follows — the message is centered vertically in it; a message taller
1528
+ // than its area PUSHES the context down rather than overlapping it
1529
+ // (an overflow past the bottom of the page is still reported by
1530
+ // BLOCK_OVERFLOW)
1531
+ const contextY = rest.length
1532
+ ? area.y +
1533
+ Math.min(area.h, Math.max(Math.round(area.h * 0.58), SPACE.lg + msgH + SPACE.md))
1534
+ : area.y + area.h;
1535
+ const msgY = area.y + Math.max(SPACE.lg, (contextY - area.y - msgH) / 2);
1536
+ const elements = [];
1537
+ if (P.accent !== false) {
1538
+ const barW = CHROME.cover.barW;
1539
+ elements.push({
1540
+ block: { type: 'panel', variant: 'accent' },
1541
+ region: {
1542
+ x: align === 'center' ? area.x + (area.w - barW) / 2 : area.x,
1543
+ y: msgY - SPACE.md,
1544
+ w: barW,
1545
+ h: CHROME.cover.barH,
1546
+ },
1547
+ group: 0,
1548
+ });
1549
+ }
1550
+ elements.push({
1551
+ block: msgBlock,
1552
+ region: { x: area.x, y: msgY, w: area.w, h: msgH },
1553
+ group: 0,
1554
+ });
1555
+ if (rest.length) {
1556
+ const ctx = {
1557
+ x: area.x,
1558
+ y: contextY,
1559
+ w: area.w,
1560
+ h: Math.max(0, area.y + area.h - contextY),
1561
+ };
1562
+ elements.push(...flowBlocks(rest, ctx, { paginate: false })[0]);
1563
+ }
1564
+ push({ elements });
1565
+ break;
1566
+ }
1567
+ case 'table':
1568
+ case 'code':
1569
+ case 'diagram':
1570
+ case 'chart':
1571
+ case 'content':
1572
+ default: {
1573
+ // a single vertical flow, with pagination; if the slide has several
1574
+ // `##` sections, each section becomes an animation group
1575
+ const secs = slide.sections.filter((s) => s.heading || s.blocks.length);
1576
+ const grouped = animate && secs.filter((s) => s.heading).length >= 2;
1577
+ const withHeadings = secs.flatMap((s, si) => {
1578
+ const blocks = s.heading
1579
+ ? [{ type: 'heading', depth: 2, runs: s.heading }, ...s.blocks]
1580
+ : s.blocks;
1581
+ return grouped ? blocks.map((b) => ({ ...b, group: si })) : blocks;
1582
+ });
1583
+ const pages = flowBlocks(withHeadings, area);
1584
+ pages.forEach((elements, p) => {
1585
+ stretchTrailingVisual(elements, area);
1586
+ push({
1587
+ elements,
1588
+ title: p === 0 ? slide.title : slide.title ? `${slide.title} (cont.)` : null,
1589
+ notes: p === 0 ? slide.notes : [],
1590
+ continued: p > 0 || undefined,
1591
+ });
1592
+ });
1593
+ break;
1594
+ }
1595
+ }
1596
+ });
1597
+
1598
+ return scenes;
1599
+ }