@bendyline/squisq 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/{Doc-Dwn5aHFC.d.ts → Doc-Ch4tc3vv.d.ts} +35 -4
  2. package/dist/__tests__/coverBlock.test.d.ts +2 -0
  3. package/dist/__tests__/coverBlock.test.d.ts.map +1 -0
  4. package/dist/__tests__/coverBlock.test.js +197 -0
  5. package/dist/__tests__/coverBlock.test.js.map +1 -0
  6. package/dist/__tests__/markdown.test.js +61 -0
  7. package/dist/__tests__/markdown.test.js.map +1 -1
  8. package/dist/__tests__/markdownToDoc.test.js +59 -0
  9. package/dist/__tests__/markdownToDoc.test.js.map +1 -1
  10. package/dist/__tests__/surface.test.d.ts +2 -0
  11. package/dist/__tests__/surface.test.d.ts.map +1 -0
  12. package/dist/__tests__/surface.test.js +57 -0
  13. package/dist/__tests__/surface.test.js.map +1 -0
  14. package/dist/{chunk-T6WBC3ND.js → chunk-6BAVXFYC.js} +137 -53
  15. package/dist/chunk-6BAVXFYC.js.map +1 -0
  16. package/dist/{chunk-QWVRN6I4.js → chunk-7GF6RDED.js} +3 -2
  17. package/dist/chunk-7GF6RDED.js.map +1 -0
  18. package/dist/{chunk-4CRBS35L.js → chunk-CYQHG27J.js} +7 -2
  19. package/dist/chunk-CYQHG27J.js.map +1 -0
  20. package/dist/{chunk-KEFWM2VC.js → chunk-F22AE6BY.js} +31 -2
  21. package/dist/chunk-F22AE6BY.js.map +1 -0
  22. package/dist/{chunk-TVPKD76V.js → chunk-FIQR4AJG.js} +50 -4
  23. package/dist/chunk-FIQR4AJG.js.map +1 -0
  24. package/dist/{chunk-NJLL5XYC.js → chunk-OASJKWO5.js} +76 -13
  25. package/dist/chunk-OASJKWO5.js.map +1 -0
  26. package/dist/doc/audioMapping.d.ts.map +1 -1
  27. package/dist/doc/audioMapping.js +41 -5
  28. package/dist/doc/audioMapping.js.map +1 -1
  29. package/dist/doc/markdownToDoc.d.ts +7 -0
  30. package/dist/doc/markdownToDoc.d.ts.map +1 -1
  31. package/dist/doc/markdownToDoc.js +49 -1
  32. package/dist/doc/markdownToDoc.js.map +1 -1
  33. package/dist/doc/templates/coverBlock.d.ts +2 -2
  34. package/dist/doc/templates/coverBlock.d.ts.map +1 -1
  35. package/dist/doc/templates/coverBlock.js +58 -33
  36. package/dist/doc/templates/coverBlock.js.map +1 -1
  37. package/dist/markdown/convert.d.ts.map +1 -1
  38. package/dist/markdown/convert.js +101 -11
  39. package/dist/markdown/convert.js.map +1 -1
  40. package/dist/markdown/index.d.ts +1 -1
  41. package/dist/markdown/index.d.ts.map +1 -1
  42. package/dist/markdown/index.js.map +1 -1
  43. package/dist/markdown/types.d.ts +19 -1
  44. package/dist/markdown/types.d.ts.map +1 -1
  45. package/dist/markdown/utils.d.ts.map +1 -1
  46. package/dist/markdown/utils.js +5 -1
  47. package/dist/markdown/utils.js.map +1 -1
  48. package/dist/schemas/Doc.d.ts +2 -2
  49. package/dist/schemas/Doc.d.ts.map +1 -1
  50. package/dist/schemas/Theme.d.ts +31 -0
  51. package/dist/schemas/Theme.d.ts.map +1 -1
  52. package/dist/schemas/Theme.js +35 -0
  53. package/dist/schemas/Theme.js.map +1 -1
  54. package/dist/schemas/themeLibrary.d.ts.map +1 -1
  55. package/dist/schemas/themeLibrary.js +68 -2
  56. package/dist/schemas/themeLibrary.js.map +1 -1
  57. package/dist/storage/ContentContainer.d.ts.map +1 -1
  58. package/dist/storage/ContentContainer.js +10 -1
  59. package/dist/storage/ContentContainer.js.map +1 -1
  60. package/dist/story/index.d.ts +13 -6
  61. package/dist/story/index.js +3 -3
  62. package/dist/{themeLibrary-DLJtGh8-.d.ts → themeLibrary-CEo2D4b6.d.ts} +1 -1
  63. package/dist/{types-DYAzisMG.d.ts → types-CkNttVSF.d.ts} +20 -2
  64. package/package.json +1 -1
  65. package/src/__tests__/coverBlock.test.ts +234 -0
  66. package/src/__tests__/markdown.test.ts +66 -0
  67. package/src/__tests__/markdownToDoc.test.ts +80 -0
  68. package/src/__tests__/surface.test.ts +68 -0
  69. package/src/doc/audioMapping.ts +43 -5
  70. package/src/doc/markdownToDoc.ts +74 -3
  71. package/src/doc/templates/coverBlock.ts +77 -47
  72. package/src/markdown/convert.ts +104 -11
  73. package/src/markdown/index.ts +1 -0
  74. package/src/markdown/types.ts +21 -1
  75. package/src/markdown/utils.ts +5 -1
  76. package/src/schemas/Doc.ts +2 -2
  77. package/src/schemas/Theme.ts +62 -0
  78. package/src/schemas/themeLibrary.ts +74 -2
  79. package/src/storage/ContentContainer.ts +9 -1
  80. package/dist/chunk-4CRBS35L.js.map +0 -1
  81. package/dist/chunk-KEFWM2VC.js.map +0 -1
  82. package/dist/chunk-NJLL5XYC.js.map +0 -1
  83. package/dist/chunk-QWVRN6I4.js.map +0 -1
  84. package/dist/chunk-T6WBC3ND.js.map +0 -1
  85. package/dist/chunk-TVPKD76V.js.map +0 -1
