@bendyline/squisq 2.0.0 → 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 (85) 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-6PJRB2CF.js → chunk-JHZWYRSW.js} +570 -2
  6. package/dist/chunk-JHZWYRSW.js.map +1 -0
  7. package/dist/{chunk-GF3BVNLU.js → chunk-JI77MGAK.js} +195 -27
  8. package/dist/chunk-JI77MGAK.js.map +1 -0
  9. package/dist/{chunk-QVANWDPH.js → chunk-OTKVFVL6.js} +1403 -41
  10. package/dist/chunk-OTKVFVL6.js.map +1 -0
  11. package/dist/{chunk-LYNJEOAC.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-QTSJOY54.js → chunk-VYFRI7R6.js} +2 -2
  16. package/dist/{chunk-T3TI7GSZ.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__/customThemesFrontmatter.test.ts +1 -0
  35. package/src/__tests__/markdown.test.ts +13 -4
  36. package/src/__tests__/markdownSanitize.test.ts +7 -5
  37. package/src/__tests__/materializeBlockLayers.test.ts +32 -0
  38. package/src/__tests__/materializePageSections.test.ts +404 -0
  39. package/src/__tests__/pageStyle.test.ts +258 -0
  40. package/src/__tests__/templateAnnotationParse.test.ts +13 -0
  41. package/src/doc/index.ts +34 -0
  42. package/src/doc/markdownToDoc.ts +3 -1
  43. package/src/doc/materializeBlockLayers.ts +170 -9
  44. package/src/doc/page/PageSection.ts +174 -0
  45. package/src/doc/page/materializePageSection.ts +616 -0
  46. package/src/doc/page/resolvePageBlock.ts +108 -0
  47. package/src/doc/page/sectionExtractors.ts +466 -0
  48. package/src/doc/pageCss.ts +628 -0
  49. package/src/doc/templateInputs.ts +1 -0
  50. package/src/doc/templates/__tests__/listBlock.test.ts +64 -0
  51. package/src/doc/templates/listBlock.ts +88 -30
  52. package/src/schemas/Doc.ts +22 -1
  53. package/src/schemas/PageStyle.ts +213 -0
  54. package/src/schemas/Theme.ts +7 -0
  55. package/src/schemas/index.ts +2 -0
  56. package/src/schemas/pageStyleDefaults.ts +204 -0
  57. package/src/schemas/themeCompile.ts +7 -0
  58. package/src/schemas/themeValidator.ts +183 -0
  59. package/src/schemas/themes/bold.json +29 -0
  60. package/src/schemas/themes/cinematic.json +32 -0
  61. package/src/schemas/themes/documentary.json +29 -0
  62. package/src/schemas/themes/gezellig.json +31 -1
  63. package/src/schemas/themes/magazine.json +37 -0
  64. package/src/schemas/themes/minimalist.json +29 -0
  65. package/src/schemas/themes/morning-light.json +32 -0
  66. package/src/schemas/themes/standard-dark.json +24 -0
  67. package/src/schemas/themes/standard.json +24 -0
  68. package/src/schemas/themes/tech-dark.json +42 -0
  69. package/src/schemas/themes/warm-earth.json +24 -0
  70. package/src/transform/registry.ts +19 -0
  71. package/src/transform/styles/dataDriven.ts +1 -0
  72. package/src/transform/styles/documentary.ts +1 -0
  73. package/src/transform/styles/magazine.ts +1 -0
  74. package/src/transform/styles/minimal.ts +1 -0
  75. package/src/transform/styles/narrative.ts +1 -0
  76. package/src/transform/types.ts +7 -0
  77. package/dist/chunk-6PJRB2CF.js.map +0 -1
  78. package/dist/chunk-FVJUUTEM.js +0 -74
  79. package/dist/chunk-FVJUUTEM.js.map +0 -1
  80. package/dist/chunk-GF3BVNLU.js.map +0 -1
  81. package/dist/chunk-LYNJEOAC.js.map +0 -1
  82. package/dist/chunk-PXLVXTLW.js.map +0 -1
  83. package/dist/chunk-QVANWDPH.js.map +0 -1
  84. /package/dist/{chunk-QTSJOY54.js.map → chunk-VYFRI7R6.js.map} +0 -0
  85. /package/dist/{chunk-T3TI7GSZ.js.map → chunk-XNLB57UD.js.map} +0 -0
@@ -115,6 +115,7 @@ squisq-custom-themes: ${encoded}
115
115
  A small editor for big ideas.
116
116
  `;
117
117
  const doc = markdownToDoc(parseMarkdown(sourceMd));
118
+ expect(doc.themeId).toBe('my-brand');
118
119
  expect(doc.customThemes).toHaveLength(1);
119
120
  expect(doc.customThemes![0]).toEqual(brand);
120
121
 
@@ -155,14 +155,14 @@ describe('parseMarkdown / stringifyMarkdown', () => {
155
155
  });
156
156
 
157
157
  it('parses mentions from @[Name](scheme:id)', () => {
158
- const doc = parse('Hey @[Leo](gezel:leo), take a look.');
158
+ const doc = parse('Hey @[Leo](person:leo), take a look.');
159
159
  const p = doc.children[0] as MarkdownParagraph;
160
160
  const text0 = p.children[0] as MarkdownText;
161
161
  expect(text0).toEqual({ type: 'text', value: 'Hey ' });
162
162
  const mention = p.children[1] as MarkdownMention;
163
163
  expect(mention).toEqual({
164
164
  type: 'mention',
165
- targetKind: 'gezel',
165
+ targetKind: 'person',
166
166
  targetId: 'leo',
167
167
  displayName: 'Leo',
168
168
  });
@@ -176,7 +176,7 @@ describe('parseMarkdown / stringifyMarkdown', () => {
176
176
  // re-parsing `\:` produces the unescaped url, so the round-trip
177
177
  // preserves the mention semantically even if the raw text changes
178
178
  // on the first pass.
179
- const input = 'Hey @[Leo](gezel:leo), ping @[Tess](gezel:tess) too.\n';
179
+ const input = 'Hey @[Leo](person:leo), ping @[Tess](person:tess) too.\n';
180
180
  const once = stringifyMarkdown(parseMarkdown(input));
181
181
  const twice = stringifyMarkdown(parseMarkdown(once));
182
182
  expect(twice).toBe(once);
@@ -184,7 +184,7 @@ describe('parseMarkdown / stringifyMarkdown', () => {
184
184
  const doc = parseMarkdown(once) as MarkdownDocument;
185
185
  const p = doc.children[0] as MarkdownParagraph;
186
186
  const m1 = p.children.find((c) => c.type === 'mention') as MarkdownMention;
187
- expect(m1.targetKind).toBe('gezel');
187
+ expect(m1.targetKind).toBe('person');
188
188
  expect(m1.targetId).toBe('leo');
189
189
  expect(m1.displayName).toBe('Leo');
190
190
  });
@@ -944,6 +944,15 @@ describe('multi-line frontmatter round-trips', () => {
944
944
  path: 'C:\\temp\\"quoted"',
945
945
  });
946
946
  });
947
+
948
+ it('writes boolean values as unquoted YAML booleans', () => {
949
+ const out = setFrontmatterValues('# Body\n', { enabled: true, disabled: false });
950
+ expect(out).toContain('enabled: true');
951
+ expect(out).toContain('disabled: false');
952
+ expect(out).not.toContain('enabled: "true"');
953
+ expect(out).not.toContain('disabled: "false"');
954
+ expect(parseMarkdown(out).frontmatter).toEqual({ enabled: true, disabled: false });
955
+ });
947
956
  });
948
957
 
949
958
  describe('frontmatter in parseMarkdown', () => {
@@ -18,17 +18,19 @@ describe('markdown HTML sanitization', () => {
18
18
  });
19
19
 
20
20
  it('extraLinkSchemes allows host-app schemes but never executable ones', () => {
21
- expect(sanitizeUrl('gezel-nav:src%2Fa.ts', 'link')).toBeNull();
22
- expect(sanitizeUrl('gezel-nav:src%2Fa.ts', 'link', { extraLinkSchemes: ['gezel-nav'] })).toBe(
23
- 'gezel-nav:src%2Fa.ts',
24
- );
21
+ expect(sanitizeUrl('workspace-nav:src%2Fa.ts', 'link')).toBeNull();
22
+ expect(
23
+ sanitizeUrl('workspace-nav:src%2Fa.ts', 'link', { extraLinkSchemes: ['workspace-nav'] }),
24
+ ).toBe('workspace-nav:src%2Fa.ts');
25
25
  expect(
26
26
  sanitizeUrl('javascript:alert(1)', 'link', { extraLinkSchemes: ['javascript'] }),
27
27
  ).toBeNull();
28
28
  expect(sanitizeUrl('data:text/html,x', 'link', { extraLinkSchemes: ['data'] })).toBeNull();
29
29
  // media URLs ignore the option entirely
30
30
  expect(
31
- sanitizeUrl('gezel-nav:src%2Fa.ts', 'media', { extraLinkSchemes: ['gezel-nav'] }),
31
+ sanitizeUrl('workspace-nav:src%2Fa.ts', 'media', {
32
+ extraLinkSchemes: ['workspace-nav'],
33
+ }),
32
34
  ).toBeNull();
33
35
  });
34
36
 
@@ -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
+ });