@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
@@ -253,7 +253,64 @@ function convertInlineChildren(children: MdastNode[], parseHtml: boolean): Markd
253
253
  const node = convertInlineNode(child, parseHtml);
254
254
  if (node) result.push(node);
255
255
  }
256
- return result;
256
+ return coalesceMentions(result);
257
+ }
258
+
259
+ /**
260
+ * Post-process an inline-children list to collapse `text('@') + link(scheme:id)`
261
+ * pairs into a single `mention` node. The wire format `@[Display](scheme:id)`
262
+ * comes back from remark as two adjacent nodes — we merge them so consumers
263
+ * see a single atomic mention. A plain link whose URL doesn't have `scheme:id`
264
+ * shape, or that isn't immediately preceded by `@`, is left alone so ordinary
265
+ * links keep working.
266
+ */
267
+ function coalesceMentions(inlines: MarkdownInlineNode[]): MarkdownInlineNode[] {
268
+ const out: MarkdownInlineNode[] = [];
269
+ for (const cur of inlines) {
270
+ const prev = out[out.length - 1];
271
+ if (
272
+ cur.type === 'link' &&
273
+ typeof cur.url === 'string' &&
274
+ prev &&
275
+ prev.type === 'text' &&
276
+ prev.value.endsWith('@')
277
+ ) {
278
+ const schemeMatch = cur.url.match(/^([a-z][a-z0-9+.-]*):(.+)$/i);
279
+ if (schemeMatch && schemeMatch[1] && schemeMatch[2]) {
280
+ const targetKind = schemeMatch[1];
281
+ const targetId = schemeMatch[2];
282
+ const displayName = extractInlineText(cur.children);
283
+ if (prev.value === '@') {
284
+ out.pop();
285
+ } else {
286
+ prev.value = prev.value.slice(0, -1);
287
+ }
288
+ out.push({
289
+ type: 'mention',
290
+ targetKind,
291
+ targetId,
292
+ displayName,
293
+ ...(cur.position ? { position: cur.position } : {}),
294
+ });
295
+ continue;
296
+ }
297
+ }
298
+ out.push(cur);
299
+ }
300
+ return out;
301
+ }
302
+
303
+ /** Plain-text of an inline children list — used to pull the display name
304
+ * out of a mention link's children. */
305
+ function extractInlineText(nodes: MarkdownInlineNode[]): string {
306
+ let out = '';
307
+ for (const n of nodes) {
308
+ if (n.type === 'text' || n.type === 'inlineCode') out += n.value;
309
+ else if ('children' in n && Array.isArray(n.children)) {
310
+ out += extractInlineText(n.children as MarkdownInlineNode[]);
311
+ }
312
+ }
313
+ return out;
257
314
  }
258
315
 