@@ -33,6 +33,7 @@ import type {
33
33
  MarkdownLinkReference,
34
34
  MarkdownImageReference,
35
35
  MarkdownText,
36
+ MarkdownMention,
36
37
  } from '../markdown/index';
37
38
 
38
39
  // ============================================
@@ -151,6 +152,49 @@ describe('parseMarkdown / stringifyMarkdown', () => {
151
152
  expect(link.children[0]).toEqual({ type: 'text', value: 'Click here' });
152
153
  });
153
154
 
155
+ it('parses mentions from @[Name](scheme:id)', () => {
156
+ const doc = parse('Hey @[Leo](gezel:leo), take a look.');
157
+ const p = doc.children[0] as MarkdownParagraph;
158
+ const text0 = p.children[0] as MarkdownText;
159
+ expect(text0).toEqual({ type: 'text', value: 'Hey ' });
160
+ const mention = p.children[1] as MarkdownMention;
161
+ expect(mention).toEqual({
162
+ type: 'mention',
163
+ targetKind: 'gezel',
164
+ targetId: 'leo',
165
+ displayName: 'Leo',
166
+ });
167
+ const text2 = p.children[2] as MarkdownText;
168
+ expect(text2.value).toBe(', take a look.');
169
+ });
170
+
171
+ it('round-trips a mention back to markdown', () => {
172
+ // remark-stringify escapes `:` in link URLs that follow an `@` (to
173
+ // disambiguate from autolink syntax). The escape is idempotent —
174
+ // re-parsing `\:` produces the unescaped url, so the round-trip
175
+ // preserves the mention semantically even if the raw text changes
176
+ // on the first pass.
177
+ const input = 'Hey @[Leo](gezel:leo), ping @[Tess](gezel:tess) too.\n';
178
+ const once = stringifyMarkdown(parseMarkdown(input));
179
+ const twice = stringifyMarkdown(parseMarkdown(once));
180
+ expect(twice).toBe(once);
181
+ // Semantics are preserved: re-parsing yields mentions.
182
+ const doc = parseMarkdown(once) as MarkdownDocument;
183
+ const p = doc.children[0] as MarkdownParagraph;
184
+ const m1 = p.children.find((c) => c.type === 'mention') as MarkdownMention;
185
+ expect(m1.targetKind).toBe('gezel');
186
+ expect(m1.targetId).toBe('leo');
187
+ expect(m1.displayName).toBe('Leo');
188
+ });
189
+
190
+ it('does not collapse @ + link when the url has no scheme', () => {
191
+ const doc = parse('Cost @[100]($100)');
192
+ const p = doc.children[0] as MarkdownParagraph;
193
+ // "$100" doesn't match scheme:id because $ isn't a scheme character
194
+ // → stays as text("Cost @") + link
195
+ expect((p.children[1] as MarkdownLink).type).toBe('link');
196
+ });
197
+
154
198
  it('parses images', () => {
155
199
  const doc = parse('![Alt text](image.png "Image title")');
156
200
  const p = doc.children[0] as MarkdownParagraph;
@@ -591,6 +635,28 @@ describe('Tree utilities', () => {
591
635
  expect(extractPlainText(doc)).toBe('TitleHello world');
592
636
  });
593
637
 
638
+ it('extractPlainText preserves list item boundaries', () => {
639
+ const list: MarkdownList = {
640
+ type: 'list',
641
+ ordered: false,
642
+ children: [
643
+ {
644
+ type: 'listItem',
645
+ children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Apple' }] }],
646
+ },
647
+ {
648
+ type: 'listItem',
649
+ children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Banana' }] }],
650
+ },
651
+ {
652
+ type: 'listItem',
653
+ children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Cherry' }] }],
654
+ },
655
+ ],
656
+ };
657
+ expect(extractPlainText(list)).toBe('Apple\nBanana\nCherry');
658
+ });
659
+
594
660
  it('countNodes counts all nodes', () => {
595
661
  expect(countNodes(doc)).toBe(7); // document + heading + text + paragraph + text + link + text
596
662
  });
@@ -468,3 +468,83 @@ describe('caption generation', () => {
468
468
  }
469
469
  });
470
470
  });
