@bendyline/squisq 0.1.1

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 (102) hide show
  1. package/dist/Types-sh2VRxfo.d.ts +15 -0
  2. package/dist/chunk-7UDSRZKG.js +103 -0
  3. package/dist/chunk-7UDSRZKG.js.map +1 -0
  4. package/dist/chunk-O5HCIUAD.js +577 -0
  5. package/dist/chunk-O5HCIUAD.js.map +1 -0
  6. package/dist/chunk-PJ7AODIM.js +3040 -0
  7. package/dist/chunk-PJ7AODIM.js.map +1 -0
  8. package/dist/chunk-QWVRN6I4.js +81 -0
  9. package/dist/chunk-QWVRN6I4.js.map +1 -0
  10. package/dist/chunk-S4F2RY37.js +92 -0
  11. package/dist/chunk-S4F2RY37.js.map +1 -0
  12. package/dist/chunk-URU6QMLY.js +888 -0
  13. package/dist/chunk-URU6QMLY.js.map +1 -0
  14. package/dist/chunk-VJN7UB2Z.js +145 -0
  15. package/dist/chunk-VJN7UB2Z.js.map +1 -0
  16. package/dist/doc/index.d.ts +3 -0
  17. package/dist/doc/index.js +126 -0
  18. package/dist/doc/index.js.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.js +217 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/markdown/index.d.ts +237 -0
  23. package/dist/markdown/index.js +33 -0
  24. package/dist/markdown/index.js.map +1 -0
  25. package/dist/schemas/index.d.ts +70 -0
  26. package/dist/schemas/index.js +61 -0
  27. package/dist/schemas/index.js.map +1 -0
  28. package/dist/spatial/index.d.ts +111 -0
  29. package/dist/spatial/index.js +25 -0
  30. package/dist/spatial/index.js.map +1 -0
  31. package/dist/storage/index.d.ts +91 -0
  32. package/dist/storage/index.js +11 -0
  33. package/dist/storage/index.js.map +1 -0
  34. package/dist/story/index.d.ts +691 -0
  35. package/dist/story/index.js +126 -0
  36. package/dist/story/index.js.map +1 -0
  37. package/dist/themeLibrary-DySHPcgj.d.ts +1352 -0
  38. package/dist/types-DHiv_Pnm.d.ts +496 -0
  39. package/package.json +91 -0
  40. package/src/__tests__/animationUtils.test.ts +77 -0
  41. package/src/__tests__/geohash.test.ts +109 -0
  42. package/src/__tests__/getLayers.test.ts +200 -0
  43. package/src/__tests__/haversine.test.ts +63 -0
  44. package/src/__tests__/localForageAdapter.test.ts +155 -0
  45. package/src/__tests__/markdown.test.ts +806 -0
  46. package/src/__tests__/markdownToDoc.test.ts +374 -0
  47. package/src/__tests__/storage.test.ts +60 -0
  48. package/src/__tests__/templates.test.ts +247 -0
  49. package/src/doc/docToMarkdown.ts +98 -0
  50. package/src/doc/getLayers.ts +152 -0
  51. package/src/doc/index.ts +8 -0
  52. package/src/doc/markdownToDoc.ts +277 -0
  53. package/src/doc/templates/accentImage.ts +338 -0
  54. package/src/doc/templates/captionUtils.ts +33 -0
  55. package/src/doc/templates/comparisonBar.ts +140 -0
  56. package/src/doc/templates/coverBlock.ts +156 -0
  57. package/src/doc/templates/dateEvent.ts +122 -0
  58. package/src/doc/templates/definitionCard.ts +129 -0
  59. package/src/doc/templates/factCard.ts +114 -0
  60. package/src/doc/templates/fullBleedQuote.ts +59 -0
  61. package/src/doc/templates/imageWithCaption.ts +167 -0
  62. package/src/doc/templates/index.ts +571 -0
  63. package/src/doc/templates/listBlock.ts +101 -0
  64. package/src/doc/templates/mapBlock.ts +132 -0
  65. package/src/doc/templates/persistentLayers.ts +522 -0
  66. package/src/doc/templates/photoGrid.ts +199 -0
  67. package/src/doc/templates/pullQuote.ts +119 -0
  68. package/src/doc/templates/quoteBlock.ts +112 -0
  69. package/src/doc/templates/sectionHeader.ts +105 -0
  70. package/src/doc/templates/statHighlight.ts +113 -0
  71. package/src/doc/templates/titleBlock.ts +100 -0
  72. package/src/doc/templates/twoColumn.ts +239 -0
  73. package/src/doc/templates/videoPullQuote.ts +128 -0
  74. package/src/doc/templates/videoWithCaption.ts +126 -0
  75. package/src/doc/utils/animationUtils.ts +135 -0
  76. package/src/doc/utils/themeUtils.ts +139 -0
  77. package/src/index.ts +5 -0
  78. package/src/markdown/convert.ts +897 -0
  79. package/src/markdown/htmlParse.ts +212 -0
  80. package/src/markdown/index.ts +110 -0
  81. package/src/markdown/parse.ts +103 -0
  82. package/src/markdown/stringify.ts +122 -0
  83. package/src/markdown/types.ts +605 -0
  84. package/src/markdown/utils.ts +165 -0
  85. package/src/ngeohash.d.ts +11 -0
  86. package/src/schemas/BlockTemplates.ts +706 -0
  87. package/src/schemas/Doc.ts +565 -0
  88. package/src/schemas/LayoutStrategy.ts +178 -0
  89. package/src/schemas/MediaProvider.ts +70 -0
  90. package/src/schemas/Theme.ts +235 -0
  91. package/src/schemas/Types.ts +15 -0
  92. package/src/schemas/Viewport.ts +91 -0
  93. package/src/schemas/index.ts +14 -0
  94. package/src/schemas/themeLibrary.ts +492 -0
  95. package/src/spatial/Geohash.ts +168 -0
  96. package/src/spatial/Haversine.ts +59 -0
  97. package/src/spatial/index.ts +2 -0
  98. package/src/storage/LocalForageAdapter.ts +103 -0
  99. package/src/storage/LocalStorageAdapter.ts +57 -0
  100. package/src/storage/MemoryStorageAdapter.ts +37 -0
  101. package/src/storage/Storage.ts +28 -0
  102. package/src/storage/index.ts +5 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Map Block Template
