@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,338 @@
1
+ /**
2
+ * Accent Image Utility
3
+ *
4
+ * Creates image layers and layout adjustments for accent images on text-based slides.
5
+ * Accent images are tasteful additions that complement text without overwhelming it.
6
+ *
7
+ * Layout patterns:
8
+ * - left-strip: 35% width vertical strip on left, text area shifted to right 65%
9
+ * - right-strip: 35% width vertical strip on right, text area shifted to left 65%
10
+ * - bottom-strip: 35% height horizontal strip at bottom, text area in upper 65%
11
+ * - corner-inset: Small 25% corner image with gradient vignette
12
+ *
13
+ * This is shared code used by both site and efb-app doc renderers.
14
+ */
15
+
16
+ import type { Layer, ImageLayer, ShapeLayer, Animation } from '../../schemas/Doc.js';
17
+ import type { AccentImage, AccentPosition } from '../../schemas/BlockTemplates.js';
18
+
19
+ const PERCENTAGE_RE = /^(\d+(?:\.\d+)?)\s*%?$/;
20
+
21
+ /**
22
+ * Layout adjustments when an accent image is present.
23
+ * Templates use these values to reposition their text content.
24
+ */
25
+ export interface AccentLayout {
26
+ /** X offset for text center (e.g., '64%' for left-strip) */
27
+ textCenterX: string;
28
+ /** Width available for text content */
29
+ textWidth: string;
30
+ /** Y offset adjustment for vertical positioning */
31
+ textYAdjust: number;
32
+ /** Whether to adjust Y positions (for bottom-strip) */
33
+ adjustY: boolean;
34
+ }
35
+
36
+ /**
37
+ * Default layout when no accent image is present.
38
+ */
39
+ export const DEFAULT_LAYOUT: AccentLayout = {
40
+ textCenterX: '50%',
41
+ textWidth: '85%',
42
+ textYAdjust: 0,
43
+ adjustY: false,
44
+ };
45
+
46
+ /**
47
+ * Map ambientMotion values to proper Ken Burns animations.
48
+ * Uses slowZoom variants which provide smooth pan/zoom without opacity changes.
49
+ * Supports both standard values ('zoomIn', 'panLeft', etc.) and legacy
50
+ * aliases ('in', 'out', 'left', 'right') for backward compatibility.
51
+ *
52
+ * @param motion - The ambient motion value (e.g., 'zoomIn', 'panLeft')
53
+ * @param duration - Optional animation duration in seconds
54
+ */
55
+ export function mapAmbientMotion(
56
+ motion: string | undefined,
57
+ duration?: number,
58
+ ): Animation | undefined {
59
+ if (!motion) return undefined;
60
+ const base = duration !== undefined ? { duration } : {};
61
+ switch (motion) {
62
+ case 'zoomIn':
63
+ case 'in':
64
+ return { type: 'slowZoom', direction: 'in', ...base };
65
+ case 'zoomOut':
66
+ case 'out':
67
+ return { type: 'slowZoom', direction: 'out', ...base };
68
+ case 'panLeft':
69
+ case 'left':
70
+ return { type: 'slowZoom', panDirection: 'left', ...base };
71
+ case 'panRight':
72
+ case 'right':
73
+ return { type: 'slowZoom', panDirection: 'right', ...base };
74
+ default:
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Accent strip width/height as percentage.
81
+ * 35% gives the image enough presence to complement the text without overwhelming it.
82
+ */
83
+ const STRIP_SIZE = 35;
84
+ const STRIP_SIZE_PCT = `${STRIP_SIZE}%`;
85
+
86
+ /**
87
+ * Get layout adjustments based on accent position.
88
+ */
89
+ export function getAccentLayout(position: AccentPosition): AccentLayout {
90
+ switch (position) {
91
+ case 'left-strip':
92
+ return {
93
+ textCenterX: `${50 + STRIP_SIZE / 2}%`, // Shift center right
94
+ textWidth: `${100 - STRIP_SIZE - 8}%`, // Leave margin
95
+ textYAdjust: 0,
96
+ adjustY: false,
97
+ };
98
+ case 'right-strip':
99
+ return {
100
+ textCenterX: `${50 - STRIP_SIZE / 2}%`, // Shift center left
101
+ textWidth: `${100 - STRIP_SIZE - 8}%`,
102
+ textYAdjust: 0,
103
+ adjustY: false,
104
+ };
105
+ case 'bottom-strip':
106
+ return {
107
+ textCenterX: '50%',
108
+ textWidth: '85%',
109
+ textYAdjust: -10, // Shift content up by 10%
110
+ adjustY: true,
111
+ };
112
+ case 'corner-inset':
113
+ return {
114
+ textCenterX: '45%', // Slight shift to avoid corner
115
+ textWidth: '70%',
116
+ textYAdjust: 0,
117
+ adjustY: false,
118
+ };
119
+ default:
120
+ return DEFAULT_LAYOUT;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Create layers for an accent image.
126
+ * Returns the image layer and any overlay/gradient layers needed.
127
+ */
128
+ export function createAccentLayers(accent: AccentImage, slideId: string): Layer[] {
129
+ const layers: Layer[] = [];
130
+ const { src, alt, position, ambientMotion, credit, license } = accent;
131
+
132
+ switch (position) {
133
+ case 'left-strip':
134
+ layers.push(createStripImage(src, alt, 'left', slideId, ambientMotion, credit, license));
135
+ layers.push(createStripGradient('left', slideId));
136
+ break;
137
+
138
+ case 'right-strip':
139
+ layers.push(createStripImage(src, alt, 'right', slideId, ambientMotion, credit, license));
140
+ layers.push(createStripGradient('right', slideId));
141
+ break;
142
+
143
+ case 'bottom-strip':
144
+ layers.push(createBottomStripImage(src, alt, slideId, ambientMotion, credit, license));
145
+ layers.push(createBottomStripGradient(slideId));
146
+ break;
147
+
148
+ case 'corner-inset':
149
+ layers.push(createCornerInsetImage(src, alt, slideId, ambientMotion, credit, license));
150
+ layers.push(createCornerVignette(slideId));
151
+ break;
152
+ }
153
+
154
+ return layers;
155
+ }
156
+
157
+ /**
158
+ * Create a vertical strip image layer.
159
+ */
160
+ function createStripImage(
161
+ src: string,
162
+ alt: string,
163
+ side: 'left' | 'right',
164
+ slideId: string,
165
+ ambientMotion?: AccentImage['ambientMotion'],
166
+ credit?: string,
167
+ license?: string,
168
+ ): ImageLayer {
169
+ return {
170
+ type: 'image',
171
+ id: `${slideId}-accent-img`,
172
+ content: {
173
+ src,
174
+ alt,
175
+ fit: 'cover',
176
+ credit,
177
+ license,
178
+ },
179
+ position: {
180
+ x: side === 'left' ? 0 : `${100 - STRIP_SIZE}%`,
181
+ y: 0,
182
+ width: STRIP_SIZE_PCT,
183
+ height: '100%',
184
+ },
185
+ animation: mapAmbientMotion(ambientMotion, 15),
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Create a gradient overlay for strip images (fades to background).
191
+ */
192
+ function createStripGradient(side: 'left' | 'right', slideId: string): ShapeLayer {
193
+ // Gradient from transparent to background color at the edge
194
+ const gradientId = side === 'left' ? 'accent-gradient-left' : 'accent-gradient-right';
195
+
196
+ return {
197
+ type: 'shape',
198
+ id: `${slideId}-accent-fade`,
199
+ content: {
200
+ shape: 'rect',
201
+ // SVG gradient will be handled by the renderer
202
+ // Using a semi-transparent overlay as fallback
203
+ fill: `url(#${gradientId})`,
204
+ },
205
+ position: {
206
+ x: side === 'left' ? `${STRIP_SIZE - 8}%` : `${100 - STRIP_SIZE}%`,
207
+ y: 0,
208
+ width: '10%',
209
+ height: '100%',
210
+ },
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Create a horizontal bottom strip image layer.
216
+ */
217
+ function createBottomStripImage(
218
+ src: string,
219
+ alt: string,
220
+ slideId: string,
221
+ ambientMotion?: AccentImage['ambientMotion'],
222
+ credit?: string,
223
+ license?: string,
224
+ ): ImageLayer {
225
+ return {
226
+ type: 'image',
227
+ id: `${slideId}-accent-img`,
228
+ content: {
229
+ src,
230
+ alt,
231
+ fit: 'cover',
232
+ credit,
233
+ license,
234
+ },
235
+ position: {
236
+ x: 0,
237
+ y: `${100 - STRIP_SIZE}%`,
238
+ width: '100%',
239
+ height: STRIP_SIZE_PCT,
240
+ },
241
+ animation: mapAmbientMotion(ambientMotion, 15),
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Create a gradient overlay for bottom strip (fades up to background).
247
+ */
248
+ function createBottomStripGradient(slideId: string): ShapeLayer {
249
+ return {
250
+ type: 'shape',
251
+ id: `${slideId}-accent-fade`,
252
+ content: {
253
+ shape: 'rect',
254
+ fill: 'url(#accent-gradient-bottom)',
255
+ },
256
+ position: {
257
+ x: 0,
258
+ y: `${100 - STRIP_SIZE - 8}%`,
259
+ width: '100%',
260
+ height: '12%',
261
+ },
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Create a corner inset image layer.
267
+ */
268
+ function createCornerInsetImage(
269
+ src: string,
270
+ alt: string,
271
+ slideId: string,
272
+ ambientMotion?: AccentImage['ambientMotion'],
273
+ credit?: string,
274
+ license?: string,
275
+ ): ImageLayer {
276
+ return {
277
+ type: 'image',
278
+ id: `${slideId}-accent-img`,
279
+ content: {
280
+ src,
281
+ alt,
282
+ fit: 'cover',
283
+ credit,
284
+ license,
285
+ },
286
+ position: {
287
+ x: '70%',
288
+ y: '60%',
289
+ width: '28%',
290
+ height: '38%',
291
+ anchor: 'top-left',
292
+ },
293
+ animation: mapAmbientMotion(ambientMotion, 15) ?? { type: 'fadeIn', duration: 2, delay: 0.5 },
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Create a vignette overlay around corner inset.
299
+ */
300
+ function createCornerVignette(slideId: string): ShapeLayer {
301
+ return {
302
+ type: 'shape',
303
+ id: `${slideId}-accent-vignette`,
304
+ content: {
305
+ shape: 'rect',
306
+ fill: 'url(#accent-vignette-corner)',
307
+ borderRadius: 8,
308
+ },
309
+ position: {
310
+ x: '69%',
311
+ y: '59%',
312
+ width: '30%',
313
+ height: '40%',
314
+ },
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Adjust a Y position value based on accent layout.
320
+ * Used by templates to shift content when bottom-strip is present.
321
+ */
322
+ export function adjustY(originalY: string | number, layout: AccentLayout): string {
323
+ if (!layout.adjustY || layout.textYAdjust === 0) {
324
+ return typeof originalY === 'number' ? `${originalY}%` : originalY;
325
+ }
326
+
327
+ // Parse percentage value
328
+ const yStr = typeof originalY === 'number' ? `${originalY}` : originalY;
329
+ const match = yStr.match(PERCENTAGE_RE);
330
+
331
+ if (match) {
332
+ const yValue = parseFloat(match[1]);
333
+ const adjusted = Math.max(5, yValue + layout.textYAdjust);
334
+ return `${adjusted}%`;
335
+ }
336
+
337
+ return yStr;
338
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Template Utilities
3
+ *
4
+ * Shared helpers for doc block templates:
5
+ * - Caption text cleaning
6
+ * - Common layer construction patterns
7
+ */
8
+
9
+ import type { Layer } from '../../schemas/Doc.js';
10
+
11
+ const TRAILING_PUNCT_RE = /[.;:!?]+$/;
12
+
13
+ /**
14
+ * Strip trailing punctuation from a caption string.
15
+ * Image captions on blocks look cleaner without terminal periods,
16
+ * semicolons, or other sentence-ending punctuation.
17
+ */
18
+ export function cleanCaption(text: string): string {
19
+ return text.replace(TRAILING_PUNCT_RE, '').trim();
20
+ }
21
+
22
+ /**
23
+ * Create a full-screen background shape layer.
24
+ * Most text-based templates start with this as their first layer.
25
+ */
26
+ export function createBackgroundLayer(id: string, fill: string): Layer {
27
+ return {
28
+ type: 'shape',
29
+ id,
30
+ content: { shape: 'rect', fill },
31
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
32
+ };
33
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Comparison Bar Template
3
+ *
4
+ * Two horizontal bars showing relative numeric values side by side.
5
+ * Bar widths are proportional to the values for immediate visual comparison.
6
+ * Good for population, distance, or measurement comparisons.
7
+ *
8
+ * This is shared code used by both site and efb-app doc renderers.
9
+ */
10
+
11
+ import type { Layer } from '../../schemas/Doc.js';
12
+ import type { ComparisonBarInput, TemplateContext } from '../../schemas/BlockTemplates.js';
13
+ import { scaledFontSize } from '../../schemas/BlockTemplates.js';
14
+ import { resolveColorScheme } from '../utils/themeUtils.js';
15
+
16
+ export function comparisonBar(input: ComparisonBarInput, context: TemplateContext): Layer[] {
17
+ const { leftLabel, leftValue, rightLabel, rightValue, unit, colorScheme = 'blue' } = input;
18
+ const { theme } = context;
19
+ const colors = resolveColorScheme(context, colorScheme);
20
+
21
+ const labelFontSize = scaledFontSize(28, context, false);
22
+ const valueFontSize = scaledFontSize(48, context, true);
23
+
24
+ // Calculate proportional bar widths (max bar = 65% of viewport width)
25
+ const maxValue = Math.max(leftValue, rightValue, 1);
26
+ const maxBarWidth = 65;
27
+ const leftBarWidth = (leftValue / maxValue) * maxBarWidth;
28
+ const rightBarWidth = (rightValue / maxValue) * maxBarWidth;
29
+
30
+ // Bar positioning
31
+ const barStartX = 15;
32
+ const barHeight = 6; // % of viewport height
33
+ const topBarY = 36;
34
+ const bottomBarY = 58;
35
+
36
+ // Format values for display
37
+ const formatValue = (v: number): string => {
38
+ if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
39
+ if (v >= 1_000) return `${(v / 1_000).toFixed(v >= 10_000 ? 0 : 1)}K`;
40
+ return v.toLocaleString();
41
+ };
42
+
43
+ const leftDisplay = unit ? `${formatValue(leftValue)} ${unit}` : formatValue(leftValue);
44
+ const rightDisplay = unit ? `${formatValue(rightValue)} ${unit}` : formatValue(rightValue);
45
+
46
+ return [
47
+ // Background
48
+ {
49
+ type: 'shape',
50
+ id: 'bg',
51
+ content: {
52
+ shape: 'rect',
53
+ fill: `linear-gradient(180deg, ${theme.colors.background} 0%, #0f1520 100%)`,
54
+ },
55
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
56
+ },
57
+
58
+ // Top bar label
59
+ {
60
+ type: 'text',
61
+ id: 'left-label',
62
+ content: {
63
+ text: leftLabel,
64
+ style: { fontSize: labelFontSize, color: theme.colors.textMuted },
65
+ },
66
+ position: { x: `${barStartX}%`, y: `${topBarY - 8}%` },
67
+ },
68
+
69
+ // Top bar value
70
+ {
71
+ type: 'text',
72
+ id: 'left-value',
73
+ content: {
74
+ text: leftDisplay,
75
+ style: { fontSize: valueFontSize, fontWeight: 'bold', color: colors.text },
76
+ },
77
+ position: { x: `${barStartX + leftBarWidth + 2}%`, y: `${topBarY - 1}%` },
78
+ animation: { type: 'fadeIn', duration: 0.8, delay: 0.3 },
79
+ },
80
+
81
+ // Top bar
82
+ {
83
+ type: 'shape',
84
+ id: 'left-bar',
85
+ content: {
86
+ shape: 'rect',
87
+ fill: colors.text,
88
+ borderRadius: 4,
89
+ },
90
+ position: {
91
+ x: `${barStartX}%`,
92
+ y: `${topBarY}%`,
93
+ width: `${leftBarWidth}%`,
94
+ height: `${barHeight}%`,
95
+ },
96
+ animation: { type: 'fadeIn', duration: 1 },
97
+ },
98
+
99
+ // Bottom bar label
100
+ {
101
+ type: 'text',
102
+ id: 'right-label',
103
+ content: {
104
+ text: rightLabel,
105
+ style: { fontSize: labelFontSize, color: theme.colors.textMuted },
106
+ },
107
+ position: { x: `${barStartX}%`, y: `${bottomBarY - 8}%` },
108
+ },
109
+
110
+ // Bottom bar value
111
+ {
112
+ type: 'text',
113
+ id: 'right-value',
114
+ content: {
115
+ text: rightDisplay,
116
+ style: { fontSize: valueFontSize, fontWeight: 'bold', color: colors.accent },
117
+ },
118
+ position: { x: `${barStartX + rightBarWidth + 2}%`, y: `${bottomBarY - 1}%` },
119
+ animation: { type: 'fadeIn', duration: 0.8, delay: 0.6 },
120
+ },
121
+
122
+ // Bottom bar
123
+ {
124
+ type: 'shape',
125
+ id: 'right-bar',
126
+ content: {
127
+ shape: 'rect',
128
+ fill: colors.accent,
129
+ borderRadius: 4,
130
+ },
131
+ position: {
132
+ x: `${barStartX}%`,
133
+ y: `${bottomBarY}%`,
134
+ width: `${rightBarWidth}%`,
135
+ height: `${barHeight}%`,
136
+ },
137
+ animation: { type: 'fadeIn', duration: 1, delay: 0.3 },
138
+ },
139
+ ];
140
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Cover Block Template
3
+ *
4
+ * Full-screen hero image with title overlay, shown before playback starts.
5
+ * This is the "poster for the doc, displaying the
6
+ * article's hero image with elegant title treatment.
7
+ *
8
+ * Features:
9
+ * - Full-screen hero image with Ken Burns ambient motion
10
+ * - Gradient overlay for text readability
11
+ * - Large centered title with optional subtitle
12
+ * - No animation delays (shown at rest, not during playback)
13
+ *
14
+ * This is shared code used by both site and efb-app doc renderers.
15
+ */
16
+
17
+ import type { Layer } from '../../schemas/Doc.js';
18
+ import type { TemplateContext } from '../../schemas/BlockTemplates.js';
19
+ import { scaledFontSize } from '../../schemas/BlockTemplates.js';
20
+ import type { StartBlockConfig } from '../../schemas/Doc.js';
21
+ import { mapAmbientMotion } from './accentImage.js';
22
+
23
+ /**
24
+ * Input for coverBlock template - matches StartBlockConfig
25
+ */
26
+ export interface CoverBlockInput {
27
+ /** Path to hero image */
28
+ heroSrc: string;
29
+ /** Alt text for the hero image */
30
+ heroAlt?: string;
31
+ /** Title to display over the hero */
32
+ title: string;
33
+ /** Optional subtitle */
34
+ subtitle?: string;
35
+ /** Ambient motion for the hero image */
36
+ ambientMotion?: 'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight';
37
+ /** Photo credit / artist name */
38
+ heroCredit?: string;
39
+ /** License identifier */
40
+ heroLicense?: string;
41
+ }
42
+
43
+ /**
44
+ * Generate cover block layers from StartBlockConfig.
45
+ */
46
+ export function coverBlock(input: CoverBlockInput, context: TemplateContext): Layer[] {
47
+ const { heroSrc, heroAlt, title, subtitle, ambientMotion, heroCredit, heroLicense } = input;
48
+ const { theme, layout } = context;
49
+
50
+ // Scale font sizes for viewport - use larger sizes for the cover
51
+ const titleFontSize = scaledFontSize(96, context, true);
52
+ const subtitleFontSize = scaledFontSize(36, context, false);
53
+
54
+ // Determine Ken Burns animation
55
+ const imageAnimation = mapAmbientMotion(ambientMotion);
56
+
57
+ const layers: Layer[] = [
58
+ // Full-screen hero image
59
+ {
60
+ type: 'image',
61
+ id: 'cover-hero',
62
+ content: {
63
+ src: heroSrc,
64
+ alt: heroAlt || title,
65
+ fit: 'cover',
66
+ credit: heroCredit,
67
+ license: heroLicense,
68
+ },
69
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
70
+ animation: imageAnimation,
71
+ },
72
+ // Gradient overlay for text readability - from bottom
73
+ {
74
+ type: 'shape',
75
+ id: 'cover-gradient',
76
+ content: {
77
+ shape: 'rect',
78
+ 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%)',
79
+ },
80
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
81
+ },
82
+ // Title - positioned lower in the frame to work with hero composition
83
+ {
84
+ type: 'text',
85
+ id: 'cover-title',
86
+ content: {
87
+ text: title,
88
+ style: {
89
+ fontSize: titleFontSize,
90
+ fontWeight: 'bold',
91
+ color: theme.colors.text,
92
+ textAlign: 'center',
93
+ shadow: true,
94
+ },
95
+ },
96
+ position: {
97
+ x: '50%',
98
+ y: subtitle ? '70%' : '75%',
99
+ anchor: 'center',
100
+ width: layout.maxTextWidth,
101
+ },
102
+ // No animation delay - shown immediately at rest
103
+ animation: { type: 'fadeIn', duration: 0.8 },
104
+ },
105
+ ];
106
+
107
+ // Add subtitle if provided
108
+ if (subtitle) {
109
+ layers.push({
110
+ type: 'text',
111
+ id: 'cover-subtitle',
112
+ content: {
113
+ text: subtitle,
114
+ style: {
115
+ fontSize: subtitleFontSize,
116
+ color: theme.colors.textMuted,
117
+ textAlign: 'center',
118
+ lineHeight: 1.5,
119
+ },
120
+ },
121
+ position: {
122
+ x: '50%',
123
+ y: '82%',
124
+ anchor: 'center',
125
+ width: layout.maxTextWidth,
126
+ },
127
+ animation: { type: 'fadeIn', duration: 0.8, delay: 0.2 },
128
+ });
129
+ }
130
+
131
+ return layers;
132
+ }
133
+
134
+ /**
135
+ * Convert StartBlockConfig to CoverBlockInput.
136
+ */
137
+ export function startBlockToCoverInput(config: StartBlockConfig): CoverBlockInput {
138
+ return {
139
+ heroSrc: config.heroSrc,
140
+ heroAlt: config.heroAlt,
141
+ title: config.title,
142
+ subtitle: config.subtitle,
143
+ ambientMotion: config.ambientMotion,
144
+ heroCredit: config.heroCredit,
145
+ heroLicense: config.heroLicense,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Expand a StartBlockConfig into a renderable Block.
151
+ * This is used by the player to render the cover block at rest.
152
+ */
153
+ export function expandCoverBlock(config: StartBlockConfig, context: TemplateContext): Layer[] {
154
+ const input = startBlockToCoverInput(config);
155
+ return coverBlock(input, context);
156
+ }