471
+
472
+ // ============================================
473
+ // Auto cover block (startBlock) generation
474
+ // ============================================
475
+
476
+ describe('auto cover block generation', () => {
477
+ it('creates startBlock from H1 title', () => {
478
+ const md = parseMarkdown('# My Great Article\n\nSome introduction text.');
479
+ const doc = markdownToDoc(md);
480
+
481
+ expect(doc.startBlock).toBeDefined();
482
+ expect(doc.startBlock!.title).toBe('My Great Article');
483
+ });
484
+
485
+ it('uses first paragraph after H1 as subtitle', () => {
486
+ const md = parseMarkdown('# Title\n\nThis is a subtitle paragraph.\n\n## Section\n\nBody');
487
+ const doc = markdownToDoc(md);
488
+
489
+ expect(doc.startBlock).toBeDefined();
490
+ expect(doc.startBlock!.subtitle).toBe('This is a subtitle paragraph.');
491
+ });
492
+
493
+ it('picks first image as heroSrc', () => {
494
+ const md = parseMarkdown('# Title\n\n![photo](hero.jpg)\n\nSome text.');
495
+ const doc = markdownToDoc(md);
496
+
497
+ expect(doc.startBlock).toBeDefined();
498
+ expect(doc.startBlock!.heroSrc).toBe('hero.jpg');
499
+ expect(doc.startBlock!.heroAlt).toBe('photo');
500
+ });
501
+
502
+ it('picks image from deeper in document', () => {
503
+ const md = parseMarkdown(
504
+ '# Title\n\nIntro text.\n\n## Section\n\nBody text.\n\n![deep pic](deep.png)',
505
+ );
506
+ const doc = markdownToDoc(md);
507
+
508
+ expect(doc.startBlock).toBeDefined();
509
+ expect(doc.startBlock!.heroSrc).toBe('deep.png');
510
+ });
511
+
512
+ it('works without images (no heroSrc)', () => {
513
+ const md = parseMarkdown('# Title\n\nPlain text, no images.');
514
+ const doc = markdownToDoc(md);
515
+
516
+ expect(doc.startBlock).toBeDefined();
517
+ expect(doc.startBlock!.title).toBe('Title');
518
+ expect(doc.startBlock!.heroSrc).toBeUndefined();
519
+ });
520
+
521
+ it('sets ambientMotion to zoomIn by default', () => {
522
+ const md = parseMarkdown('# Title\n\nContent.');
523
+ const doc = markdownToDoc(md);
524
+
525
+ expect(doc.startBlock!.ambientMotion).toBe('zoomIn');
526
+ });
527
+
528
+ it('does not create startBlock when no H1 exists', () => {
529
+ const md = parseMarkdown('## Section A\n\nText A\n\n## Section B\n\nText B');
530
+ const doc = markdownToDoc(md);
531
+
532
+ expect(doc.startBlock).toBeUndefined();
533
+ });
534
+
535
+ it('does not create startBlock when generateCoverBlock is false', () => {
536
+ const md = parseMarkdown('# Title\n\nContent.');
537
+ const doc = markdownToDoc(md, { generateCoverBlock: false });
538
+
539
+ expect(doc.startBlock).toBeUndefined();
540
+ });
541
+
542
+ it('does not create startBlock for heading-only document with no content', () => {
543
+ const md = parseMarkdown('# Title');
544
+ const doc = markdownToDoc(md);
545
+
546
+ expect(doc.startBlock).toBeDefined();
547
+ expect(doc.startBlock!.title).toBe('Title');
548
+ expect(doc.startBlock!.subtitle).toBeUndefined();
549
+ });
550
+ });
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ applySurface,
4
+ DARK_SURFACE,
5
+ DEFAULT_THEME,
6
+ LIGHT_SURFACE,
7
+ type SurfaceScheme,
8
+ } from '../schemas/index.js';
9
+
10
+ describe('SurfaceScheme', () => {
11
+ it('LIGHT_SURFACE and DARK_SURFACE expose the full field set', () => {
12
+ for (const s of [LIGHT_SURFACE, DARK_SURFACE]) {
13
+ expect(typeof s.id).toBe('string');
14
+ expect(typeof s.background).toBe('string');
15
+ expect(typeof s.backgroundLight).toBe('string');
16
+ expect(typeof s.text).toBe('string');
17
+ expect(typeof s.textMuted).toBe('string');
18
+ }
19
+ });
20
+ });
21
+
22
+ describe('applySurface', () => {
23
+ it('overlays surface fields onto a theme', () => {
24
+ const out = applySurface(DEFAULT_THEME, LIGHT_SURFACE);
25
+ expect(out.colors.background).toBe(LIGHT_SURFACE.background);
26
+ expect(out.colors.backgroundLight).toBe(LIGHT_SURFACE.backgroundLight);
27
+ expect(out.colors.text).toBe(LIGHT_SURFACE.text);
28
+ expect(out.colors.textMuted).toBe(LIGHT_SURFACE.textMuted);
29
+ });
30
+
31
+ it('leaves editorial colors (primary, highlight, warning) untouched', () => {
32
+ const out = applySurface(DEFAULT_THEME, DARK_SURFACE);
33
+ expect(out.colors.primary).toBe(DEFAULT_THEME.colors.primary);
34
+ expect(out.colors.secondary).toBe(DEFAULT_THEME.colors.secondary);
35
+ expect(out.colors.highlight).toBe(DEFAULT_THEME.colors.highlight);
36
+ expect(out.colors.warning).toBe(DEFAULT_THEME.colors.warning);
37
+ });
38
+
39
+ it('leaves typography, style, renderStyle, colorSchemes untouched', () => {
40
+ const out = applySurface(DEFAULT_THEME, LIGHT_SURFACE);
41
+ expect(out.typography).toBe(DEFAULT_THEME.typography);
42
+ expect(out.style).toBe(DEFAULT_THEME.style);
43
+ expect(out.renderStyle).toBe(DEFAULT_THEME.renderStyle);
44
+ expect(out.colorSchemes).toBe(DEFAULT_THEME.colorSchemes);
45
+ });
46
+
47
+ it('returns a new theme (does not mutate the base)', () => {
48
+ const frozenTheme = DEFAULT_THEME;
49
+ const originalBg = frozenTheme.colors.background;
50
+ const out = applySurface(frozenTheme, DARK_SURFACE);
51
+ expect(out).not.toBe(frozenTheme);
52
+ expect(out.colors).not.toBe(frozenTheme.colors);
53
+ expect(frozenTheme.colors.background).toBe(originalBg);
54
+ });
55
+
56
+ it('accepts custom SurfaceScheme objects', () => {
57
+ const custom: SurfaceScheme = {
58
+ id: 'parchment',
59
+ background: '#f4ecd8',
60
+ backgroundLight: '#e8dfc4',
61
+ text: '#2b2b2b',
62
+ textMuted: '#6b6b6b',
63
+ };
64
+ const out = applySurface(DEFAULT_THEME, custom);
65
+ expect(out.colors.background).toBe('#f4ecd8');
66
+ expect(out.colors.textMuted).toBe('#6b6b6b');
67
+ });
68
+ });
@@ -124,23 +124,61 @@ interface Mp3Info {
124
124
 
125
125
  // ── Core Functions ───────────────────────────────────────────────────
126
126
 
127
+ /**
128
+ * Parse a consolidated timing.json (version 2) which contains all section
129
+ * timing data in a single file.
130
+ */
131
+ function parseConsolidatedTiming(data: ArrayBuffer): Record<string, AudioTimingData> | null {
132
+ try {
133
+ const text = new TextDecoder().decode(data);
134
+ const parsed = JSON.parse(text);
135
+ if (parsed && parsed.version === 2 && parsed.sections) {
136
+ return parsed.sections as Record<string, AudioTimingData>;
137
+ }
138
+ return null;
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
127
144
  /**
128
145
  * Discover all MP3 files in a container and load their timing data.
146
+ * Checks for a consolidated timing.json first, then falls back to per-file .timing.json.
129
147
  */
130
148
  async function discoverMp3s(container: ContentContainer): Promise<Mp3Info[]> {
131
149
  const files = await container.listFiles();
132
150
  const mp3Files = files.filter((f) => f.path.endsWith('.mp3'));
133
151
  const results: Mp3Info[] = [];
134
152
 
153
+ // Try to load consolidated timing.json
154
+ let consolidatedSections: Record<string, AudioTimingData> | null = null;
155
+ const consolidatedData = await container.readFile('timing.json');
156
+ if (consolidatedData) {
157
+ consolidatedSections = parseConsolidatedTiming(consolidatedData);
158
+ }
159
+
135
160
  for (const file of mp3Files) {
136
161
  const filename = file.path.split('/').pop() ?? file.path;
137
162
  let timing: AudioTimingData | null = null;
138
163
 
139
- // Try to load timing.json
140
- const timingPath = `${file.path}.timing.json`;
141
- const timingData = await container.readFile(timingPath);
142
- if (timingData) {
143
- timing = parseTimingJson(timingData);
164
+ // Try consolidated timing first: match section name from filename
165
+ if (consolidatedSections) {
166
+ const mp3Base = filename.replace(/\.mp3$/, '');
167
+ for (const [sectionName, sectionTiming] of Object.entries(consolidatedSections)) {
168
+ if (mp3Base.endsWith(`-${sectionName}`)) {
169
+ timing = sectionTiming;
170
+ break;
171
+ }
172
+ }
173
+ }
174
+
175
+ // Fall back to per-file .timing.json
176
+ if (!timing) {
177
+ const timingPath = `${file.path}.timing.json`;
178
+ const timingData = await container.readFile(timingPath);
179
+ if (timingData) {
180
+ timing = parseTimingJson(timingData);
181
+ }
144
182
  }
145
183
 
146
184
  results.push({ path: file.path, filename, timing });
@@ -22,8 +22,14 @@
22
22
  * ```
23
23
  */
24
24
 
25
- import type { Doc, Block, CaptionTrack, CaptionPhrase } from '../schemas/Doc.js';
26
- import type { MarkdownDocument, MarkdownBlockNode, MarkdownHeading } from '../markdown/types.js';
25
+ import type { Doc, Block, CaptionTrack, CaptionPhrase, StartBlockConfig } from '../schemas/Doc.js';
26
+ import type {
27
+ MarkdownDocument,
28
+ MarkdownBlockNode,
29
+ MarkdownHeading,
30
+ MarkdownNode,
31
+ MarkdownImage,
32
+ } from '../markdown/types.js';
27
33
  import { extractPlainText } from '../markdown/utils.js';
28
34
  import { estimateReadingTime } from '../timing/readingTime.js';
29
35
 
@@ -46,6 +52,14 @@ export interface MarkdownToDocOptions {
46
52
 
47
53
  /** Custom ID generator. Receives the heading node and its index. */
48
54
  generateId?: (heading: MarkdownHeading, index: number) => string;
55
+
56
+ /**
57
+ * Whether to auto-generate a cover startBlock from the first H1 heading.
58
+ * When true (default), a StartBlockConfig is created using the first H1's
59
+ * text as the title. If the document contains an image, the first image
60
+ * is used as the hero. Set to false to suppress automatic cover generation.
61
+ */
62
+ generateCoverBlock?: boolean;
49
63
  }
50
64
 
51
65
  // ============================================
@@ -267,7 +281,7 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
267
281
  const captions: CaptionTrack | undefined =
268
282
  phrases.length > 0 ? { phrases, generatedAt: new Date().toISOString(), version: 1 } : undefined;
269
283
 
270
- return {
284
+ const doc: Doc = {
271
285
  articleId,
272
286
  duration: currentTime,
273
287
  blocks: rootBlocks,
@@ -277,6 +291,16 @@ export function markdownToDoc(markdownDoc: MarkdownDocument, options?: MarkdownT
277
291
  ...(captions ? { captions } : {}),
278
292
  ...(markdownDoc.frontmatter ? { frontmatter: markdownDoc.frontmatter } : {}),
279
293
  };
294
+
295
+ // Auto-generate cover startBlock from the first H1 heading
296
+ if (options?.generateCoverBlock ?? true) {
297
+ const coverConfig = buildStartBlock(markdownDoc, rootBlocks);
298
+ if (coverConfig) {
299
+ doc.startBlock = coverConfig;
300
+ }
301
+ }
302
+
303
+ return doc;
280
304
  }
281
305
 
282
306
  // ============================================
@@ -409,3 +433,50 @@ function splitIntoSentences(text: string): string[] {
409
433
  }
410
434
  return merged;
411
435
  }
436
+
437
+ /**
438
+ * Walk a MarkdownNode tree depth-first to find the first image node.
439
+ * Returns the MarkdownImage or undefined if none found.
440
+ */
441
+ function findFirstImage(node: MarkdownNode): MarkdownImage | undefined {
442
+ if (node.type === 'image') return node as MarkdownImage;
443
+ if ('children' in node && Array.isArray((node as { children?: unknown[] }).children)) {
444
+ for (const child of (node as { children: MarkdownNode[] }).children) {
445
+ const found = findFirstImage(child);
446
+ if (found) return found;
447
+ }
448
+ }
449
+ return undefined;
450
+ }
451
+
452
+ /**
453
+ * Build a StartBlockConfig from the document's first H1 heading and optional
454
+ * first image. Returns undefined if the document has no H1 heading.
455
+ */
456
+ function buildStartBlock(
457
+ markdownDoc: MarkdownDocument,
458
+ rootBlocks: Block[],
459
+ ): StartBlockConfig | undefined {
460
+ // Find the first H1 block — it provides the cover title
461
+ const firstH1 = rootBlocks.find((b) => b.sourceHeading?.depth === 1);
462
+ if (!firstH1) return undefined;
463
+
464
+ const title = firstH1.title ?? extractPlainText(firstH1.sourceHeading!);
465
+ if (!title) return undefined;
466
+
467
+ // Look for the first paragraph immediately after the H1 to use as subtitle
468
+ const subtitle =
469
+ firstH1.contents?.[0]?.type === 'paragraph' ? extractPlainText(firstH1.contents[0]) : undefined;
470
+
471
+ // Scan the whole document for the first image to use as the hero
472
+ const firstImage = findFirstImage(markdownDoc);
473
+
474
+ const config: StartBlockConfig = {
475
+ title,
476
+ ...(subtitle ? { subtitle } : {}),
477
+ ...(firstImage ? { heroSrc: firstImage.url, heroAlt: firstImage.alt ?? title } : {}),
478
+ ambientMotion: 'zoomIn',
479
+ };
480
+
481
+ return config;
482
+ }
@@ -25,8 +25,8 @@ import { mapAmbientMotion } from './accentImage.js';
25
25
  * Input for coverBlock template - matches StartBlockConfig
26
26
  */
27
27
  export interface CoverBlockInput {
28
- /** Path to hero image */
29
- heroSrc: string;
28
+ /** Path to hero image (omit for theme-driven background) */
29
+ heroSrc?: string;
30
30
  /** Alt text for the hero image */
31
31
  heroAlt?: string;
32
32
  /** Title to display over the hero */
@@ -48,63 +48,93 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
48
48
  const { heroSrc, heroAlt, title, subtitle, ambientMotion, heroCredit, heroLicense } = input;
49
49
  const { theme, layout } = context;
50
50
 
51
- // Scale font sizes for viewport - use larger sizes for the cover
52
- const titleFontSize = scaledFontSize(96, context, true);
53
- const subtitleFontSize = scaledFontSize(36, context, false);
51
+ // Scale font sizes for viewport - cover titles are larger than regular title blocks
52
+ const titleFontSize = scaledFontSize(120, context, true);
53
+ const subtitleFontSize = scaledFontSize(40, context, false);
54
54
 
55
- // Determine Ken Burns animation
56
- const imageAnimation = mapAmbientMotion(ambientMotion);
55
+ const layers: Layer[] = [];
57
56
 
58
- const layers: Layer[] = [
59
- // Full-screen hero image
60
- {
61
- type: 'image',
62
- id: 'cover-hero',
63
- content: {
64
- src: heroSrc,
65
- alt: heroAlt || title,
66
- fit: 'cover',
67
- credit: heroCredit,
68
- license: heroLicense,
57
+ if (heroSrc) {
58
+ // Hero image path: full-screen image with gradient overlay for text readability
59
+ const imageAnimation = mapAmbientMotion(ambientMotion);
60
+
61
+ layers.push(
62
+ {
63
+ type: 'image',
64
+ id: 'cover-hero',
65
+ content: {
66
+ src: heroSrc,
67
+ alt: heroAlt || title,
68
+ fit: 'cover',
69
+ credit: heroCredit,
70
+ license: heroLicense,
71
+ },
72
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
73
+ animation: imageAnimation,
69
74
  },
70
- position: { x: 0, y: 0, width: '100%', height: '100%' },
71
- animation: imageAnimation,
72
- },
73
- // Gradient overlay for text readability - from bottom
74
- {
75
+ {
76
+ type: 'shape',
77
+ id: 'cover-gradient',
78
+ content: {
79
+ shape: 'rect',
80
+ fill: 'linear-gradient(0deg, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.1) 70%, transparent 100%)',
81
+ },
82
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
83
+ },
84
+ );
85
+ } else {
86
+ // No hero image: use a rich theme-driven background with radial gradient
87
+ layers.push({
75
88
  type: 'shape',
76
- id: 'cover-gradient',
89
+ id: 'cover-bg',
77
90
  content: {
78
91
  shape: 'rect',
79
- fill: 'linear-gradient(0deg, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.1) 70%, transparent 100%)',
92
+ fill: `radial-gradient(ellipse at 50% 40%, ${theme.colors.primary} 0%, ${theme.colors.background} 100%)`,
80
93
  },
81
94
  position: { x: 0, y: 0, width: '100%', height: '100%' },
82
- },
83
- // Title - positioned lower in the frame to work with hero composition
84
- {
85
- type: 'text',
86
- id: 'cover-title',
95
+ });
96
+
97
+ // Subtle decorative accent line below title
98
+ layers.push({
99
+ type: 'shape',
100
+ id: 'cover-accent',
87
101
  content: {
88
- text: title,
89
- style: {
90
- fontSize: titleFontSize,
91
- fontFamily: getThemeFont(context, 'title'),
92
- fontWeight: 'bold',
93
- color: theme.colors.text,
94
- textAlign: 'center',
95
- shadow: true,
96
- },
102
+ shape: 'rect',
103
+ fill: 'rgba(255, 255, 255, 0.2)',
97
104
  },
98
105
  position: {
99
- x: '50%',
100
- y: subtitle ? '70%' : '75%',
101
- anchor: 'center',
102
- width: layout.maxTextWidth,
106
+ x: '35%',
107
+ y: subtitle ? '42%' : '58%',
108
+ width: '30%',
109
+ height: '2px',
110
+ },
111
+ });
112
+ }
113
+
114
+ // Title - positioned lower when over hero, centered when over solid background
115
+ layers.push({
116
+ type: 'text',
117
+ id: 'cover-title',
118
+ content: {
119
+ text: title,
120
+ style: {
121
+ fontSize: titleFontSize,
122
+ fontFamily: getThemeFont(context, 'title'),
123
+ fontWeight: 'bold',
124
+ color: theme.colors.text,
125
+ textAlign: 'center',
126
+ shadow: true,
103
127
  },
104
- // No animation delay - shown immediately at rest
105
- animation: { type: 'fadeIn', duration: 0.8 },
106
128
  },
107
- ];
129
+ position: {
130
+ x: '50%',
131
+ y: heroSrc ? (subtitle ? '70%' : '75%') : subtitle ? layout.primaryY : '50%',
132
+ anchor: 'center',
133
+ width: layout.maxTextWidth,
134
+ },
135
+ // No animation delay - shown immediately at rest
136
+ animation: { type: 'fadeIn', duration: 0.8 },
137
+ });
108
138
 
109
139
  // Add subtitle if provided
110
140
  if (subtitle) {
@@ -123,7 +153,7 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
123
153
  },
124
154
  position: {
125
155
  x: '50%',
126
- y: '82%',
156
+ y: heroSrc ? '82%' : layout.secondaryY,
127
157
  anchor: 'center',
128
158
  width: layout.maxTextWidth,
129
159
  },