3
+ *
4
+ * Full-screen geographic map with optional title and caption overlays.
5
+ * Great for establishing geographic context at the start of a doc
6
+ * or when discussing location-specific content.
7
+ * Adapts font sizes and positioning for different viewports.
8
+ *
9
+ * Map tiles are fetched from free/open-source providers.
10
+ * See docs/MAP_TILES.md for available styles and attribution requirements.
11
+ *
12
+ * This is shared code used by both site and efb-app doc renderers.
13
+ */
14
+
15
+ import type { Layer } from '../../schemas/Doc.js';
16
+ import type { MapBlockInput, TemplateContext } from '../../schemas/BlockTemplates.js';
17
+ import { scaledFontSize } from '../../schemas/BlockTemplates.js';
18
+ import { mapAmbientMotion } from './accentImage.js';
19
+
20
+ export function mapBlock(input: MapBlockInput, context: TemplateContext): Layer[] {
21
+ const {
22
+ center,
23
+ zoom,
24
+ mapStyle = 'terrain',
25
+ title,
26
+ caption,
27
+ markers,
28
+ ambientMotion,
29
+ staticSrc,
30
+ } = input;
31
+ const { theme, layout } = context;
32
+
33
+ // Scale font sizes for viewport
34
+ const titleFontSize = scaledFontSize(64, context, true);
35
+ const captionFontSize = scaledFontSize(32, context, false);
36
+
37
+ // Determine animation based on ambientMotion setting
38
+ const mapAnimation = mapAmbientMotion(ambientMotion);
39
+
40
+ const layers: Layer[] = [
41
+ // Map background
42
+ {
43
+ type: 'map',
44
+ id: 'map-bg',
45
+ content: {
46
+ center,
47
+ zoom,
48
+ style: mapStyle,
49
+ markers,
50
+ showAttribution: true,
51
+ staticSrc, // Use pre-rendered image if available
52
+ },
53
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
54
+ animation: mapAnimation,
55
+ },
56
+ ];
57
+
58
+ // Add title overlay if provided
59
+ if (title) {
60
+ // Gradient overlay at top for title readability
61
+ layers.push({
62
+ type: 'shape',
63
+ id: 'title-overlay',
64
+ content: {
65
+ shape: 'rect',
66
+ fill: 'rgba(0,0,0,0.6)',
67
+ },
68
+ position: { x: 0, y: 0, width: '100%', height: '18%' },
69
+ });
70
+
71
+ // Title text - positioned at top of slide
72
+ layers.push({
73
+ type: 'text',
74
+ id: 'title',
75
+ content: {
76
+ text: title,
77
+ style: {
78
+ fontSize: titleFontSize,
79
+ fontWeight: 'bold',
80
+ color: theme.colors.text,
81
+ textAlign: 'center',
82
+ shadow: true,
83
+ },
84
+ },
85
+ position: {
86
+ x: '50%',
87
+ y: '9%',
88
+ anchor: 'center',
89
+ width: layout.maxTextWidth,
90
+ },
91
+ animation: { type: 'fadeIn', duration: 1, delay: 0.3 },
92
+ });
93
+ }
94
+
95
+ // Add caption if provided
96
+ if (caption) {
97
+ // Gradient overlay at bottom for caption readability
98
+ layers.push({
99
+ type: 'shape',
100
+ id: 'caption-overlay',
101
+ content: {
102
+ shape: 'rect',
103
+ fill: 'rgba(0,0,0,0.6)',
104
+ },
105
+ position: { x: 0, y: '82%', width: '100%', height: '18%' },
106
+ });
107
+
108
+ // Caption text
109
+ layers.push({
110
+ type: 'text',
111
+ id: 'caption',
112
+ content: {
113
+ text: caption,
114
+ style: {
115
+ fontSize: captionFontSize,
116
+ color: theme.colors.text,
117
+ textAlign: 'center',
118
+ shadow: true,
119
+ },
120
+ },
121
+ position: {
122
+ x: '50%',
123
+ y: layout.captionY,
124
+ anchor: 'center',
125
+ width: layout.maxTextWidth,
126
+ },
127
+ animation: { type: 'fadeIn', duration: 1, delay: 0.5 },
128
+ });
129
+ }
130
+
131
+ return layers;
132
+ }
@@ -0,0 +1,522 @@
1
+ /**
2
+ * Persistent Layers Expansion
3
+ *
4
+ * Expands persistent layer templates (solid backgrounds, gradients, overlays)
5
+ * into raw Layer arrays. These layers are injected into each block based on
6
+ * per-block flags (useBottomLayer, useTopLayer).
7
+ *
8
+ * This is shared code used by both site and efb-app doc renderers.
9
+ *
10
+ * Related Files:
11
+ * - schemas/BlockTemplates.ts - PersistentLayerConfig types
12
+ */
13
+
14
+ import type { Layer } from '../../schemas/Doc.js';
15
+ import type {
16
+ PersistentLayer,
17
+ PersistentLayerTemplate,
18
+ PersistentLayerConfig,
19
+ SolidBackgroundConfig,
20
+ GradientBackgroundConfig,
21
+ ImageBackgroundConfig,
22
+ PatternBackgroundConfig,
23
+ TitleCaptionConfig,
24
+ CornerBrandingConfig,
25
+ ProgressIndicatorConfig,
26
+ } from '../../schemas/BlockTemplates.js';
27
+ import type { Theme } from '../../schemas/Theme.js';
28
+ import { isPersistentLayerTemplate } from '../../schemas/BlockTemplates.js';
29
+
30
+ // ============================================
31
+ // Gradient Presets
32
+ // ============================================
33
+
34
+ const GRADIENT_PRESETS: Record<string, string> = {
35
+ 'dark-vignette':
36
+ 'radial-gradient(ellipse at center, rgba(26,32,44,0.8) 0%, rgba(0,0,0,0.95) 100%)',
37
+ 'radial-dark': 'radial-gradient(ellipse at center, #1a202c 0%, #000000 100%)',
38
+ 'warm-sunset': 'linear-gradient(135deg, rgba(124,58,48,0.9) 0%, rgba(26,32,44,0.95) 100%)',
39
+ 'cool-blue': 'linear-gradient(135deg, rgba(26,54,93,0.9) 0%, rgba(26,32,44,0.95) 100%)',
40
+ 'earth-tones': 'linear-gradient(135deg, rgba(68,51,34,0.9) 0%, rgba(26,32,44,0.95) 100%)',
41
+ };
42
+
43
+ // ============================================
44
+ // Background Layer Expansion
45
+ // ============================================
46
+
47
+ /**
48
+ * Expand a solid background config to a Layer.
49
+ */
50
+ function expandSolidBackground(config: SolidBackgroundConfig): Layer {
51
+ return {
52
+ type: 'shape',
53
+ id: 'persistent-bg-solid',
54
+ content: {
55
+ shape: 'rect',
56
+ fill: config.color,
57
+ },
58
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Expand a gradient background config to a Layer.
64
+ */
65
+ function expandGradientBackground(config: GradientBackgroundConfig): Layer {
66
+ const gradient = config.gradient ?? GRADIENT_PRESETS[config.preset ?? 'dark-vignette'];
67
+
68
+ return {
69
+ type: 'shape',
70
+ id: 'persistent-bg-gradient',
71
+ content: {
72
+ shape: 'rect',
73
+ fill: gradient,
74
+ },
75
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Expand an image background config to Layers.
81
+ * Returns multiple layers: base image + dark overlay for readability.
82
+ */
83
+ function expandImageBackground(config: ImageBackgroundConfig): Layer[] {
84
+ const layers: Layer[] = [];
85
+ const opacity = config.opacity ?? 0.4;
86
+
87
+ // Base image layer
88
+ const imageLayer: Layer = {
89
+ type: 'image',
90
+ id: 'persistent-bg-image',
91
+ content: {
92
+ src: config.src,
93
+ alt: 'Background',
94
+ fit: 'cover',
95
+ },
96
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
97
+ animation: config.ambientMotion
98
+ ? {
99
+ type: config.ambientMotion,
100
+ duration: 30, // Long ambient motion
101
+ }
102
+ : undefined,
103
+ };
104
+
105
+ layers.push(imageLayer);
106
+
107
+ // Add dark overlay for text readability
108
+ layers.push({
109
+ type: 'shape',
110
+ id: 'persistent-bg-overlay',
111
+ content: {
112
+ shape: 'rect',
113
+ fill: `rgba(0,0,0,${0.5 + (1 - opacity) * 0.3})`,
114
+ },
115
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
116
+ });
117
+
118
+ return layers;
119
+ }
120
+
121
+ /**
122
+ * Expand a pattern background config to a Layer.
123
+ */
124
+ function expandPatternBackground(config: PatternBackgroundConfig): Layer {
125
+ const opacity = config.opacity ?? 0.1;
126
+ const color = config.color ?? `rgba(255,255,255,${opacity})`;
127
+
128
+ // Pattern as SVG data URI for rect patterns
129
+ // For simplicity, use a solid subtle color with reduced opacity
130
+ return {
131
+ type: 'shape',
132
+ id: 'persistent-bg-pattern',
133
+ content: {
134
+ shape: 'rect',
135
+ fill: color,
136
+ },
137
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
138
+ };
139
+ }
140
+
141
+ // ============================================
142
+ // Overlay Layer Expansion
143
+ // ============================================
144
+
145
+ /**
146
+ * Expand a title caption config to Layers.
147
+ */
148
+ function expandTitleCaption(config: TitleCaptionConfig): Layer[] {
149
+ const layers: Layer[] = [];
150
+ const fontSize = config.fontSize ?? 18;
151
+ const hasSubtitle = !!config.subtitle;
152
+
153
+ // Position calculations
154
+ const isBottom = config.position.includes('bottom');
155
+ const isLeft = config.position.includes('left');
156
+
157
+ // Even padding around the pill (3% from edges)
158
+ const pad = 3; // percent
159
+
160
+ // Pill dimensions — taller when subtitle present
161
+ const pillHeight = hasSubtitle ? '15%' : '7%';
162
+ // Position above player controls
163
+ const bgYPos = isBottom ? (hasSubtitle ? '78%' : '84%') : '2%';
164
+ const bgXPos = isLeft ? `${pad}%` : '68%';
165
+
166
+ // Thumbnail sizing — use different width/height to appear square in 16:9 viewport
167
+ // In 16:9, 6% of width ≈ 10.7% of height in pixels, so use ~6%w x 10%h for visual square
168
+ const thumbW = hasSubtitle ? '6%' : '5%';
169
+ const thumbH = hasSubtitle ? '10%' : '5%';
170
+ const thumbPad = 1.5; // padding inside pill around thumbnail (percent)
171
+
172
+ // Text X offset: after thumbnail + padding, or just pill padding
173
+ const textX =
174
+ config.showThumbnail && isLeft
175
+ ? `${pad + thumbPad + 6 + thumbPad}%` // pill edge + thumb padding + thumb width + gap
176
+ : `${pad + thumbPad}%`;
177
+ // Text area width: pill width minus thumbnail area minus padding
178
+ const textWidth = config.showThumbnail
179
+ ? '19%' // narrower when thumbnail takes space
180
+ : '24%';
181
+
182
+ // Title Y: top of text area inside pill
183
+ const titleYPos = isBottom ? (hasSubtitle ? '80%' : '86%') : '4%';
184
+
185
+ // Background pill for readability
186
+ layers.push({
187
+ type: 'shape',
188
+ id: 'persistent-caption-bg',
189
+ content: {
190
+ shape: 'rect',
191
+ fill: 'rgba(0,0,0,0.6)',
192
+ borderRadius: 8,
193
+ },
194
+ position: {
195
+ x: bgXPos,
196
+ y: bgYPos,
197
+ width: config.showThumbnail ? '30%' : '28%',
198
+ height: pillHeight,
199
+ },
200
+ });
201
+
202
+ // Thumbnail if configured
203
+ if (config.showThumbnail && config.thumbnailSrc) {
204
+ const thumbX = isLeft ? `${pad + thumbPad}%` : '93%';
205
+ // Center thumbnail vertically in pill
206
+ const thumbY = isBottom ? (hasSubtitle ? `${78 + thumbPad}%` : '85%') : '3%';
207
+
208
+ layers.push({
209
+ type: 'image',
210
+ id: 'persistent-caption-thumb',
211
+ content: {
212
+ src: config.thumbnailSrc,
213
+ alt: 'Article thumbnail',
214
+ fit: 'cover',
215
+ },
216
+ position: {
217
+ x: thumbX,
218
+ y: thumbY,
219
+ width: thumbW,
220
+ height: thumbH,
221
+ },
222
+ });
223
+ }
224
+
225
+ // Title text — single line, cropped with ellipsis
226
+ layers.push({
227
+ type: 'text',
228
+ id: 'persistent-caption-title',
229
+ content: {
230
+ text: config.title,
231
+ style: {
232
+ fontSize,
233
+ fontWeight: 'bold',
234
+ color: 'rgba(255,255,255,0.9)',
235
+ textAlign: isLeft ? 'left' : 'right',
236
+ maxLines: 1,
237
+ },
238
+ },
239
+ position: {
240
+ x: textX,
241
+ y: titleYPos,
242
+ width: textWidth,
243
+ },
244
+ });
245
+
246
+ // Subtitle text (e.g., short URL)
247
+ if (hasSubtitle) {
248
+ const subtitleYPos = isBottom ? '85.5%' : '8%';
249
+
250
+ layers.push({
251
+ type: 'text',
252
+ id: 'persistent-caption-subtitle',
253
+ content: {
254
+ text: config.subtitle!,
255
+ style: {
256
+ fontSize: 18,
257
+ fontWeight: 'normal',
258
+ color: 'rgba(255,255,255,0.55)',
259
+ textAlign: isLeft ? 'left' : 'right',
260
+ },
261
+ },
262
+ position: {
263
+ x: textX,
264
+ y: subtitleYPos,
265
+ width: textWidth,
266
+ },
267
+ });
268
+ }
269
+
270
+ return layers;
271
+ }
272
+
273
+ /**
274
+ * Expand a corner branding config to a Layer.
275
+ */
276
+ function expandCornerBranding(config: CornerBrandingConfig): Layer {
277
+ const isBottom = config.position.includes('bottom');
278
+ const isLeft = config.position.includes('left');
279
+
280
+ const xPos = isLeft ? '3%' : '90%';
281
+ const yPos = isBottom ? '94%' : '3%';
282
+
283
+ if (config.isImage) {
284
+ return {
285
+ type: 'image',
286
+ id: 'persistent-branding',
287
+ content: {
288
+ src: config.content,
289
+ alt: 'Branding',
290
+ fit: 'contain',
291
+ },
292
+ position: {
293
+ x: xPos,
294
+ y: yPos,
295
+ width: '8%',
296
+ height: '4%',
297
+ },
298
+ };
299
+ }
300
+
301
+ return {
302
+ type: 'text',
303
+ id: 'persistent-branding',
304
+ content: {
305
+ text: config.content,
306
+ style: {
307
+ fontSize: 14,
308
+ fontWeight: 'normal',
309
+ color: 'rgba(255,255,255,0.6)',
310
+ },
311
+ },
312
+ position: {
313
+ x: xPos,
314
+ y: yPos,
315
+ },
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Expand a progress indicator config to a Layer.
321
+ * Note: Progress requires block context, returns placeholder bar.
322
+ */
323
+ function expandProgressIndicator(config: ProgressIndicatorConfig): Layer {
324
+ const color = config.color ?? 'rgba(255,255,255,0.3)';
325
+ const y = config.position === 'top' ? '1%' : '98%';
326
+
327
+ // Simple bar style - actual progress would need to be set per-block
328
+ return {
329
+ type: 'shape',
330
+ id: 'persistent-progress',
331
+ content: {
332
+ shape: 'rect',
333
+ fill: color,
334
+ borderRadius: 2,
335
+ },
336
+ position: {
337
+ x: '5%',
338
+ y,
339
+ width: '90%',
340
+ height: '0.5%',
341
+ },
342
+ };
343
+ }
344
+
345
+ // ============================================
346
+ // Main Expansion Function
347
+ // ============================================
348
+
349
+ /**
350
+ * Expand a single persistent layer (template or raw) to raw Layer(s).
351
+ */
352
+ export function expandPersistentLayer(layer: PersistentLayer): Layer[] {
353
+ // If already a raw Layer, return as-is
354
+ if (!isPersistentLayerTemplate(layer)) {
355
+ return [layer as Layer];
356
+ }
357
+
358
+ const template = layer as PersistentLayerTemplate;
359
+ const config = template.config;
360
+
361
+ switch (config.type) {
362
+ case 'solidBackground':
363
+ return [expandSolidBackground(config)];
364
+ case 'gradientBackground':
365
+ return [expandGradientBackground(config)];
366
+ case 'imageBackground':
367
+ return expandImageBackground(config);
368
+ case 'patternBackground':
369
+ return [expandPatternBackground(config)];
370
+ case 'titleCaption':
371
+ return expandTitleCaption(config);
372
+ case 'cornerBranding':
373
+ return [expandCornerBranding(config)];
374
+ case 'progressIndicator':
375
+ return [expandProgressIndicator(config)];
376
+ default:
377
+ console.warn('Unknown persistent layer template:', (config as Record<string, unknown>).type);
378
+ return [];
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Expand all persistent layers in a config to raw Layer arrays.
384
+ */
385
+ export function expandPersistentLayers(layers: PersistentLayer[] | undefined): Layer[] {
386
+ if (!layers || layers.length === 0) {
387
+ return [];
388
+ }
389
+
390
+ // Use reduce+concat instead of flatMap for Coherent GT compatibility (ES2017)
391
+ return layers.reduce<Layer[]>((acc, layer) => acc.concat(expandPersistentLayer(layer)), []);
392
+ }
393
+
394
+ // ============================================
395
+ // Style Presets (legacy helper — prefer theme.persistentLayers)
396
+ // ============================================
397
+
398
+ /** Legacy style preset names. Prefer using `Theme.persistentLayers` directly. */
399
+ export type DocStylePreset = 'minimal' | 'documentary' | 'branded' | 'cinematic' | 'clean';
400
+
401
+ /**
402
+ * Get a PersistentLayerConfig from a style preset.
403
+ *
404
+ * @param preset - Style preset name
405
+ * @param articleTitle - Article title for title caption
406
+ * @param heroSrc - Optional hero image for cinematic style
407
+ */
408
+ export function getDocStyleConfig(
409
+ preset: DocStylePreset,
410
+ articleTitle: string,
411
+ heroSrc?: string,
412
+ subtitle?: string,
413
+ ): PersistentLayerConfig {
414
+ switch (preset) {
415
+ case 'minimal':
416
+ return {};
417
+
418
+ case 'documentary':
419
+ return {
420
+ bottomLayers: [
421
+ {
422
+ template: 'gradientBackground',
423
+ config: { type: 'gradientBackground', preset: 'dark-vignette' },
424
+ },
425
+ ],
426
+ topLayers: [
427
+ {
428
+ template: 'titleCaption',
429
+ config: {
430
+ type: 'titleCaption',
431
+ title: articleTitle,
432
+ subtitle,
433
+ position: 'bottom-left',
434
+ fontSize: 24,
435
+ showThumbnail: !!heroSrc,
436
+ thumbnailSrc: heroSrc,
437
+ },
438
+ },
439
+ ],
440
+ };
441
+
442
+ case 'branded':
443
+ return {
444
+ bottomLayers: [
445
+ {
446
+ template: 'gradientBackground',
447
+ config: { type: 'gradientBackground', preset: 'cool-blue' },
448
+ },
449
+ ],
450
+ topLayers: [
451
+ {
452
+ template: 'titleCaption',
453
+ config: {
454
+ type: 'titleCaption',
455
+ title: articleTitle,
456
+ subtitle,
457
+ position: 'bottom-left',
458
+ fontSize: 26,
459
+ showThumbnail: !!heroSrc,
460
+ thumbnailSrc: heroSrc,
461
+ },
462
+ },
463
+ ],
464
+ };
465
+
466
+ case 'cinematic':
467
+ return {
468
+ bottomLayers: heroSrc
469
+ ? [
470
+ {
471
+ template: 'imageBackground',
472
+ config: {
473
+ type: 'imageBackground',
474
+ src: heroSrc,
475
+ blur: 12,
476
+ opacity: 0.3,
477
+ ambientMotion: 'zoomIn',
478
+ },
479
+ },
480
+ ]
481
+ : [
482
+ {
483
+ template: 'gradientBackground',
484
+ config: { type: 'gradientBackground', preset: 'radial-dark' },
485
+ },
486
+ ],
487
+ topLayers: [
488
+ {
489
+ template: 'titleCaption',
490
+ config: {
491
+ type: 'titleCaption',
492
+ title: articleTitle,
493
+ subtitle,
494
+ position: 'bottom-right',
495
+ fontSize: 22,
496
+ },
497
+ },
498
+ ],
499
+ };
500
+
501
+ case 'clean':
502
+ return {
503
+ bottomLayers: [
504
+ {
505
+ template: 'solidBackground',
506
+ config: { type: 'solidBackground', color: '#1a202c' },
507
+ },
508
+ ],
509
+ };
510
+
511
+ default:
512
+ return {};
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Get persistent layers from a Theme. Returns the theme's baked-in
518
+ * persistentLayers config, or an empty config when the theme has none.
519
+ */
520
+ export function getPersistentLayersFromTheme(theme: Theme): PersistentLayerConfig {
521
+ return theme.persistentLayers ?? {};
522
+ }