259
316
  function convertBlockNode(node: MdastNode, parseHtml: boolean): MarkdownBlockNode | null {
@@ -590,7 +647,7 @@ export function toMdast(doc: MarkdownDocument): MdastNode {
590
647
  function blockToMdast(node: MarkdownBlockNode): MdastNode {
591
648
  switch (node.type) {
592
649
  case 'heading': {
593
- const mdastChildren = node.children.map(inlineToMdast);
650
+ const mdastChildren = inlineChildrenToMdast(node.children);
594
651
  if (node.templateAnnotation) {
595
652
  const suffix = serializeTemplateAnnotation(node.templateAnnotation);
596
653
  // Append to last text node, or create a new one
@@ -612,7 +669,7 @@ function blockToMdast(node: MarkdownBlockNode): MdastNode {
612
669
  case 'paragraph':
613
670
  return {
614
671
  type: 'paragraph',
615
- children: node.children.map(inlineToMdast),
672
+ children: inlineChildrenToMdast(node.children),
616
673
  ...mdastPosField(node.position),
617
674
  };
618
675
 
@@ -718,7 +775,7 @@ function blockToMdast(node: MarkdownBlockNode): MdastNode {
718
775
  type: 'leafDirective',
719
776
  name: node.name,
720
777
  ...(node.attributes ? { attributes: node.attributes } : {}),
721
- children: node.children.map(inlineToMdast),
778
+ children: inlineChildrenToMdast(node.children),
722
779
  ...mdastPosField(node.position),
723
780
  };
724
781
 
@@ -757,11 +814,34 @@ function tableRowToMdast(row: MarkdownTableRow): MdastNode {
757
814
  function tableCellToMdast(cell: MarkdownTableCell): MdastNode {
758
815
  return {
759
816
  type: 'tableCell',
760
- children: cell.children.map(inlineToMdast),
817
+ children: inlineChildrenToMdast(cell.children),
761
818
  ...mdastPosField(cell.position),
762
819
  };
763
820
  }
764
821
 
822
+ /**
823
+ * Serialize an array of inline nodes to mdast, flat-mapping the result so
824
+ * a single `mention` expands to two mdast nodes (`text('@')` + `link`).
825
+ * Use in place of `.map(inlineToMdast)` wherever we walk a children array.
826
+ */
827
+ function inlineChildrenToMdast(nodes: MarkdownInlineNode[]): MdastNode[] {
828
+ const out: MdastNode[] = [];
829
+ for (const n of nodes) {
830
+ if (n.type === 'mention') {
831
+ out.push({ type: 'text', value: '@' });
832
+ out.push({
833
+ type: 'link',
834
+ url: `${n.targetKind}:${n.targetId}`,
835
+ children: [{ type: 'text', value: n.displayName }],
836
+ ...mdastPosField(n.position),
837
+ });
838
+ } else {
839
+ out.push(inlineToMdast(n));
840
+ }
841
+ }
842
+ return out;
843
+ }
844
+
765
845
  function inlineToMdast(node: MarkdownInlineNode): MdastNode {
766
846
  switch (node.type) {
767
847
  case 'text':
@@ -774,21 +854,21 @@ function inlineToMdast(node: MarkdownInlineNode): MdastNode {
774
854
  case 'emphasis':
775
855
  return {
776
856
  type: 'emphasis',
777
- children: node.children.map(inlineToMdast),
857
+ children: inlineChildrenToMdast(node.children),
778
858
  ...mdastPosField(node.position),
779
859
  };
780
860
 
781
861
  case 'strong':
782
862
  return {
783
863
  type: 'strong',
784
- children: node.children.map(inlineToMdast),
864
+ children: inlineChildrenToMdast(node.children),
785
865
  ...mdastPosField(node.position),
786
866
  };
787
867
 
788
868
  case 'delete':
789
869
  return {
790
870
  type: 'delete',
791
- children: node.children.map(inlineToMdast),
871
+ children: inlineChildrenToMdast(node.children),
792
872
  ...mdastPosField(node.position),
793
873
  };
794
874
 
@@ -804,7 +884,7 @@ function inlineToMdast(node: MarkdownInlineNode): MdastNode {
804
884
  type: 'link',
805
885
  url: node.url,
806
886
  ...(node.title != null ? { title: node.title } : {}),
807
- children: node.children.map(inlineToMdast),
887
+ children: inlineChildrenToMdast(node.children),
808
888
  ...mdastPosField(node.position),
809
889
  };
810
890
 
@@ -851,7 +931,7 @@ function inlineToMdast(node: MarkdownInlineNode): MdastNode {
851
931
  identifier: node.identifier,
852
932
  ...(node.label != null ? { label: node.label } : {}),
853
933
  referenceType: node.referenceType,
854
- children: node.children.map(inlineToMdast),
934
+ children: inlineChildrenToMdast(node.children),
855
935
  ...mdastPosField(node.position),
856
936
  };
857
937
 
@@ -870,7 +950,20 @@ function inlineToMdast(node: MarkdownInlineNode): MdastNode {
870
950
  type: 'textDirective',
871
951
  name: node.name,
872
952
  ...(node.attributes ? { attributes: node.attributes } : {}),
873
- children: node.children.map(inlineToMdast),
953
+ children: inlineChildrenToMdast(node.children),
954
+ ...mdastPosField(node.position),
955
+ };
956
+
957
+ case 'mention':
958
+ // Fallback: when `inlineToMdast` is called directly (not via
959
+ // `inlineChildrenToMdast`), collapse the mention to just the link
960
+ // form. The `@` prefix is lost, but the id+name still round-trip.
961
+ // Normal call sites go through `inlineChildrenToMdast` and get
962
+ // the proper text('@') + link pair.
963
+ return {
964
+ type: 'link',
965
+ url: `${node.targetKind}:${node.targetId}`,
966
+ children: [{ type: 'text', value: node.displayName }],
874
967
  ...mdastPosField(node.position),
875
968
  };
876
969
 
@@ -77,6 +77,7 @@ export type {
77
77
  MarkdownLinkReference,
78
78
  MarkdownImageReference,
79
79
  MarkdownTextDirective,
80
+ MarkdownMention,
80
81
 
81
82
  // Union types
82
83
  MarkdownBlockNode,
@@ -507,6 +507,25 @@ export interface MarkdownTextDirective extends MarkdownNodeBase {
507
507
  children: MarkdownInlineNode[];
508
508
  }
509
509
 
510
+ /**
511
+ * An @-mention of a named entity such as a user or agent.
512
+ *
513
+ * Wire format: `@[Display Name](scheme:id)` — a regular Markdown link
514
+ * preceded by `@`, with a namespaced URL scheme that identifies what
515
+ * kind of entity is being mentioned. Parsers that don't know about
516
+ * mentions still render it as a link with text "Display Name" pointing
517
+ * at "scheme:id" — graceful fallback.
518
+ */
519
+ export interface MarkdownMention extends MarkdownNodeBase {
520
+ type: 'mention';
521
+ /** Namespace for the mentioned entity (e.g. `"gezel"`, `"user"`). */
522
+ targetKind: string;
523
+ /** Stable identifier within the namespace. */
524
+ targetId: string;
525
+ /** Text shown to the reader. */
526
+ displayName: string;
527
+ }
528
+
510
529
  // ============================================
511
530
  // Union Types
512
531
  // ============================================
@@ -543,7 +562,8 @@ export type MarkdownInlineNode =
543
562
  | MarkdownFootnoteReference
544
563
  | MarkdownLinkReference
545
564
  | MarkdownImageReference
546
- | MarkdownTextDirective;
565
+ | MarkdownTextDirective
566
+ | MarkdownMention;
547
567
 
548
568
  /**
549
569
  * Any node in the markdown tree. Includes structural nodes (listItem,
@@ -87,7 +87,11 @@ export function extractPlainText(node: MarkdownNode): string {
87
87
  }
88
88
 
89
89
  const children = getChildren(node);
90
- return children.map(extractPlainText).join('');
90
+ // Preserve boundaries between block-level elements (list items, paragraphs
91
+ // inside list items, blockquotes) so downstream consumers like caption
92
+ // splitting can treat each item as a separate phrase.
93
+ const separator = node.type === 'list' || node.type === 'listItem' ? '\n' : '';
94
+ return children.map(extractPlainText).join(separator);
91
95
  }
92
96
 
93
97
  /**
@@ -22,8 +22,8 @@
22
22
  * Configuration for the Start/resting block shown before playback begins.
23
23
  */
24
24
  export interface StartBlockConfig {
25
- /** Hero image path (relative to article media) */
26
- heroSrc: string;
25
+ /** Hero image path (relative to article media). When omitted, a theme-driven background is used. */
26
+ heroSrc?: string;
27
27
  /** Alt text for the hero image */
28
28
  heroAlt?: string;
29
29
  /** Title to display over the hero */
@@ -233,3 +233,65 @@ export function createTheme(base: Theme, overrides: DeepPartial<Theme>): Theme {
233
233
  }
234
234
  return merged;
235
235
  }
236
+
237
+ // ============================================
238
+ // Surface Schemes — orthogonal to Theme
239
+ // ============================================
240
+
241
+ /**
242
+ * A Surface Scheme is the light/dark axis, orthogonal to the editorial
243
+ * identity of a Theme. A Theme chooses voice (serif vs sans, muted vs
244
+ * bold, documentary vs magazine); a SurfaceScheme chooses what the paper
245
+ * looks like. Any theme can render on either surface.
246
+ *
247
+ * When a SurfaceScheme is applied to a Theme (via `applySurface`), these
248
+ * fields override the corresponding entries in `ThemeColorPalette` —
249
+ * everything else (primary, highlight, warning, etc.) stays as the theme
250
+ * defined it.
251
+ */
252
+ export interface SurfaceScheme {
253
+ /** Identifier — 'light', 'dark', or a custom id. */
254
+ id: string;
255
+ background: string;
256
+ backgroundLight: string;
257
+ text: string;
258
+ textMuted: string;
259
+ }
260
+
261
+ /** Near-white paper with dark text — standard light mode. */
262
+ export const LIGHT_SURFACE: SurfaceScheme = {
263
+ id: 'light',
264
+ background: '#ffffff',
265
+ backgroundLight: '#f5f5f5',
266
+ text: '#1a1a1a',
267
+ textMuted: '#666666',
268
+ };
269
+
270
+ /** Dark charcoal paper with light text — standard dark mode. */
271
+ export const DARK_SURFACE: SurfaceScheme = {
272
+ id: 'dark',
273
+ background: '#1a202c',
274
+ backgroundLight: '#2d3748',
275
+ text: '#f0f0f0',
276
+ textMuted: '#a0aec0',
277
+ };
278
+
279
+ /**
280
+ * Overlay a SurfaceScheme's surface colors onto a Theme's palette,
281
+ * leaving everything else (primary, highlight, warning, typography,
282
+ * style, renderStyle, colorSchemes) untouched. Returns a new Theme;
283
+ * callers that want to preserve the original's id should set it
284
+ * explicitly in the overrides.
285
+ */
286
+ export function applySurface(theme: Theme, surface: SurfaceScheme): Theme {
287
+ return {
288
+ ...theme,
289
+ colors: {
290
+ ...theme.colors,
291
+ background: surface.background,
292
+ backgroundLight: surface.backgroundLight,
293
+ text: surface.text,
294
+ textMuted: surface.textMuted,
295
+ },
296
+ };
297
+ }
@@ -44,7 +44,6 @@ const DM_SANS = '"DM Sans", "Segoe UI", Roboto, sans-serif';
44
44
  const CORMORANT = '"Cormorant Garamond", Garamond, Georgia, serif';
45
45
  const CRIMSON = '"Crimson Text", Georgia, serif';
46
46
  const PT_SERIF = '"PT Serif", Georgia, serif';
47
- const HANKEN_GROTESK = '"Hanken Grotesk", system-ui, sans-serif';
48
47
 
49
48
  /** Standard 6-scheme set used by the documentary theme (migrated from old COLOR_SCHEMES). */
50
49
  const CLASSIC_COLOR_SCHEMES: Record<string, ThemeColorScheme> = {
@@ -473,7 +472,7 @@ const warmEarth: Theme = {
473
472
  warning: '#e53e3e',
474
473
  },
475
474
  typography: {
476
- bodyFontFamily: HANKEN_GROTESK,
475
+ bodyFontFamily: PT_SERIF,
477
476
  titleFontFamily: PT_SERIF,
478
477
  titleWeight: 'bold',
479
478
  lineHeight: 1.6,
@@ -508,6 +507,78 @@ const warmEarth: Theme = {
508
507
  },
509
508
  };
510
509
 
510
+ // ============================================
511
+ // 9. Gezellig
512
+ // ============================================
513
+
514
+ /**
515
+ * Warm sage + terracotta palette that matches the Gezel desktop app.
516
+ * Background is a subtly orange-tinted near-black — a much warmer surface
517
+ * than the standard `#1a202c`-family dark themes. Primary is Gezel's
518
+ * accent orange (`#b0724c`); secondary is the titlebar sage (`#667f62`).
519
+ *
520
+ * Typography uses system fonts with emoji fallbacks at the tail of each
521
+ * stack — `OpenMoji Color` first (picked up when the host app has loaded
522
+ * its WOFF with `unicode-range` scoped to emoji codepoints), then the
523
+ * platform emoji fonts. Hosts that haven't registered OpenMoji fall
524
+ * through to the platform emoji font transparently.
525
+ *
526
+ * "Gezellig" — Dutch, roughly "cozy / companionable", which is the brand
527
+ * direction of the Gezel app (an ensemble of AI gezels that feel like a
528
+ * crew, not a robot). Works as a general-purpose warm dark theme outside
529
+ * the app too.
530
+ */
531
+ const GEZELLIG_SANS =
532
+ 'system-ui, -apple-system, "Segoe UI", Roboto, "OpenMoji Color", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif';
533
+ const GEZELLIG_SERIF =
534
+ 'Georgia, "Times New Roman", "OpenMoji Color", "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", serif';
535
+
536
+ const gezellig: Theme = {
537
+ id: 'gezellig',
538
+ name: 'Gezellig',
539
+ description:
540
+ 'Warm sage + terracotta palette inspired by the Gezel desktop app. Subtle orange-tinted dark background, cream text, sage and terracotta accents.',
541
+ colors: {
542
+ primary: '#b0724c',
543
+ secondary: '#667f62',
544
+ background: '#1f1a17',
545
+ backgroundLight: '#2b2420',
546
+ text: '#f3ede0',
547
+ textMuted: '#a89b85',
548
+ highlight: '#c0875d',
549
+ warning: '#e07a6b',
550
+ },
551
+ typography: {
552
+ bodyFontFamily: GEZELLIG_SANS,
553
+ titleFontFamily: GEZELLIG_SERIF,
554
+ monoFontFamily: SYSTEM_MONO,
555
+ titleWeight: 'bold',
556
+ lineHeight: 1.55,
557
+ },
558
+ style: {
559
+ textShadow: false,
560
+ overlayOpacity: 0.4,
561
+ animationSpeed: 1.0,
562
+ borderRadius: 6,
563
+ },
564
+ renderStyle: {
565
+ name: 'gezellig',
566
+ defaultTextAnimation: 'fadeIn',
567
+ defaultImageAnimation: 'slowZoom',
568
+ ambientMotion: false,
569
+ defaultTransition: { type: 'fade', duration: 0.6 },
570
+ },
571
+ colorSchemes: {
572
+ // Warm, earthen palette with a sage-green outlier for contrast.
573
+ terracotta: { bg: '#3d2620', text: '#f3ede0', accent: '#c0875d' },
574
+ sage: { bg: '#28332a', text: '#e3ead9', accent: '#8aa589' },
575
+ amber: { bg: '#3d2e17', text: '#fbe3a8', accent: '#d4a149' },
576
+ plum: { bg: '#2e1f2a', text: '#e8d4e0', accent: '#b088a5' },
577
+ ink: { bg: '#1a1f26', text: '#d5dde8', accent: '#7b8fa8' },
578
+ cream: { bg: '#f3ede0', text: '#2b2420', accent: '#b0724c' },
579
+ },
580
+ };
581
+
511
582
  // ============================================
512
583
  // Theme Library
513
584
  // ============================================
@@ -525,6 +596,7 @@ export const THEMES: Record<string, Theme> = {
525
596
  magazine,
526
597
  cinematic,
527
598
  'warm-earth': warmEarth,
599
+ gezellig,
528
600
  };
529
601
 
530
602
  /** The default theme id. */
@@ -120,7 +120,15 @@ export class MemoryContentContainer implements ContentContainer {
120
120
  }
121
121
 
122
122
  async writeFile(path: string, data: ArrayBuffer | Uint8Array, mimeType?: string): Promise<void> {
123
- const buffer = data instanceof ArrayBuffer ? data : data.slice().buffer;
123
+ // Copy to a standalone ArrayBuffer. Uint8Array/Buffer may share a larger
124
+ // backing buffer (e.g., Node's buffer pool), so we must slice to the
125
+ // exact byte range to avoid storing stale pool data.
126
+ let buffer: ArrayBuffer;
127
+ if (data instanceof ArrayBuffer) {
128
+ buffer = data;
129
+ } else {
130
+ buffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
131
+ }
124
132
  this.files.set(path, {
125
133
  data: buffer,
126
134
  mimeType: mimeType ?? guessMimeType(path),
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/storage/MemoryStorageAdapter.ts","../src/storage/LocalStorageAdapter.ts","../src/storage/LocalForageAdapter.ts","../src/storage/ContentContainer.ts","../src/storage/MediaProviderFromContainer.ts"],"sourcesContent":["/**\n * In-memory storage adapter for testing and SSR environments.\n * Data does not persist across page reloads.\n */\n\nimport type { StorageAdapter } from './Storage.js';\n\nexport class MemoryStorageAdapter implements StorageAdapter {\n readonly supportsEnumeration = true;\n private store = new Map<string, string>();\n\n async get<T>(key: string): Promise<T | null> {\n const val = this.store.get(key);\n if (val === undefined) return null;\n try {\n return JSON.parse(val) as T;\n } catch {\n return null;\n }\n }\n\n async set<T>(key: string, value: T): Promise<void> {\n this.store.set(key, JSON.stringify(value));\n }\n\n async remove(key: string): Promise<void> {\n this.store.delete(key);\n }\n\n async clear(): Promise<void> {\n this.store.clear();\n }\n\n async keys(): Promise<string[]> {\n return Array.from(this.store.keys());\n }\n}\n","/**\n * localStorage adapter for web browsers.\n * Supports a configurable key prefix to namespace stored data.\n */\n\nimport type { StorageAdapter } from './Storage.js';\n\nexport class LocalStorageAdapter implements StorageAdapter {\n readonly supportsEnumeration = true;\n private prefix: string;\n\n constructor(prefix = '') {\n this.prefix = prefix;\n }\n\n async get<T>(key: string): Promise<T | null> {\n try {\n const raw = localStorage.getItem(this.prefix + key);\n if (raw === null) return null;\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n }\n\n async set<T>(key: string, value: T): Promise<void> {\n localStorage.setItem(this.prefix + key, JSON.stringify(value));\n }\n\n async remove(key: string): Promise<void> {\n localStorage.removeItem(this.prefix + key);\n }\n\n async clear(): Promise<void> {\n const keysToRemove: string[] = [];\n for (let i = 0; i < localStorage.length; i++) {\n const k = localStorage.key(i);\n if (k && k.startsWith(this.prefix)) {\n keysToRemove.push(k);\n }\n }\n for (const k of keysToRemove) {\n localStorage.removeItem(k);\n }\n }\n\n async keys(): Promise<string[]> {\n const result: string[] = [];\n for (let i = 0; i < localStorage.length; i++) {\n const k = localStorage.key(i);\n if (k && k.startsWith(this.prefix)) {\n result.push(k.slice(this.prefix.length));\n }\n }\n return result;\n }\n}\n","/**\n * LocalForage adapter - IndexedDB storage with automatic fallbacks.\n *\n * Uses localforage which provides IndexedDB storage with automatic\n * fallbacks to WebSQL and localStorage. This allows storing much more\n * data than raw localStorage (which is limited to ~5MB).\n *\n * Unlike LocalStorageAdapter, this adapter can store binary data\n * (ArrayBuffer, Blob, Uint8Array) natively without JSON serialization.\n *\n * Ported from qualla-internal/shared/services/StorageAdapters.ts with\n * configurable store name and key prefix.\n */\n\nimport localforage from 'localforage';\nimport type { StorageAdapter } from './Storage.js';\n\nexport interface LocalForageAdapterOptions {\n /** IndexedDB database name (default: 'squisq') */\n name?: string;\n /** IndexedDB object store name (default: 'data') */\n storeName?: string;\n /** Key prefix for namespacing (default: '') */\n prefix?: string;\n /** Human-readable description (default: 'Squisq storage') */\n description?: string;\n}\n\nexport class LocalForageAdapter implements StorageAdapter {\n readonly supportsEnumeration = true;\n private store: LocalForage;\n private prefix: string;\n\n constructor(options: LocalForageAdapterOptions = {}) {\n const {\n name = 'squisq',\n storeName = 'data',\n prefix = '',\n description = 'Squisq storage',\n } = options;\n\n this.prefix = prefix;\n this.store = localforage.createInstance({ name, storeName, description });\n }\n\n async get<T>(key: string): Promise<T | null> {\n try {\n const value = await this.store.getItem<T>(this.prefix + key);\n return value;\n } catch (e: unknown) {\n console.warn('[Storage] get (IndexedDB) failed:', key, e);\n return null;\n }\n }\n\n async set<T>(key: string, value: T): Promise<void> {\n try {\n await this.store.setItem(this.prefix + key, value);\n } catch (e: unknown) {\n console.error('[Storage] set (IndexedDB) failed:', key, e);\n throw e;\n }\n }\n\n async remove(key: string): Promise<void> {\n try {\n await this.store.removeItem(this.prefix + key);\n } catch (e: unknown) {\n console.warn('[Storage] remove (IndexedDB) failed:', key, e);\n }\n }\n\n async clear(): Promise<void> {\n try {\n if (this.prefix) {\n // Only remove keys with our prefix\n const keys = await this.store.keys();\n const keysToRemove = keys.filter((k) => k.startsWith(this.prefix));\n await Promise.all(keysToRemove.map((k) => this.store.removeItem(k)));\n } else {\n // No prefix — clear the entire store\n await this.store.clear();\n }\n } catch (e: unknown) {\n console.error('[Storage] clear (IndexedDB) failed:', e);\n }\n }\n\n async keys(): Promise<string[]> {\n try {\n const allKeys = await this.store.keys();\n if (this.prefix) {\n return allKeys\n .filter((k) => k.startsWith(this.prefix))\n .map((k) => k.slice(this.prefix.length));\n }\n return allKeys;\n } catch (e: unknown) {\n console.warn('[Storage] keys (IndexedDB) failed:', e);\n return [];\n }\n }\n}\n","/**\n * ContentContainer — virtual file system for document containers.\n *\n * A Squisq document is more than just markdown: it's a container of files\n * including the primary markdown document, images, audio, timing data, and\n * other media. ContentContainer provides an abstract async file system\n * interface for reading, writing, and listing these files.\n *\n * Paths are forward-slash separated strings relative to the container root\n * (e.g., 'images/hero.jpg', 'index.md'). No leading slash.\n *\n * Implementations:\n * - MemoryContentContainer — in-memory Map (for zip import, tests, transient use)\n * - SlotContentContainer — backed by IndexedDB slot storage (in the site package)\n */\n\n/**\n * Metadata about a file in a ContentContainer.\n */\nexport interface ContentEntry {\n /** Relative path within the container (e.g., 'images/hero.jpg') */\n path: string;\n /** MIME type (e.g., 'image/jpeg') */\n mimeType: string;\n /** File size in bytes */\n size: number;\n}\n\n/**\n * Abstract async file system for document containers.\n *\n * All paths are forward-slash separated, relative to the container root,\n * with no leading slash. Example: 'images/hero.jpg', 'index.md'.\n */\nexport interface ContentContainer {\n /** Read a file's binary content. Returns null if the file does not exist. */\n readFile(path: string): Promise<ArrayBuffer | null>;\n\n /** Write a file. Creates or overwrites. */\n writeFile(path: string, data: ArrayBuffer | Uint8Array, mimeType?: string): Promise<void>;\n\n /** Remove a file. No-op if the file does not exist. */\n removeFile(path: string): Promise<void>;\n\n /**\n * List files in the container.\n * @param prefix — Optional path prefix to filter by (e.g., 'images/')\n */\n listFiles(prefix?: string): Promise<ContentEntry[]>;\n\n /** Check whether a file exists. */\n exists(path: string): Promise<boolean>;\n\n /**\n * Find the primary markdown document path.\n *\n * Discovery order: index.md → doc.md → document.md → first *.md at root.\n * Returns null if no markdown file is found at the root level.\n */\n getDocumentPath(): Promise<string | null>;\n\n /**\n * Convenience: read the primary markdown document as a UTF-8 string.\n * Returns null if no markdown file is found.\n */\n readDocument(): Promise<string | null>;\n\n /**\n * Convenience: write a markdown document.\n * @param markdown — The markdown content\n * @param filename — Filename to use (defaults to 'index.md')\n */\n writeDocument(markdown: string, filename?: string): Promise<void>;\n}\n\n// ============================================\n// Well-known markdown filenames in priority order\n// ============================================\n\nconst MARKDOWN_PRIORITY = ['index.md', 'doc.md', 'document.md'];\n\n/**\n * Find the primary markdown path from a list of file entries.\n * Exported for reuse by other ContentContainer implementations.\n */\nexport function findDocumentPath(entries: ContentEntry[]): string | null {\n // Only consider root-level files (no '/' in path)\n const rootFiles = entries.filter((e) => !e.path.includes('/'));\n\n for (const name of MARKDOWN_PRIORITY) {\n if (rootFiles.some((e) => e.path.toLowerCase() === name)) {\n return name;\n }\n }\n\n // Fallback: first .md file at root\n const firstMd = rootFiles.find((e) => e.path.toLowerCase().endsWith('.md'));\n return firstMd?.path ?? null;\n}\n\n// ============================================\n// MemoryContentContainer\n// ============================================\n\ninterface MemoryFile {\n data: ArrayBuffer;\n mimeType: string;\n}\n\n/**\n * In-memory ContentContainer backed by a Map.\n *\n * Used for zip import (deserialize into memory), tests, and transient operations.\n */\nexport class MemoryContentContainer implements ContentContainer {\n private files = new Map<string, MemoryFile>();\n\n async readFile(path: string): Promise<ArrayBuffer | null> {\n return this.files.get(path)?.data ?? null;\n }\n\n async writeFile(path: string, data: ArrayBuffer | Uint8Array, mimeType?: string): Promise<void> {\n const buffer = data instanceof ArrayBuffer ? data : data.slice().buffer;\n this.files.set(path, {\n data: buffer,\n mimeType: mimeType ?? guessMimeType(path),\n });\n }\n\n async removeFile(path: string): Promise<void> {\n this.files.delete(path);\n }\n\n async listFiles(prefix?: string): Promise<ContentEntry[]> {\n const entries: ContentEntry[] = [];\n for (const [path, file] of this.files) {\n if (prefix && !path.startsWith(prefix)) continue;\n entries.push({\n path,\n mimeType: file.mimeType,\n size: file.data.byteLength,\n });\n }\n return entries;\n }\n\n async exists(path: string): Promise<boolean> {\n return this.files.has(path);\n }\n\n async getDocumentPath(): Promise<string | null> {\n return findDocumentPath(await this.listFiles());\n }\n\n async readDocument(): Promise<string | null> {\n const docPath = await this.getDocumentPath();\n if (!docPath) return null;\n const data = await this.readFile(docPath);\n if (!data) return null;\n return new TextDecoder().decode(data);\n }\n\n async writeDocument(markdown: string, filename?: string): Promise<void> {\n const name = filename ?? 'index.md';\n const data = new TextEncoder().encode(markdown);\n await this.writeFile(name, data, 'text/markdown');\n }\n}\n\n// ============================================\n// MIME type guessing\n// ============================================\n\nconst EXTENSION_MIME_MAP: Record<string, string> = {\n '.md': 'text/markdown',\n '.txt': 'text/plain',\n '.json': 'application/json',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.png': 'image/png',\n '.gif': 'image/gif',\n '.svg': 'image/svg+xml',\n '.webp': 'image/webp',\n '.avif': 'image/avif',\n '.mp4': 'video/mp4',\n '.webm': 'video/webm',\n '.mp3': 'audio/mpeg',\n '.wav': 'audio/wav',\n '.ogg': 'audio/ogg',\n '.css': 'text/css',\n '.html': 'text/html',\n '.js': 'application/javascript',\n};\n\nfunction guessMimeType(path: string): string {\n const dot = path.lastIndexOf('.');\n if (dot === -1) return 'application/octet-stream';\n const ext = path.slice(dot).toLowerCase();\n return EXTENSION_MIME_MAP[ext] ?? 'application/octet-stream';\n}\n","/**\n * MediaProviderFromContainer — bridges ContentContainer to MediaProvider.\n *\n * Creates a MediaProvider that resolves relative paths by reading binary data\n * from a ContentContainer and generating blob URLs. Blob URLs are cached and\n * revoked on dispose().\n *\n * This allows any ContentContainer (memory, slot-backed, zip-loaded) to be\n * used with existing rendering components (DocPlayer, ImageLayer, VideoLayer)\n * that consume MediaProvider.\n */\n\nimport type { MediaProvider, MediaEntry } from '../schemas/MediaProvider.js';\nimport type { ContentContainer } from './ContentContainer.js';\n\n/**\n * Create a MediaProvider backed by a ContentContainer.\n *\n * @param container — The ContentContainer to read/write media from\n * @returns A MediaProvider that resolves paths to blob URLs\n */\nexport function createMediaProviderFromContainer(container: ContentContainer): MediaProvider {\n const blobUrlCache = new Map<string, string>();\n\n return {\n async resolveUrl(relativePath: string): Promise<string> {\n const cached = blobUrlCache.get(relativePath);\n if (cached) return cached;\n\n const data = await container.readFile(relativePath);\n if (!data) return relativePath;\n\n const entries = await container.listFiles();\n const entry = entries.find((e) => e.path === relativePath);\n const mimeType = entry?.mimeType ?? 'application/octet-stream';\n\n const blob = new Blob([data], { type: mimeType });\n const url = URL.createObjectURL(blob);\n blobUrlCache.set(relativePath, url);\n return url;\n },\n\n async listMedia(): Promise<MediaEntry[]> {\n const entries = await container.listFiles();\n return entries\n .filter((e) => !e.path.toLowerCase().endsWith('.md'))\n .map((e) => ({\n name: e.path,\n mimeType: e.mimeType,\n size: e.size,\n }));\n },\n\n async addMedia(\n name: string,\n data: ArrayBuffer | Blob | Uint8Array,\n mimeType: string,\n ): Promise<string> {\n // Invalidate any cached blob URL for this path before overwriting\n const cached = blobUrlCache.get(name);\n if (cached) {\n URL.revokeObjectURL(cached);\n blobUrlCache.delete(name);\n }\n\n let buffer: ArrayBuffer | Uint8Array;\n if (data instanceof Blob) {\n buffer = await data.arrayBuffer();\n } else {\n buffer = data;\n }\n await container.writeFile(name, buffer, mimeType);\n return name;\n },\n\n async removeMedia(relativePath: string): Promise<void> {\n const cached = blobUrlCache.get(relativePath);\n if (cached) {\n URL.revokeObjectURL(cached);\n blobUrlCache.delete(relativePath);\n }\n await container.removeFile(relativePath);\n },\n\n dispose(): void {\n for (const url of blobUrlCache.values()) {\n URL.revokeObjectURL(url);\n }\n blobUrlCache.clear();\n },\n };\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/schemas/Doc.ts","../src/schemas/Theme.ts"],"sourcesContent":["/**\n * Doc Schema\n *\n * Defines the JSON format for AI-scriptable visual docs that accompany\n * audio narration. A Doc describes a sequence of animated blocks\n * with text overlays, images, and transitions synchronized to audio.\n *\n * The format is designed to be:\n * 1. AI-generatable - Claude can create scripts from article content\n * 2. Browser-renderable - SVG + CSS animations (ES2015 compatible)\n * 3. Video-exportable - Frame-by-frame capture via Playwright\n *\n * Timing: Blocks are timed to match narration audio segments. Each article\n * has 3-6 MP3 files (intro + sections), and blocks are grouped by segment.\n */\n\n// ============================================\n// Core Types\n// ============================================\n\n/**\n * Configuration for the Start/resting block shown before playback begins.\n */\nexport interface StartBlockConfig {\n /** Hero image path (relative to article media) */\n heroSrc: string;\n /** Alt text for the hero image */\n heroAlt?: string;\n /** Title to display over the hero */\n title: string;\n /** Optional subtitle */\n subtitle?: string;\n /** Ambient motion for the hero image */\n ambientMotion?: 'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight';\n /** Photo credit / artist name for the hero image */\n heroCredit?: string;\n /** License identifier for the hero image */\n heroLicense?: string;\n}\n\n/**\n * A complete visual doc for an article.\n */\nexport interface Doc {\n /** Article ID this doc belongs to */\n articleId: string;\n\n /** Total duration in seconds (sum of all audio segments) */\n duration: number;\n\n /** Ordered list of blocks */\n blocks: Block[];\n\n /** Audio track configuration */\n audio: AudioTrack;\n\n /** Caption track for closed captions (generated by postprocess-doc) */\n captions?: CaptionTrack;\n\n /**\n * Start/resting block shown before playback begins.\n * Displays hero image with title - acts as a \"poster\" for the doc.\n * The player shows this until the user starts playback.\n */\n startBlock?: StartBlockConfig;\n\n /**\n * Persistent layers rendered on every block (behind and/or on top of content).\n * Used for consistent branding overlays, background gradients, etc.\n */\n persistentLayers?: import('./BlockTemplates.js').PersistentLayerConfig;\n\n /**\n * Optional theme identifier. Resolved at render time via `resolveTheme()`.\n * When omitted, the renderer uses the default theme ('documentary').\n */\n themeId?: string;\n\n /** Optional metadata */\n meta?: {\n generatedAt?: string;\n generatedBy?: string;\n version?: number;\n };\n\n /**\n * YAML frontmatter from the source markdown.\n * Carries rendering hints like `document-render-as` and custom metadata.\n */\n frontmatter?: Record<string, unknown>;\n}\n\n/**\n * A single block in the doc.\n *\n * Blocks can be flat (audio-synced timeline) or nested (markdown-driven hierarchy).\n * When derived from markdown, a block corresponds to a heading section:\n * - `sourceHeading` references the original MarkdownHeading node\n * - `contents` holds the body markdown between this heading and the next\n * - `children` holds sub-heading blocks (e.g., H2s under an H1)\n */\nexport interface Block {\n /** Unique identifier for this block */\n id: string;\n\n /** When this block appears (seconds from start) */\n startTime: number;\n\n /** How long this block is visible (seconds) */\n duration: number;\n\n /** Which audio segment this block belongs to (0-indexed) */\n audioSegment: number;\n\n /**\n * Pre-computed visual layers, rendered back-to-front.\n *\n * Optional — template-derived blocks typically omit this and compute\n * layers on demand via `getLayers()` from `@bendyline/squisq/doc`.\n * Raw blocks (e.g., hand-crafted by AI) may provide layers directly.\n */\n layers?: Layer[];\n\n /** Entry transition from previous block */\n transition?: Transition;\n\n /** Template name that generated this block (for debugging) */\n template?: string;\n\n /**\n * Display title for template rendering.\n * Extracted from the sourceHeading when the block is created by markdownToDoc().\n * Templates like sectionHeader and titleBlock read this for their title layer.\n */\n title?: string;\n\n // ── Markdown-driven hierarchy (optional) ──\n\n /**\n * Nested sub-blocks (heading hierarchy).\n * When a doc is derived from markdown, sub-headings within this\n * heading's section become child blocks. For example, H2 blocks\n * under an H1 block appear here.\n */\n children?: Block[];\n\n /**\n * Markdown body content for this block's section.\n * Contains the block-level markdown nodes (paragraphs, lists, code,\n * blockquotes, etc.) that appear between this heading and the next\n * heading or sub-heading. Empty for pure structural blocks.\n */\n contents?: import('../markdown/types.js').MarkdownBlockNode[];\n\n /**\n * Reference to the MarkdownHeading node that created this block.\n * Present when the block was derived from markdown via markdownToDoc().\n * Enables round-tripping back to markdown and provides heading depth.\n * Absent for the preamble block (content before the first heading).\n */\n sourceHeading?: import('../markdown/types.js').MarkdownHeading;\n\n /**\n * Template overrides extracted from markdown directives.\n * For example, `### Data Section {template=chart colorScheme=blue}`\n * would produce `{ template: 'chart', colorScheme: 'blue' }`.\n * Allows per-block customization of template selection and parameters.\n */\n templateOverrides?: Record<string, string>;\n}\n\n// ============================================\n// Layer Types\n// ============================================\n\n/**\n * A visual element within a block.\n * Layers are composited back-to-front (first layer is background).\n */\nexport type Layer = ImageLayer | TextLayer | ShapeLayer | MapLayer | VideoLayer | TableLayer;\n\ninterface BaseLayer {\n /** Unique identifier for this layer */\n id: string;\n\n /** Position and size */\n position: Position;\n\n /** Animation to apply */\n animation?: Animation;\n}\n\n/**\n * Image layer - displays an image with optional Ken Burns effect.\n */\nexport interface ImageLayer extends BaseLayer {\n type: 'image';\n content: {\n /** Path to image file (relative to article media dir) */\n src: string;\n /** Alt text for accessibility */\n alt: string;\n /** How to fit image in bounds */\n fit?: 'cover' | 'contain' | 'fill';\n /** Photo credit / artist name */\n credit?: string;\n /** License identifier (e.g., 'CC BY-SA 4.0') */\n license?: string;\n };\n}\n\n/**\n * Text layer - displays text with styling.\n */\nexport interface TextLayer extends BaseLayer {\n type: 'text';\n content: {\n /** Text to display (supports \\n for line breaks) */\n text: string;\n /** Text styling */\n style: TextStyle;\n };\n}\n\n/**\n * Shape layer - simple geometric shapes for visual accents.\n */\nexport interface ShapeLayer extends BaseLayer {\n type: 'shape';\n content: {\n /** Shape type */\n shape: 'rect' | 'circle' | 'line';\n /** Fill color (CSS color or 'none') */\n fill?: string;\n /** Stroke color */\n stroke?: string;\n /** Stroke width in pixels */\n strokeWidth?: number;\n /** Corner radius for rect */\n borderRadius?: number;\n };\n}\n\n/**\n * Map layer - displays a geographic map with optional markers.\n *\n * Maps are rendered as composite tile images from free tile providers.\n * Supported styles use open-source/free tiles requiring attribution.\n */\nexport interface MapLayer extends BaseLayer {\n type: 'map';\n content: {\n /** Center coordinates for the map */\n center: {\n lat: number;\n lng: number;\n };\n /** Zoom level (0-18, typically 8-14 for doc blocks) */\n zoom: number;\n /** Map tile style */\n style: MapTileStyle;\n /** Optional markers to display on the map */\n markers?: MapMarker[];\n /** Pre-rendered static image path (for video export reliability) */\n staticSrc?: string;\n /** Show attribution text (default: true) */\n showAttribution?: boolean;\n };\n}\n\n/**\n * Video layer - displays a short video clip, always muted (narration provides audio).\n *\n * Rendered via an HTML5 <video> element inside <foreignObject>, similar to how\n * ImageLayer uses <img> for Ken Burns effects. clipStart/clipEnd define the window\n * within the source video to play. During Playwright frame capture (seekTo mode),\n * the video is seeked programmatically rather than playing in real time.\n */\nexport interface VideoLayer extends BaseLayer {\n type: 'video';\n content: {\n /** Path to video file (relative to article media dir) */\n src: string;\n /** Path to poster frame (shown before video loads / during seek) */\n posterSrc?: string;\n /** Alt text for accessibility */\n alt: string;\n /** How to fit video in bounds */\n fit?: 'cover' | 'contain' | 'fill';\n /** Start time within the source video (seconds) */\n clipStart: number;\n /** End time within the source video (seconds) */\n clipEnd: number;\n /** Total source video duration (for validation) */\n sourceDuration?: number;\n /** Video credit / artist name */\n credit?: string;\n /** License identifier (e.g., 'CC BY-SA 4.0') */\n license?: string;\n };\n}\n\n/**\n * Table layer - renders a data table with themed styling.\n * Uses foreignObject inside SVG for HTML-based table layout.\n */\nexport interface TableLayer extends BaseLayer {\n type: 'table';\n content: {\n /** Header cell values */\n headers: string[];\n /** Data rows (array of cell value arrays) */\n rows: string[][];\n /** Per-column alignment */\n align?: (('left' | 'right' | 'center') | null)[];\n /** Visual styling */\n style: TableLayerStyle;\n };\n}\n\n/**\n * Styling options for a TableLayer.\n */\nexport interface TableLayerStyle {\n /** Header row background color */\n headerBackground: string;\n /** Header row text color */\n headerColor: string;\n /** Body cell background color */\n cellBackground: string;\n /** Body cell text color */\n cellColor: string;\n /** Border/divider color */\n borderColor: string;\n /** Font size in pixels */\n fontSize: number;\n /** Font family */\n fontFamily?: string;\n /** Header font family (falls back to fontFamily) */\n headerFontFamily?: string;\n /** Corner radius for the table container */\n borderRadius?: number;\n}\n\n/**\n * Available map tile styles from free/open-source providers.\n */\nexport type MapTileStyle =\n | 'terrain' // OpenTopoMap - topographic/terrain (default)\n | 'satellite' // ESRI World Imagery\n | 'road' // OpenStreetMap standard\n | 'toner' // Stadia Toner (high contrast B&W)\n | 'watercolor'; // Stadia Watercolor (artistic)\n\n/**\n * A marker to display on the map.\n */\nexport interface MapMarker {\n /** Marker latitude */\n lat: number;\n /** Marker longitude */\n lng: number;\n /** Optional label text */\n label?: string;\n /** Marker color (CSS color, default: #ef4444) */\n color?: string;\n /** Marker icon type */\n icon?: 'pin' | 'circle' | 'star';\n}\n\n// ============================================\n// Position & Styling\n// ============================================\n\n/**\n * Position and dimensions for a layer.\n * Values can be pixels (number) or percentages (string like \"50%\").\n */\nexport interface Position {\n /** X position (pixels or percentage) */\n x: number | string;\n /** Y position (pixels or percentage) */\n y: number | string;\n /** Width (pixels or percentage) */\n width?: number | string;\n /** Height (pixels or percentage) */\n height?: number | string;\n /** Anchor point for positioning */\n anchor?: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';\n}\n\n/**\n * Text styling options.\n */\nexport interface TextStyle {\n /** Font size in pixels */\n fontSize: number;\n /** Font family (defaults to system sans-serif) */\n fontFamily?: string;\n /** Font weight */\n fontWeight?: 'normal' | 'bold';\n /** Text color (CSS color) */\n color: string;\n /** Text alignment */\n textAlign?: 'left' | 'center' | 'right';\n /** Line height multiplier */\n lineHeight?: number;\n /** Add drop shadow for readability over images */\n shadow?: boolean;\n /** Background color for text box */\n background?: string;\n /** Padding around text (pixels) */\n padding?: number;\n /** Maximum number of lines before truncation (adds \"...\" to last line) */\n maxLines?: number;\n}\n\n// ============================================\n// Animation & Transitions\n// ============================================\n\n/**\n * Animation to apply to a layer.\n */\nexport interface Animation {\n /** Animation type */\n type: AnimationType;\n /** Duration in seconds (defaults to layer's block duration) */\n duration?: number;\n /** Delay before animation starts (seconds) */\n delay?: number;\n /** CSS easing function */\n easing?: string;\n /** For Ken Burns: zoom direction */\n direction?: 'in' | 'out';\n /** For pan animations: pan direction */\n panDirection?: 'left' | 'right' | 'up' | 'down';\n}\n\n/**\n * Available animation types.\n */\nexport type AnimationType =\n | 'none'\n | 'fadeIn'\n | 'fadeOut'\n | 'slowZoom' // Slow zoom with optional pan\n | 'zoomIn'\n | 'zoomOut'\n | 'panLeft'\n | 'panRight'\n | 'typewriter'; // Text appears letter by letter\n\n/**\n * Transition between blocks.\n */\nexport interface Transition {\n /** Transition type */\n type: TransitionType;\n /** Duration in seconds */\n duration: number;\n}\n\nexport type TransitionType =\n | 'cut' // Instant switch\n | 'fade' // Cross-fade\n | 'dissolve' // Soft dissolve\n | 'slideLeft' // New block enters from right\n | 'slideRight'; // New block enters from left\n\n// ============================================\n// Audio Configuration\n// ============================================\n\n/**\n * Audio track configuration.\n * Articles have multiple MP3 segments (intro + sections).\n */\nexport interface AudioTrack {\n /** Audio segments in playback order */\n segments: AudioSegment[];\n}\n\n// ============================================\n// Captions\n// ============================================\n\n/**\n * A single word with precise timing, used for word-level highlighting\n * in social-style captions. Populated from TTS timing data when available.\n */\nexport interface CaptionWord {\n /** The word text */\n text: string;\n /** Start time in seconds (relative to doc start) */\n startTime: number;\n /** End time in seconds */\n endTime: number;\n}\n\n/**\n * A single caption phrase to display during playback.\n */\nexport interface CaptionPhrase {\n /** The text to display */\n text: string;\n /** Start time in seconds (relative to doc start) */\n startTime: number;\n /** End time in seconds */\n endTime: number;\n /** Which audio segment this caption belongs to (0-indexed) */\n audioSegment: number;\n /**\n * Optional per-word timing from TTS timing data.\n * When present, enables precise word-level highlighting in social\n * caption style. When absent, word timing is interpolated evenly\n * across the phrase duration.\n */\n words?: CaptionWord[];\n}\n\n/**\n * Caption track for a doc.\n */\nexport interface CaptionTrack {\n /** Caption phrases in chronological order */\n phrases: CaptionPhrase[];\n /** When the captions were generated */\n generatedAt: string;\n /** Algorithm version for regeneration detection */\n version: number;\n}\n\n/**\n * A single audio segment (one MP3 file).\n */\nexport interface AudioSegment {\n /** Path to MP3 file (relative to article media dir) */\n src: string;\n /** Segment name (e.g., \"intro\", \"history\") */\n name: string;\n /** Duration in seconds */\n duration: number;\n /** Start time in overall timeline (calculated) */\n startTime: number;\n}\n\n// ============================================\n// Audio Timing Data (from TTS)\n// ============================================\n\n/**\n * A single word-level timing bookmark from TTS synthesis.\n * Used for precise audio-to-content mapping and word-level caption sync.\n */\nexport interface AudioBookmark {\n /** Unique identifier (e.g., \"word-0\", \"word-1\") */\n id: string;\n /** Timestamp in seconds from audio start */\n time: number;\n /** Character offset in the source text */\n charOffset: number;\n /** The word text at this position */\n textFragment?: string;\n}\n\n/**\n * Timing data for an audio segment, typically from a `.timing.json` file\n * generated alongside TTS audio. Contains the source text and per-word\n * timing bookmarks for precise content matching and caption sync.\n */\nexport interface AudioTimingData {\n /** The normalized plain text that was synthesized */\n sourceText: string;\n /** Word-level timing bookmarks */\n bookmarks: AudioBookmark[];\n /** Total audio duration in seconds */\n duration: number;\n}\n\n// ============================================\n// Helper Functions\n// ============================================\n\n/**\n * Calculate total duration from audio segments.\n */\nexport function calculateDuration(audio: AudioTrack): number {\n return audio.segments.reduce((sum, seg) => sum + seg.duration, 0);\n}\n\n/**\n * Find which audio segment is playing at a given time.\n */\nexport function getSegmentAtTime(audio: AudioTrack, time: number): number {\n let elapsed = 0;\n for (let i = 0; i < audio.segments.length; i++) {\n elapsed += audio.segments[i].duration;\n if (time < elapsed) return i;\n }\n return audio.segments.length - 1;\n}\n\n/**\n * Find which block should be visible at a given time.\n */\nexport function getBlockAtTime(blocks: Block[], time: number): Block | null {\n for (let i = blocks.length - 1; i >= 0; i--) {\n const block = blocks[i];\n if (time >= block.startTime && time < block.startTime + block.duration) {\n return block;\n }\n }\n return blocks[0] || null;\n}\n\n/**\n * Find the caption phrase that should be displayed at a given time.\n */\nexport function getCaptionAtTime(\n captions: CaptionTrack | undefined,\n time: number,\n): CaptionPhrase | null {\n if (!captions || !captions.phrases.length) return null;\n\n for (const phrase of captions.phrases) {\n if (time >= phrase.startTime && time < phrase.endTime) {\n return phrase;\n }\n }\n return null;\n}\n\n/**\n * Validate a Doc for completeness.\n */\nexport function validateDoc(script: Doc): string[] {\n const errors: string[] = [];\n\n if (!script.articleId) {\n errors.push('Missing articleId');\n }\n\n if (!script.blocks || script.blocks.length === 0) {\n errors.push('No blocks defined');\n }\n\n if (!script.audio || !script.audio.segments || script.audio.segments.length === 0) {\n errors.push('No audio segments defined');\n }\n\n // Check for gaps in block coverage\n const totalDuration = script.duration;\n let covered = 0;\n for (const block of script.blocks) {\n if (block.startTime > covered + 0.1) {\n errors.push(`Gap in blocks: ${covered}s to ${block.startTime}s`);\n }\n covered = Math.max(covered, block.startTime + block.duration);\n }\n\n if (covered < totalDuration - 0.1) {\n errors.push(`Blocks end at ${covered}s but audio is ${totalDuration}s`);\n }\n\n return errors;\n}\n","/**\n * Theme System\n *\n * A Theme bundles color palette, typography, visual style, render-style\n * algorithm, and per-block color schemes into one JSON-serializable object.\n * Builders choose a theme from the built-in library or create a custom one\n * via `createTheme(base, overrides)`.\n *\n * Design principles:\n * - Fully JSON-serializable (no functions) — storable in config / APIs.\n * - Doc carries an optional `themeId` pointer; resolution happens at render time.\n * - `createTheme` deep-merges a base theme with partial overrides.\n */\n\nimport type { LayoutHints } from './LayoutStrategy.js';\nimport type { AnimationType, TransitionType } from './Doc.js';\nimport type { PersistentLayerConfig } from './BlockTemplates.js';\n\n// ============================================\n// Color Palette\n// ============================================\n\n/**\n * Core color palette for a theme. Every color is a CSS color string.\n */\nexport interface ThemeColorPalette {\n /** Primary accent color */\n primary: string;\n /** Secondary accent color */\n secondary: string;\n /** Background color (typically dark) */\n background: string;\n /** Lighter background for contrast panels */\n backgroundLight: string;\n /** Main text color */\n text: string;\n /** Muted/secondary text color */\n textMuted: string;\n /** Highlight/emphasis color */\n highlight: string;\n /** Warning/alert color */\n warning: string;\n}\n\n/**\n * A named color scheme used by templates for per-block color variation\n * (e.g., statHighlight or sectionHeader).\n */\nexport interface ThemeColorScheme {\n /** Background fill */\n bg: string;\n /** Primary text tint */\n text: string;\n /** Accent / secondary tint */\n accent: string;\n}\n\n// ============================================\n// Typography\n// ============================================\n\n/**\n * Typography settings for a theme.\n */\nexport interface ThemeTypography {\n /** Font family for body / description text */\n bodyFontFamily: string;\n /** Font family for titles and headings */\n titleFontFamily: string;\n /** Font family for code / monospaced text (optional) */\n monoFontFamily?: string;\n /** Multiplier applied to LayoutHints.titleScale (default 1.0) */\n titleScale?: number;\n /** Multiplier applied to LayoutHints.bodyScale (default 1.0) */\n bodyScale?: number;\n /** Default body line height (default 1.4) */\n lineHeight?: number;\n /** Default title line height */\n titleLineHeight?: number;\n /** Default title font weight */\n titleWeight?: 'normal' | 'bold';\n}\n\n// ============================================\n// Visual Style\n// ============================================\n\n/**\n * Global visual-style knobs that templates consult.\n */\nexport interface ThemeStyle {\n /** Default border radius for cards / shapes (px) */\n borderRadius?: number;\n /** Whether templates should default to text shadows */\n textShadow?: boolean;\n /** Darkness of overlay on image-backed blocks (0–1) */\n overlayOpacity?: number;\n /** Multiplier on all animation durations (1.0 = normal, <1 faster, >1 slower) */\n animationSpeed?: number;\n /** Default horizontal padding for text (percentage string, e.g. \"5%\") */\n blockPadding?: string;\n}\n\n// ============================================\n// Render Style\n// ============================================\n\n/**\n * Render-style algorithm preset. Controls layout tweaks, default animations,\n * transitions, and per-template behavioral hints.\n */\nexport interface RenderStyle {\n /** Identifier for this algorithmic approach (e.g. \"documentary\", \"magazine\") */\n name: string;\n /** Partial overrides merged onto the orientation-based LayoutHints */\n layoutOverrides?: Partial<LayoutHints>;\n /** Default entrance animation for text layers */\n defaultTextAnimation?: AnimationType;\n /** Default animation for background / image layers */\n defaultImageAnimation?: AnimationType;\n /** Whether to apply Ken Burns ambient motion to images by default */\n ambientMotion?: boolean;\n /** Default block-to-block transition */\n defaultTransition?: { type: TransitionType; duration?: number };\n /**\n * Per-template behavioral hints. Keys are template names, values are\n * string/number/boolean maps that templates can read to vary their output.\n *\n * @example\n * ```\n * { statHighlight: { entrance: 'dramatic' }, titleBlock: { showAccentLine: false } }\n * ```\n */\n templateHints?: Record<string, Record<string, string | number | boolean>>;\n}\n\n// ============================================\n// Theme\n// ============================================\n\n/**\n * A complete, JSON-serializable theme definition.\n */\nexport interface Theme {\n /** Unique identifier (e.g. \"documentary\") */\n id: string;\n /** Human-readable display name */\n name: string;\n /** Short description for theme pickers */\n description?: string;\n /** Color palette */\n colors: ThemeColorPalette;\n /** Typography settings */\n typography: ThemeTypography;\n /** Global visual-style knobs */\n style: ThemeStyle;\n /** Algorithmic render-style preset */\n renderStyle: RenderStyle;\n /**\n * Named color schemes for per-block color variation.\n * Templates reference these by name (e.g. \"blue\", \"warm\").\n */\n colorSchemes: Record<string, ThemeColorScheme>;\n /** Optional persistent layers baked into the theme */\n persistentLayers?: PersistentLayerConfig;\n}\n\n// ============================================\n// Deep-Partial helper type\n// ============================================\n\n/**\n * Recursively makes every property optional.\n */\nexport type DeepPartial<T> = {\n [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];\n};\n\n// ============================================\n// Helpers\n// ============================================\n\n/**\n * Deep-merge `source` into `target`, returning a new object.\n * Arrays are replaced wholesale (not concatenated).\n */\nfunction deepMerge<T extends Record<string, unknown>>(target: T, source: DeepPartial<T>): T {\n const result = { ...target } as Record<string, unknown>;\n for (const key of Object.keys(source)) {\n const srcVal = (source as Record<string, unknown>)[key];\n const tgtVal = (target as Record<string, unknown>)[key];\n if (\n srcVal !== null &&\n srcVal !== undefined &&\n typeof srcVal === 'object' &&\n !Array.isArray(srcVal) &&\n tgtVal !== null &&\n tgtVal !== undefined &&\n typeof tgtVal === 'object' &&\n !Array.isArray(tgtVal)\n ) {\n result[key] = deepMerge(\n tgtVal as Record<string, unknown>,\n srcVal as DeepPartial<Record<string, unknown>>,\n );\n } else if (srcVal !== undefined) {\n result[key] = srcVal;\n }\n }\n return result as T;\n}\n\n/**\n * Create a new theme by deep-merging overrides onto a base theme.\n * The returned theme gets its own `id` if one is provided in overrides,\n * otherwise keeps the base theme's `id` suffixed with \"-custom\".\n *\n * @example\n * ```ts\n * const myTheme = createTheme(THEMES.documentary, {\n * colors: { primary: '#ff0000' },\n * typography: { bodyFontFamily: '\"Inter\", sans-serif' },\n * });\n * ```\n */\nexport function createTheme(base: Theme, overrides: DeepPartial<Theme>): Theme {\n const merged = deepMerge(\n base as unknown as Record<string, unknown>,\n overrides as DeepPartial<Record<string, unknown>>,\n ) as unknown as Theme;\n if (!overrides.id && merged.id === base.id) {\n merged.id = `${base.id}-custom`;\n }\n return merged;\n}\n"],"mappings":";AA2kBO,SAAS,kBAAkB,OAA2B;AAC3D,SAAO,MAAM,SAAS,OAAO,CAAC,KAAK,QAAQ,MAAM,IAAI,UAAU,CAAC;AAClE;AAKO,SAAS,iBAAiB,OAAmB,MAAsB;AACxE,MAAI,UAAU;AACd,WAAS,IAAI,GAAG,IAAI,MAAM,SAAS,QAAQ,KAAK;AAC9C,eAAW,MAAM,SAAS,CAAC,EAAE;AAC7B,QAAI,OAAO,QAAS,QAAO;AAAA,EAC7B;AACA,SAAO,MAAM,SAAS,SAAS;AACjC;AAKO,SAAS,eAAe,QAAiB,MAA4B;AAC1E,WAAS,IAAI,OAAO,SAAS,GAAG,KAAK,GAAG,KAAK;AAC3C,UAAM,QAAQ,OAAO,CAAC;AACtB,QAAI,QAAQ,MAAM,aAAa,OAAO,MAAM,YAAY,MAAM,UAAU;AACtE,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO,OAAO,CAAC,KAAK;AACtB;AAKO,SAAS,iBACd,UACA,MACsB;AACtB,MAAI,CAAC,YAAY,CAAC,SAAS,QAAQ,OAAQ,QAAO;AAElD,aAAW,UAAU,SAAS,SAAS;AACrC,QAAI,QAAQ,OAAO,aAAa,OAAO,OAAO,SAAS;AACrD,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;AAKO,SAAS,YAAY,QAAuB;AACjD,QAAM,SAAmB,CAAC;AAE1B,MAAI,CAAC,OAAO,WAAW;AACrB,WAAO,KAAK,mBAAmB;AAAA,EACjC;AAEA,MAAI,CAAC,OAAO,UAAU,OAAO,OAAO,WAAW,GAAG;AAChD,WAAO,KAAK,mBAAmB;AAAA,EACjC;AAEA,MAAI,CAAC,OAAO,SAAS,CAAC,OAAO,MAAM,YAAY,OAAO,MAAM,SAAS,WAAW,GAAG;AACjF,WAAO,KAAK,2BAA2B;AAAA,EACzC;AAGA,QAAM,gBAAgB,OAAO;AAC7B,MAAI,UAAU;AACd,aAAW,SAAS,OAAO,QAAQ;AACjC,QAAI,MAAM,YAAY,UAAU,KAAK;AACnC,aAAO,KAAK,kBAAkB,OAAO,QAAQ,MAAM,SAAS,GAAG;AAAA,IACjE;AACA,cAAU,KAAK,IAAI,SAAS,MAAM,YAAY,MAAM,QAAQ;AAAA,EAC9D;AAEA,MAAI,UAAU,gBAAgB,KAAK;AACjC,WAAO,KAAK,iBAAiB,OAAO,kBAAkB,aAAa,GAAG;AAAA,EACxE;AAEA,SAAO;AACT;;;ACheA,SAAS,UAA6C,QAAW,QAA2B;AAC1F,QAAM,SAAS,EAAE,GAAG,OAAO;AAC3B,aAAW,OAAO,OAAO,KAAK,MAAM,GAAG;AACrC,UAAM,SAAU,OAAmC,GAAG;AACtD,UAAM,SAAU,OAAmC,GAAG;AACtD,QACE,WAAW,QACX,WAAW,UACX,OAAO,WAAW,YAClB,CAAC,MAAM,QAAQ,MAAM,KACrB,WAAW,QACX,WAAW,UACX,OAAO,WAAW,YAClB,CAAC,MAAM,QAAQ,MAAM,GACrB;AACA,aAAO,GAAG,IAAI;AAAA,QACZ;AAAA,QACA;AAAA,MACF;AAAA,IACF,WAAW,WAAW,QAAW;AAC/B,aAAO,GAAG,IAAI;AAAA,IAChB;AAAA,EACF;AACA,SAAO;AACT;AAeO,SAAS,YAAY,MAAa,WAAsC;AAC7E,QAAM,SAAS;AAAA,IACb;AAAA,IACA;AAAA,EACF;AACA,MAAI,CAAC,UAAU,MAAM,OAAO,OAAO,KAAK,IAAI;AAC1C,WAAO,KAAK,GAAG,KAAK,EAAE;AAAA,EACxB;AACA,SAAO;AACT;","names":[]}