@bendyline/squisq 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/{Doc-ylqWKDc_.d.ts → Doc-DynJleo-.d.ts} +159 -2
  2. package/dist/{ImageEditDoc-D828IXJ1.d.ts → ImageEditDoc-Ca4Tr9uN.d.ts} +1 -1
  3. package/dist/{chunk-PXLVXTLW.js → chunk-ILWDXLUK.js} +24 -6
  4. package/dist/chunk-ILWDXLUK.js.map +1 -0
  5. package/dist/{chunk-W4O2TX5I.js → chunk-JHZWYRSW.js} +569 -1
  6. package/dist/chunk-JHZWYRSW.js.map +1 -0
  7. package/dist/{chunk-X6DUONGT.js → chunk-JI77MGAK.js} +123 -10
  8. package/dist/chunk-JI77MGAK.js.map +1 -0
  9. package/dist/{chunk-TFJVXUHH.js → chunk-OTKVFVL6.js} +1403 -41
  10. package/dist/chunk-OTKVFVL6.js.map +1 -0
  11. package/dist/{chunk-MMPBOUK7.js → chunk-QUUPYOSP.js} +8 -2
  12. package/dist/chunk-QUUPYOSP.js.map +1 -0
  13. package/dist/chunk-VWCJ2WXC.js +215 -0
  14. package/dist/chunk-VWCJ2WXC.js.map +1 -0
  15. package/dist/{chunk-2PWOO4ZE.js → chunk-VYFRI7R6.js} +2 -2
  16. package/dist/{chunk-CY2R2VPR.js → chunk-XNLB57UD.js} +2 -2
  17. package/dist/doc/index.d.ts +144 -5
  18. package/dist/doc/index.js +24 -4
  19. package/dist/generate/index.d.ts +1 -1
  20. package/dist/imageEdit/index.d.ts +3 -3
  21. package/dist/index.d.ts +6 -5
  22. package/dist/index.js +66 -8
  23. package/dist/jsonForm/index.d.ts +1 -1
  24. package/dist/jsonForm/index.js +4 -4
  25. package/dist/materializePageSection-DDzubQXo.d.ts +223 -0
  26. package/dist/narration/index.d.ts +1 -1
  27. package/dist/narration/index.js +3 -3
  28. package/dist/schemas/index.d.ts +30 -5
  29. package/dist/schemas/index.js +41 -3
  30. package/dist/{themeLibrary-RjVgLpUi.d.ts → themeLibrary-B72m0W1y.d.ts} +1 -1
  31. package/dist/transform/index.d.ts +8 -9
  32. package/dist/transform/index.js +1 -1
  33. package/package.json +1 -1
  34. package/src/__tests__/materializeBlockLayers.test.ts +32 -0
  35. package/src/__tests__/materializePageSections.test.ts +404 -0
  36. package/src/__tests__/pageStyle.test.ts +258 -0
  37. package/src/__tests__/templateAnnotationParse.test.ts +13 -0
  38. package/src/doc/index.ts +34 -0
  39. package/src/doc/materializeBlockLayers.ts +170 -9
  40. package/src/doc/page/PageSection.ts +174 -0
  41. package/src/doc/page/materializePageSection.ts +616 -0
  42. package/src/doc/page/resolvePageBlock.ts +108 -0
  43. package/src/doc/page/sectionExtractors.ts +466 -0
  44. package/src/doc/pageCss.ts +628 -0
  45. package/src/doc/templateInputs.ts +1 -0
  46. package/src/schemas/Doc.ts +22 -1
  47. package/src/schemas/PageStyle.ts +213 -0
  48. package/src/schemas/Theme.ts +7 -0
  49. package/src/schemas/index.ts +2 -0
  50. package/src/schemas/pageStyleDefaults.ts +204 -0
  51. package/src/schemas/themeCompile.ts +7 -0
  52. package/src/schemas/themeValidator.ts +183 -0
  53. package/src/schemas/themes/bold.json +29 -0
  54. package/src/schemas/themes/cinematic.json +32 -0
  55. package/src/schemas/themes/documentary.json +29 -0
  56. package/src/schemas/themes/gezellig.json +30 -0
  57. package/src/schemas/themes/magazine.json +37 -0
  58. package/src/schemas/themes/minimalist.json +29 -0
  59. package/src/schemas/themes/morning-light.json +32 -0
  60. package/src/schemas/themes/standard-dark.json +24 -0
  61. package/src/schemas/themes/standard.json +24 -0
  62. package/src/schemas/themes/tech-dark.json +42 -0
  63. package/src/schemas/themes/warm-earth.json +24 -0
  64. package/src/transform/registry.ts +19 -0
  65. package/src/transform/styles/dataDriven.ts +1 -0
  66. package/src/transform/styles/documentary.ts +1 -0
  67. package/src/transform/styles/magazine.ts +1 -0
  68. package/src/transform/styles/minimal.ts +1 -0
  69. package/src/transform/styles/narrative.ts +1 -0
  70. package/src/transform/types.ts +7 -0
  71. package/dist/chunk-FVJUUTEM.js +0 -74
  72. package/dist/chunk-FVJUUTEM.js.map +0 -1
  73. package/dist/chunk-MMPBOUK7.js.map +0 -1
  74. package/dist/chunk-PXLVXTLW.js.map +0 -1
  75. package/dist/chunk-TFJVXUHH.js.map +0 -1
  76. package/dist/chunk-W4O2TX5I.js.map +0 -1
  77. package/dist/chunk-X6DUONGT.js.map +0 -1
  78. /package/dist/{chunk-2PWOO4ZE.js.map → chunk-VYFRI7R6.js.map} +0 -0
  79. /package/dist/{chunk-CY2R2VPR.js.map → chunk-XNLB57UD.js.map} +0 -0
@@ -39,6 +39,38 @@ describe('materializeBlockLayers', () => {
39
39
  expect(materialized.layers[0]).not.toBe(authoredLayer);
40
40
  });
41
41
 
42
+ it('materializes Mermaid fences for empty and templated blocks', () => {
43
+ const contents = [
44
+ {
45
+ type: 'code' as const,
46
+ lang: 'mermaid',
47
+ value: 'flowchart LR\n start --> next',
48
+ },
49
+ ];
50
+ const empty: Block = {
51
+ id: 'rich-empty',
52
+ startTime: 0,
53
+ duration: 5,
54
+ audioSegment: 0,
55
+ contents,
56
+ };
57
+ const richOnly = materializeBlockLayers(empty, { persistentLayers: false });
58
+ expect(richOnly.source).toBe('rich-content');
59
+ expect(richOnly.layers).toHaveLength(1);
60
+ expect(richOnly.layers[0]).toMatchObject({
61
+ type: 'mermaid',
62
+ content: { source: 'flowchart LR\n start --> next' },
63
+ });
64
+
65
+ const templated = materializeBlockLayers(
66
+ { ...titleBlock, contents } as unknown as TemplateBlock,
67
+ { persistentLayers: false },
68
+ );
69
+ expect(templated.source).toBe('template');
70
+ expect(templated.layers.some((layer) => layer.type === 'text')).toBe(true);
71
+ expect(templated.layers.some((layer) => layer.type === 'mermaid')).toBe(true);
72
+ });
73
+
42
74
  it('resolves document-scoped custom templates in the on-demand API', () => {
43
75
  const customTemplate: CustomTemplateDefinition = {
44
76
  name: 'hero',
@@ -0,0 +1,404 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import type { Block, Doc } from '../schemas/Doc.js';
3
+ import type { Theme } from '../schemas/Theme.js';
4
+ import { PAGE_SECTION_KINDS } from '../schemas/PageStyle.js';
5
+ import { THEMES, DEFAULT_THEME } from '../schemas/themeLibrary.js';
6
+ import { createTheme } from '../schemas/Theme.js';
7
+ import { templateRegistry } from '../doc/templates/registry.js';
8
+ import { TEMPLATE_ALIASES } from '../doc/templates/templateNames.js';
9
+ import { sectionExtractors } from '../doc/page/sectionExtractors.js';
10
+ import {
11
+ materializePageSection,
12
+ materializePageSections,
13
+ resolvePageStyle,
14
+ } from '../doc/page/materializePageSection.js';
15
+ import { parseMarkdown } from '../markdown/parse.js';
16
+ import { markdownToDoc } from '../doc/markdownToDoc.js';
17
+ import { buildPageCss, buildPageCssVars, PAGE_BASE_CSS } from '../doc/pageCss.js';
18
+ import { resolveTransformStyle } from '../transform/registry.js';
19
+
20
+ afterEach(() => {
21
+ vi.restoreAllMocks();
22
+ });
23
+
24
+ function templateBlock(template: string, extra: Record<string, unknown> = {}): Block {
25
+ return {
26
+ id: `test-${template}`,
27
+ startTime: 0,
28
+ duration: 1,
29
+ audioSegment: 0,
30
+ template,
31
+ ...extra,
32
+ } as unknown as Block;
33
+ }
34
+
35
+ function docOf(blocks: Block[], extra: Partial<Doc> = {}): Doc {
36
+ return { blocks, duration: 10, ...extra } as Doc;
37
+ }
38
+
39
+ describe('section extractor coverage', () => {
40
+ it('covers every registered template id', () => {
41
+ for (const id of Object.keys(templateRegistry)) {
42
+ expect(sectionExtractors[id], `template "${id}" has no section extractor`).toBeDefined();
43
+ }
44
+ });
45
+
46
+ it('maps every registry template (and alias) to a valid section kind', () => {
47
+ const names = [...Object.keys(templateRegistry), ...Object.keys(TEMPLATE_ALIASES)];
48
+ for (const name of names) {
49
+ const { section, source } = materializePageSection(templateBlock(name));
50
+ expect(source, name).toBe('template');
51
+ expect(PAGE_SECTION_KINDS, `${name} → ${section.kind}`).toContain(section.kind);
52
+ }
53
+ });
54
+
55
+ it('extracts typed slots for representative templates', () => {
56
+ const stat = materializePageSection(
57
+ templateBlock('statHighlight', { stat: '42%', description: 'more speed', detail: 'ctx' }),
58
+ ).section;
59
+ expect(stat.kind).toBe('stat-band');
60
+ expect(stat.slots.items?.[0]).toMatchObject({ value: '42%', title: 'more speed' });
61
+
62
+ const quote = materializePageSection(
63
+ templateBlock('quote', { quote: 'Words matter.', attribution: 'Someone' }),
64
+ ).section;
65
+ expect(quote.kind).toBe('quote-band');
66
+ expect(quote.slots.body?.text).toBe('Words matter.');
67
+ expect(quote.slots.attribution).toBe('Someone');
68
+
69
+ const feature = materializePageSection(
70
+ templateBlock('rightFeature', { imageSrc: 'a.png', title: 'T', body: 'B' }),
71
+ ).section;
72
+ expect(feature.kind).toBe('feature-split');
73
+ expect(feature.variant).toBe('media-right');
74
+ expect(feature.slots.media).toMatchObject({ type: 'image', src: 'a.png' });
75
+
76
+ const table = materializePageSection(
77
+ templateBlock('dataTable', { headers: ['A'], rows: [['1']] }),
78
+ ).section;
79
+ expect(table.kind).toBe('table-section');
80
+ expect(table.slots.table).toMatchObject({ headers: ['A'], rows: [['1']] });
81
+
82
+ const diagram = materializePageSection(templateBlock('diagram', { nodes: [] })).section;
83
+ expect(diagram.kind).toBe('canvas-embed');
84
+ expect(diagram.slots.media).toMatchObject({ type: 'canvas', spatial: 'diagram' });
85
+
86
+ const video = materializePageSection(
87
+ templateBlock('videoWithCaption', {
88
+ videoSrc: 'v.mp4',
89
+ videoAlt: 'clip',
90
+ clipStart: 0,
91
+ clipEnd: 3,
92
+ }),
93
+ ).section;
94
+ expect(video.kind).toBe('media-figure');
95
+ expect(video.slots.media).toMatchObject({ type: 'video', src: 'v.mp4' });
96
+ });
97
+
98
+ it('pullQuote with a background image becomes a media-backed quote band', () => {
99
+ const { section } = materializePageSection(
100
+ templateBlock('pullQuote', {
101
+ text: 'Big words',
102
+ backgroundImage: { src: 'bg.jpg', alt: 'bg' },
103
+ }),
104
+ );
105
+ expect(section.kind).toBe('quote-band');
106
+ expect(section.background).toBe('media');
107
+ expect(section.slots.media).toMatchObject({ type: 'image', src: 'bg.jpg' });
108
+ });
109
+ });
110
+
111
+ describe('non-template paths', () => {
112
+ it('unknown template falls back to a visible diagnostic callout without console.warn', () => {
113
+ const warnSpy = vi.spyOn(console, 'warn');
114
+ const { section, source, diagnostic } = materializePageSection(templateBlock('zzz-nope'));
115
+ expect(source).toBe('fallback');
116
+ expect(section.kind).toBe('callout');
117
+ expect(section.variant).toBe('diagnostic');
118
+ expect(section.slots.meta).toContain('Unknown template');
119
+ expect(diagnostic).toMatchObject({ code: 'unknown-template', template: 'zzz-nope' });
120
+ expect(warnSpy).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it('custom templates become custom canvas embeds at their authored aspect', () => {
124
+ const { section, source } = materializePageSection(templateBlock('my-custom'), {
125
+ customTemplates: [
126
+ {
127
+ name: 'my-custom',
128
+ label: 'My Custom',
129
+ viewport: { width: 1080, height: 1080 },
130
+ layers: [],
131
+ } as never,
132
+ ],
133
+ });
134
+ expect(source).toBe('custom-template');
135
+ expect(section.kind).toBe('canvas-embed');
136
+ expect(section.slots.media).toMatchObject({
137
+ type: 'canvas',
138
+ spatial: 'custom',
139
+ aspect: { width: 1080, height: 1080 },
140
+ });
141
+ });
142
+
143
+ it('authored layer blocks become authored canvas embeds', () => {
144
+ const block = {
145
+ id: 'raw',
146
+ startTime: 0,
147
+ duration: 1,
148
+ audioSegment: 0,
149
+ layers: [{ id: 'l1', type: 'text', content: { text: 'hi' } }],
150
+ } as unknown as Block;
151
+ const { section, source } = materializePageSection(block);
152
+ expect(source).toBe('authored');
153
+ expect(section.kind).toBe('canvas-embed');
154
+ expect(section.slots.media).toMatchObject({ type: 'canvas', spatial: 'authored' });
155
+ });
156
+
157
+ it('plain heading blocks become prose sections with markdown body', () => {
158
+ const doc = markdownToDoc(parseMarkdown('# Hello\n\nSome *rich* text.'));
159
+ const sections = materializePageSections(doc);
160
+ const prose = sections.find((entry) => entry.section.kind === 'prose');
161
+ expect(prose).toBeDefined();
162
+ expect(prose!.section.slots.title).toBe('Hello');
163
+ expect(prose!.section.slots.headingLevel).toBe(1);
164
+ expect(prose!.section.slots.body?.markdown?.length).toBeGreaterThan(0);
165
+ expect(prose!.section.slots.body?.text).toContain('Some');
166
+ });
167
+
168
+ it('auto-templated blocks map to their typed section', () => {
169
+ const doc = markdownToDoc(parseMarkdown('# Journey\n\n> The only way out is through.'));
170
+ const sections = materializePageSections(doc);
171
+ const auto = sections.find((entry) => entry.section.template === 'quote');
172
+ expect(auto).toBeDefined();
173
+ expect(auto!.section.kind).toBe('quote-band');
174
+ expect(auto!.section.slots.body?.text).toContain('only way out');
175
+ });
176
+
177
+ it('preserves Mermaid and unconsumed media alongside typed page templates', () => {
178
+ const doc = markdownToDoc(
179
+ parseMarkdown(
180
+ '# Rich {[factCard]}\n\nNarrative copy.\n\n```mermaid\nflowchart LR\n a --> b\n```',
181
+ ),
182
+ );
183
+ const [entry] = materializePageSections(doc);
184
+ expect(entry.section.kind).toBe('callout');
185
+ expect(entry.section.slots.body?.text).not.toContain('flowchart LR');
186
+ expect(entry.section.slots.richContent?.markdown).toEqual([
187
+ expect.objectContaining({ type: 'code', lang: 'mermaid' }),
188
+ ]);
189
+
190
+ const imageBlock = templateBlock('quote', {
191
+ quote: 'Keep the picture too.',
192
+ contents: parseMarkdown('![Architecture](architecture.png)').children,
193
+ });
194
+ const imageSection = materializePageSection(imageBlock).section;
195
+ expect(imageSection.slots.richContent?.markdown).toEqual([
196
+ expect.objectContaining({ type: 'paragraph' }),
197
+ ]);
198
+ });
199
+ });
200
+
201
+ describe('doc-level art direction', () => {
202
+ it('synthesizes a cover hero when requested', () => {
203
+ const doc = docOf([templateBlock('quote', { quote: 'q' })], {
204
+ startBlock: { title: 'Cover Title', subtitle: 'Sub', heroSrc: 'hero.jpg' },
205
+ });
206
+ const sections = materializePageSections(doc, { cover: doc.startBlock });
207
+ expect(sections[0].section.kind).toBe('hero');
208
+ expect(sections[0].section.source).toBe('cover');
209
+ expect(sections[0].section.slots.title).toBe('Cover Title');
210
+ expect(sections[0].section.background).toBe('media');
211
+ expect(sections[0].section.emphasis).toBe('lead');
212
+
213
+ const withoutCover = materializePageSections(doc);
214
+ expect(withoutCover[0].section.kind).not.toBe('hero');
215
+ });
216
+
217
+ it('dedupes the cover against an authored title block (authored hero wins)', () => {
218
+ const doc = docOf([templateBlock('title', { title: 'Same Title', subtitle: 'Authored sub' })], {
219
+ startBlock: { title: 'Same Title', heroSrc: 'hero.jpg', heroAlt: 'hero' },
220
+ });
221
+ const sections = materializePageSections(doc, { cover: doc.startBlock });
222
+ const heroes = sections.filter((entry) => entry.section.kind === 'hero');
223
+ expect(heroes).toHaveLength(1);
224
+ expect(heroes[0].section.source).toBe('template');
225
+ // The cover's hero image grafts onto the authored hero when it has none.
226
+ expect(heroes[0].section.slots.media).toMatchObject({ type: 'image', src: 'hero.jpg' });
227
+ expect(heroes[0].section.background).toBe('media');
228
+ });
229
+
230
+ it('dedupes the cover against a mirrored leading prose H1', () => {
231
+ const doc = markdownToDoc(parseMarkdown('# My Doc\n\nIntro paragraph.\n\nSecond paragraph.'));
232
+ expect(doc.startBlock?.title).toBe('My Doc');
233
+ const sections = materializePageSections(doc, { cover: doc.startBlock });
234
+ expect(sections[0].section.kind).toBe('hero');
235
+ expect(sections[0].section.slots.subtitle).toBe('Intro paragraph.');
236
+ // The H1's own section keeps only the non-mirrored body.
237
+ const prose = sections.find((entry) => entry.section.kind === 'prose')!;
238
+ expect(prose.section.slots.title).toBeUndefined();
239
+ expect(prose.section.slots.body?.text).toBe('Second paragraph.');
240
+ });
241
+
242
+ it('applies the alternate background rhythm across non-prose sections', () => {
243
+ const theme = THEMES.standard; // backgroundRhythm: alternate
244
+ const doc = docOf([
245
+ templateBlock('quote', { quote: 'a' }),
246
+ templateBlock('quote', { quote: 'b' }),
247
+ templateBlock('quote', { quote: 'c' }),
248
+ ]);
249
+ const sections = materializePageSections(doc, { theme });
250
+ expect(sections.map((entry) => entry.section.background)).toEqual([
251
+ 'base',
252
+ 'alternate',
253
+ 'base',
254
+ ]);
255
+ });
256
+
257
+ it('rotates accent schemes with cycle strategy and lets block colorScheme win', () => {
258
+ const theme = THEMES.standard; // accentRotation: cycle
259
+ const schemeNames = Object.keys(theme.colorSchemes);
260
+ const doc = docOf([
261
+ templateBlock('sectionHeader', { title: 'one' }),
262
+ templateBlock('sectionHeader', { title: 'two' }),
263
+ templateBlock('sectionHeader', { title: 'pinned', colorScheme: 'teal' }),
264
+ ]);
265
+ const sections = materializePageSections(doc, { theme });
266
+ expect(sections[0].section.accent).toMatchObject({
267
+ schemeName: schemeNames[0],
268
+ role: 'rotation',
269
+ });
270
+ expect(sections[1].section.accent).toMatchObject({
271
+ schemeName: schemeNames[1],
272
+ role: 'rotation',
273
+ });
274
+ expect(sections[2].section.accent).toMatchObject({ schemeName: 'teal', role: 'block' });
275
+ });
276
+
277
+ it('primary-only strategy skips rotation', () => {
278
+ const theme = THEMES.documentary;
279
+ const doc = docOf([templateBlock('sectionHeader', { title: 'one' })]);
280
+ const sections = materializePageSections(doc, { theme });
281
+ expect(sections[0].section.accent.role).toBe('primary');
282
+ expect(sections[0].section.accent.schemeName).toBeUndefined();
283
+ });
284
+
285
+ it('applies theme section overrides (documentary quote-band → editorial variant)', () => {
286
+ const sections = materializePageSections(docOf([templateBlock('quote', { quote: 'q' })]), {
287
+ theme: THEMES.documentary,
288
+ });
289
+ expect(sections[0].section.variant).toBe('editorial');
290
+ });
291
+
292
+ it('applies per-template hints (tech-dark diagram → terminal frame)', () => {
293
+ const sections = materializePageSections(docOf([templateBlock('diagram', {})]), {
294
+ theme: THEMES['tech-dark'],
295
+ });
296
+ expect(sections[0].section.hints).toMatchObject({ frame: 'terminal' });
297
+ });
298
+
299
+ it('numbers hero/banner eyebrows under numbered and mono-tag treatments', () => {
300
+ const doc = docOf([
301
+ templateBlock('sectionHeader', { title: 'one' }),
302
+ templateBlock('quote', { quote: 'interlude' }),
303
+ templateBlock('sectionHeader', { title: 'two' }),
304
+ ]);
305
+ const sections = materializePageSections(doc, { theme: THEMES.documentary });
306
+ expect(sections[0].section.slots.eyebrow).toBe('01');
307
+ expect(sections[1].section.slots.eyebrow).toBeUndefined();
308
+ expect(sections[2].section.slots.eyebrow).toBe('02');
309
+
310
+ const clean = materializePageSections(doc, { theme: THEMES.standard });
311
+ expect(clean[0].section.slots.eyebrow).toBeUndefined();
312
+ });
313
+
314
+ it('container template children are consumed; other children recurse with depth', () => {
315
+ const container = templateBlock('diagram', {
316
+ children: [
317
+ {
318
+ id: 'child-consumed',
319
+ startTime: 0,
320
+ duration: 1,
321
+ audioSegment: 0,
322
+ template: 'sectionHeader',
323
+ },
324
+ ],
325
+ });
326
+ const parent = {
327
+ ...templateBlock('sectionHeader', { title: 'parent' }),
328
+ children: [
329
+ {
330
+ id: 'child-kept',
331
+ startTime: 0,
332
+ duration: 1,
333
+ audioSegment: 0,
334
+ sourceHeading: {
335
+ type: 'heading',
336
+ depth: 2,
337
+ children: [{ type: 'text', value: 'Child' }],
338
+ },
339
+ } as unknown as Block,
340
+ ],
341
+ } as Block;
342
+ const sections = materializePageSections(docOf([container, parent]));
343
+ const ids = sections.map((entry) => entry.section.blockId);
344
+ expect(ids).not.toContain('child-consumed');
345
+ expect(ids).toContain('child-kept');
346
+ const child = sections.find((entry) => entry.section.blockId === 'child-kept')!;
347
+ expect(child.section.depth).toBe(1);
348
+ });
349
+
350
+ it('transform page hints overlay spacing and front-load emphasis', () => {
351
+ const hints = resolveTransformStyle('documentary').page;
352
+ expect(hints).toMatchObject({ spacing: 'generous', emphasisCurve: 'front-loaded' });
353
+
354
+ const style = resolvePageStyle(THEMES.standard, { spacing: 'compact' });
355
+ expect(style.tokens.sectionSpacing).toBe('compact');
356
+ expect(THEMES.standard.pageStyle!.tokens.sectionSpacing).toBe('comfortable');
357
+
358
+ const doc = docOf([
359
+ templateBlock('quote', { quote: 'a' }),
360
+ templateBlock('quote', { quote: 'b' }),
361
+ templateBlock('quote', { quote: 'c' }),
362
+ ]);
363
+ const sections = materializePageSections(doc, {
364
+ transformPage: { emphasisCurve: 'front-loaded' },
365
+ });
366
+ expect(sections[0].section.emphasis).toBe('strong');
367
+ expect(sections[1].section.emphasis).toBe('strong');
368
+ expect(sections[2].section.emphasis).toBe('standard');
369
+ });
370
+
371
+ it('indexes sections pre-order with totals', () => {
372
+ const doc = docOf([
373
+ templateBlock('quote', { quote: 'a' }),
374
+ templateBlock('quote', { quote: 'b' }),
375
+ ]);
376
+ const sections = materializePageSections(doc);
377
+ expect(sections.map((entry) => entry.section.index)).toEqual([0, 1]);
378
+ expect(sections.every((entry) => entry.section.totalSections === 2)).toBe(true);
379
+ });
380
+ });
381
+
382
+ describe('page CSS', () => {
383
+ it('builds theme vars covering colors, fonts, and token dimensions', () => {
384
+ const vars = buildPageCssVars(THEMES.magazine);
385
+ expect(vars['--squisq-page-bg']).toBe(THEMES.magazine.colors.background);
386
+ expect(vars['--squisq-page-wide-max']).toBe('1240px');
387
+ expect(vars['--squisq-page-title-font']).toContain('DM Serif');
388
+ });
389
+
390
+ it('buildPageCss embeds the vars and the structural sheet', () => {
391
+ const css = buildPageCss(DEFAULT_THEME);
392
+ expect(css).toContain('.squisq-page {');
393
+ expect(css).toContain('--squisq-page-bg:');
394
+ expect(css).toContain(PAGE_BASE_CSS.slice(0, 60).trim());
395
+ });
396
+
397
+ it('resolvePageStyle derives a style for themes without pageStyle', () => {
398
+ const theme = createTheme(DEFAULT_THEME, { id: 'legacy' });
399
+ delete (theme as unknown as Record<string, unknown>).pageStyle;
400
+ const style = resolvePageStyle(theme as Theme);
401
+ expect(style.family).toBe('clean');
402
+ expect(style.tokens.contentMaxWidth).toBeGreaterThan(0);
403
+ });
404
+ });