@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,199 @@
1
+ /**
2
+ * Photo Grid Template
3
+ *
4
+ * Displays 2-4 images in a tiled layout for visual variety.
5
+ * Layout adapts based on image count and viewport orientation:
6
+ *
7
+ * Landscape:
8
+ * - 2 images: side-by-side 50/50
9
+ * - 3 images: one large left (60%) + two stacked right
10
+ * - 4 images: 2x2 grid
11
+ *
12
+ * Portrait (9:16): images stack horizontally (top/bottom) instead of
13
+ * side-by-side, giving each image full width on narrow screens.
14
+ *
15
+ * This is shared code used by both site and efb-app doc renderers.
16
+ */
17
+
18
+ import type { Layer } from '../../schemas/Doc.js';
19
+ import type { PhotoGridInput, TemplateContext } from '../../schemas/BlockTemplates.js';
20
+ import { scaledFontSize } from '../../schemas/BlockTemplates.js';
21
+ import { cleanCaption } from './captionUtils.js';
22
+
23
+ /** Gap between images as percentage */
24
+ const GAP = 0.5;
25
+
26
+ export function photoGrid(input: PhotoGridInput, context: TemplateContext): Layer[] {
27
+ const { images, caption: rawCaption, ambientMotion } = input;
28
+
29
+ // Guard: images array is required
30
+ if (!images || images.length === 0) return [];
31
+
32
+ const caption = rawCaption ? cleanCaption(rawCaption) : rawCaption;
33
+ const { theme, layout } = context;
34
+
35
+ const layers: Layer[] = [
36
+ // Black background (visible in gaps between images)
37
+ {
38
+ type: 'shape',
39
+ id: 'bg',
40
+ content: { shape: 'rect', fill: '#000000' },
41
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
42
+ },
43
+ ];
44
+
45
+ // Reserve bottom space for caption — more room in landscape for larger text
46
+ const captionHeight = caption ? (layout.stackColumns ? 18 : 22) : 0;
47
+ const gridHeight = 100 - captionHeight;
48
+
49
+ // Generate image positions based on count; stack vertically in portrait
50
+ const positions = getGridPositions(images.length, gridHeight, layout.stackColumns);
51
+
52
+ const altFontSize = scaledFontSize(24, context, false);
53
+
54
+ for (let i = 0; i < Math.min(images.length, 4); i++) {
55
+ const img = images[i];
56
+ const pos = positions[i];
57
+
58
+ // Placeholder behind image (visible if image fails to load)
59
+ layers.push({
60
+ type: 'shape',
61
+ id: `grid-placeholder-${i}`,
62
+ content: { shape: 'rect', fill: theme.colors.background },
63
+ position: { x: `${pos.x}%`, y: `${pos.y}%`, width: `${pos.w}%`, height: `${pos.h}%` },
64
+ });
65
+
66
+ // Alt-text label on placeholder
67
+ if (img.alt) {
68
+ layers.push({
69
+ type: 'text',
70
+ id: `grid-alt-${i}`,
71
+ content: {
72
+ text: img.alt,
73
+ style: {
74
+ fontSize: altFontSize,
75
+ color: theme.colors.textMuted,
76
+ textAlign: 'center' as const,
77
+ },
78
+ },
79
+ position: { x: `${pos.x + pos.w / 2}%`, y: `${pos.y + pos.h / 2}%`, anchor: 'center' },
80
+ });
81
+ }
82
+
83
+ // Image layer (covers placeholder when loaded)
84
+ layers.push({
85
+ type: 'image',
86
+ id: `grid-img-${i}`,
87
+ content: {
88
+ src: img.src,
89
+ alt: img.alt,
90
+ fit: 'cover',
91
+ credit: img.credit,
92
+ license: img.license,
93
+ },
94
+ position: {
95
+ x: `${pos.x}%`,
96
+ y: `${pos.y}%`,
97
+ width: `${pos.w}%`,
98
+ height: `${pos.h}%`,
99
+ },
100
+ // Apply ambient motion to the first (largest) image only
101
+ animation:
102
+ i === 0 && ambientMotion
103
+ ? { type: ambientMotion, duration: 15 }
104
+ : { type: 'fadeIn', duration: 1, delay: 0.2 * i },
105
+ });
106
+ }
107
+
108
+ // Caption with gradient overlay
109
+ if (caption) {
110
+ const captionFontSize = scaledFontSize(layout.stackColumns ? 28 : 40, context, false);
111
+
112
+ layers.push({
113
+ type: 'shape',
114
+ id: 'caption-bg',
115
+ content: {
116
+ shape: 'rect',
117
+ fill: 'linear-gradient(transparent, rgba(0,0,0,0.8))',
118
+ },
119
+ position: { x: 0, y: `${gridHeight - 4}%`, width: '100%', height: `${captionHeight + 4}%` },
120
+ });
121
+
122
+ layers.push({
123
+ type: 'text',
124
+ id: 'caption',
125
+ content: {
126
+ text: caption,
127
+ style: {
128
+ fontSize: captionFontSize,
129
+ color: theme.colors.text,
130
+ textAlign: 'center',
131
+ shadow: true,
132
+ },
133
+ },
134
+ position: {
135
+ x: '50%',
136
+ y: `${gridHeight + (layout.stackColumns ? captionHeight / 2 : captionHeight * 0.3)}%`,
137
+ anchor: 'center',
138
+ width: '90%',
139
+ },
140
+ animation: { type: 'fadeIn', duration: 1, delay: 0.5 },
141
+ });
142
+ }
143
+
144
+ return layers;
145
+ }
146
+
147
+ interface GridPos {
148
+ x: number;
149
+ y: number;
150
+ w: number;
151
+ h: number;
152
+ }
153
+
154
+ function getGridPositions(count: number, gridHeight: number, stacked: boolean): GridPos[] {
155
+ const halfW = 50 - GAP / 2;
156
+ const halfH = gridHeight / 2 - GAP / 2;
157
+
158
+ switch (count) {
159
+ case 2:
160
+ if (stacked) {
161
+ // Portrait: stack top/bottom
162
+ return [
163
+ { x: 0, y: 0, w: 100, h: halfH },
164
+ { x: 0, y: gridHeight / 2 + GAP / 2, w: 100, h: halfH },
165
+ ];
166
+ }
167
+ return [
168
+ { x: 0, y: 0, w: halfW, h: gridHeight },
169
+ { x: 50 + GAP / 2, y: 0, w: halfW, h: gridHeight },
170
+ ];
171
+ case 3: {
172
+ if (stacked) {
173
+ // Portrait: stack all three vertically
174
+ const thirdH = gridHeight / 3 - (GAP * 2) / 3;
175
+ return [
176
+ { x: 0, y: 0, w: 100, h: thirdH },
177
+ { x: 0, y: gridHeight / 3 + GAP / 3, w: 100, h: thirdH },
178
+ { x: 0, y: (gridHeight * 2) / 3 + (GAP * 2) / 3, w: 100, h: thirdH },
179
+ ];
180
+ }
181
+ const leftW = 60 - GAP / 2;
182
+ const rightW = 40 - GAP / 2;
183
+ return [
184
+ { x: 0, y: 0, w: leftW, h: gridHeight },
185
+ { x: 60 + GAP / 2, y: 0, w: rightW, h: halfH },
186
+ { x: 60 + GAP / 2, y: gridHeight / 2 + GAP / 2, w: rightW, h: halfH },
187
+ ];
188
+ }
189
+ case 4:
190
+ default:
191
+ // 2x2 grid works well in both orientations
192
+ return [
193
+ { x: 0, y: 0, w: halfW, h: halfH },
194
+ { x: 50 + GAP / 2, y: 0, w: halfW, h: halfH },
195
+ { x: 0, y: gridHeight / 2 + GAP / 2, w: halfW, h: halfH },
196
+ { x: 50 + GAP / 2, y: gridHeight / 2 + GAP / 2, w: halfW, h: halfH },
197
+ ];
198
+ }
199
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Pull Quote Template
3
+ *
4
+ * Quote text over a full-bleed background image with dark overlay.
5
+ * Cinematic alternative to quoteBlock when a high-quality image is available.
6
+ * Combines the visual impact of imageWithCaption with the text focus of quoteBlock.
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 { PullQuoteInput, TemplateContext } from '../../schemas/BlockTemplates.js';
13
+ import { scaledFontSize } from '../../schemas/BlockTemplates.js';
14
+
15
+ export function pullQuote(input: PullQuoteInput, context: TemplateContext): Layer[] {
16
+ const { text, attribution, backgroundImage, ambientMotion } = input;
17
+
18
+ // Guard: backgroundImage is required
19
+ if (!backgroundImage?.src) return [];
20
+
21
+ const quoteFontSize = scaledFontSize(52, context, true);
22
+ const attrFontSize = scaledFontSize(24, context, false);
23
+ const decoFontSize = scaledFontSize(200, context, true);
24
+
25
+ const layers: Layer[] = [
26
+ // Full-bleed background image
27
+ {
28
+ type: 'image',
29
+ id: 'bg-image',
30
+ content: {
31
+ src: backgroundImage.src,
32
+ alt: backgroundImage.alt,
33
+ fit: 'cover',
34
+ credit: backgroundImage.credit,
35
+ license: backgroundImage.license,
36
+ },
37
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
38
+ animation: ambientMotion ? { type: ambientMotion, duration: 15 } : undefined,
39
+ },
40
+
41
+ // Dark overlay for text readability
42
+ {
43
+ type: 'shape',
44
+ id: 'overlay',
45
+ content: {
46
+ shape: 'rect',
47
+ fill: 'rgba(0, 0, 0, 0.55)',
48
+ },
49
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
50
+ },
51
+
52
+ // Decorative quotation mark
53
+ {
54
+ type: 'text',
55
+ id: 'deco-quote',
56
+ content: {
57
+ text: '\u201C',
58
+ style: {
59
+ fontSize: decoFontSize,
60
+ color: 'rgba(255, 255, 255, 0.08)',
61
+ textAlign: 'center',
62
+ },
63
+ },
64
+ position: {
65
+ x: '50%',
66
+ y: '20%',
67
+ anchor: 'center',
68
+ },
69
+ },
70
+
71
+ // Quote text
72
+ {
73
+ type: 'text',
74
+ id: 'quote-text',
75
+ content: {
76
+ text,
77
+ style: {
78
+ fontSize: quoteFontSize,
79
+ color: '#ffffff',
80
+ textAlign: 'center',
81
+ lineHeight: 1.5,
82
+ shadow: true,
83
+ },
84
+ },
85
+ position: {
86
+ x: '50%',
87
+ y: attribution ? '45%' : '50%',
88
+ anchor: 'center',
89
+ width: '80%',
90
+ },
91
+ animation: { type: 'fadeIn', duration: 2 },
92
+ },
93
+ ];
94
+
95
+ // Attribution
96
+ if (attribution) {
97
+ layers.push({
98
+ type: 'text',
99
+ id: 'attribution',
100
+ content: {
101
+ text: `\u2014 ${attribution}`,
102
+ style: {
103
+ fontSize: attrFontSize,
104
+ color: 'rgba(255, 255, 255, 0.7)',
105
+ textAlign: 'center',
106
+ shadow: true,
107
+ },
108
+ },
109
+ position: {
110
+ x: '50%',
111
+ y: '72%',
112
+ anchor: 'center',
113
+ },
114
+ animation: { type: 'fadeIn', duration: 1, delay: 1.5 },
115
+ });
116
+ }
117
+
118
+ return layers;
119
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Quote Block Template
3
+ *
4
+ * Large centered quote with optional attribution.
5
+ * Good for impactful statements or descriptions.
6
+ * Adapts font sizes and positioning for different viewports.
7
+ *
8
+ * Supports optional accent images that appear as tasteful side/bottom strips.
9
+ *
10
+ * This is shared code used by both site and efb-app doc renderers.
11
+ */
12
+
13
+ import type { Layer } from '../../schemas/Doc.js';
14
+ import type { QuoteBlockInput, TemplateContext } from '../../schemas/BlockTemplates.js';
15
+ import { scaledFontSize } from '../../schemas/BlockTemplates.js';
16
+ import { createAccentLayers, getAccentLayout, adjustY, DEFAULT_LAYOUT } from './accentImage.js';
17
+ import { createBackgroundLayer } from './captionUtils.js';
18
+
19
+ export function quoteBlock(input: QuoteBlockInput, context: TemplateContext): Layer[] {
20
+ const { quote, attribution, accentImage } = input;
21
+ const { theme, layout } = context;
22
+
23
+ // Get layout adjustments if accent image is present
24
+ const accentLayout = accentImage ? getAccentLayout(accentImage.position) : DEFAULT_LAYOUT;
25
+
26
+ // Scale font sizes for viewport
27
+ const quoteFontSize = scaledFontSize(48, context, true);
28
+ const attrFontSize = scaledFontSize(24, context, false);
29
+
30
+ // Decorative quotation mark font size
31
+ const decorativeQuoteFontSize = scaledFontSize(280, context, true);
32
+
33
+ const layers: Layer[] = [
34
+ createBackgroundLayer(
35
+ 'bg',
36
+ `linear-gradient(160deg, ${theme.colors.backgroundLight} 0%, #1e2636 100%)`,
37
+ ),
38
+ ];
39
+
40
+ // Add accent image layers (behind text, after background)
41
+ if (accentImage) {
42
+ layers.push(...createAccentLayers(accentImage, input.id));
43
+ }
44
+
45
+ // Decorative opening quotation mark — oversized, low-opacity behind quote
46
+ layers.push({
47
+ type: 'text',
48
+ id: 'deco-quote',
49
+ content: {
50
+ text: '\u201C',
51
+ style: {
52
+ fontSize: decorativeQuoteFontSize,
53
+ color: 'rgba(255, 255, 255, 0.06)',
54
+ textAlign: 'center',
55
+ },
56
+ },
57
+ position: {
58
+ x: accentLayout.textCenterX,
59
+ y: adjustY('18%', accentLayout),
60
+ anchor: 'center',
61
+ },
62
+ });
63
+
64
+ // Quote text - positioned based on accent layout
65
+ const quoteY = attribution ? layout.primaryY : '50%';
66
+ layers.push({
67
+ type: 'text',
68
+ id: 'quote',
69
+ content: {
70
+ text: quote,
71
+ style: {
72
+ fontSize: quoteFontSize,
73
+ color: theme.colors.text,
74
+ textAlign: 'center',
75
+ lineHeight: 1.6,
76
+ shadow: true,
77
+ },
78
+ },
79
+ position: {
80
+ x: accentLayout.textCenterX,
81
+ y: adjustY(quoteY, accentLayout),
82
+ anchor: 'center',
83
+ width: accentLayout.textWidth,
84
+ },
85
+ animation: { type: 'fadeIn', duration: 2 },
86
+ });
87
+
88
+ // Add attribution if provided
89
+ if (attribution) {
90
+ layers.push({
91
+ type: 'text',
92
+ id: 'attribution',
93
+ content: {
94
+ text: `— ${attribution}`,
95
+ style: {
96
+ fontSize: attrFontSize,
97
+ color: theme.colors.textMuted,
98
+ textAlign: 'center',
99
+ shadow: !!accentImage,
100
+ },
101
+ },
102
+ position: {
103
+ x: accentLayout.textCenterX,
104
+ y: adjustY(layout.captionY, accentLayout),
105
+ anchor: 'center',
106
+ },
107
+ animation: { type: 'fadeIn', duration: 1, delay: 1.5 },
108
+ });
109
+ }
110
+
111
+ return layers;
112
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Section Header Template
3
+ *
4
+ * Section title card with optional background image.
5
+ * Used to introduce new sections of a story.
6
+ * When an image is provided, displays like a title slide with the image as background.
7
+ * Without an image, falls back to a colored background.
8
+ *
9
+ * This is shared code used by both site and efb-app doc renderers.
10
+ */
11
+
12
+ import type { Layer, AnimationType } from '../../schemas/Doc.js';
13
+ import type { SectionHeaderInput, TemplateContext } from '../../schemas/BlockTemplates.js';
14
+ import { scaledFontSize } from '../../schemas/BlockTemplates.js';
15
+ import { resolveColorScheme } from '../utils/themeUtils.js';
16
+
17
+ export function sectionHeader(input: SectionHeaderInput, context: TemplateContext): Layer[] {
18
+ const { title, colorScheme = 'blue', imageSrc, imageAlt, ambientMotion } = input;
19
+ const { layout } = context;
20
+ const colors = resolveColorScheme(context, colorScheme);
21
+
22
+ // Scale font sizes for viewport
23
+ const titleFontSize = scaledFontSize(72, context, true);
24
+
25
+ const layers: Layer[] = [];
26
+
27
+ // Background - either image or solid color
28
+ if (imageSrc) {
29
+ // Image background with Ken Burns effect
30
+ layers.push({
31
+ type: 'image',
32
+ id: 'bg-image',
33
+ content: {
34
+ src: imageSrc,
35
+ alt: imageAlt || title,
36
+ fit: 'cover',
37
+ },
38
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
39
+ animation: ambientMotion
40
+ ? { type: ambientMotion as AnimationType, duration: 8 }
41
+ : { type: 'slowZoom', duration: 8, direction: 'in' },
42
+ });
43
+
44
+ // Dark overlay for text readability
45
+ layers.push({
46
+ type: 'shape',
47
+ id: 'overlay',
48
+ content: {
49
+ shape: 'rect',
50
+ fill: 'rgba(0, 0, 0, 0.5)',
51
+ },
52
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
53
+ });
54
+ } else {
55
+ // Solid color background
56
+ layers.push({
57
+ type: 'shape',
58
+ id: 'bg',
59
+ content: {
60
+ shape: 'rect',
61
+ fill: colors.bg,
62
+ },
63
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
64
+ });
65
+
66
+ // Decorative lines above and below title
67
+ layers.push({
68
+ type: 'shape',
69
+ id: 'line-top',
70
+ content: { shape: 'rect', fill: `${colors.text}33` },
71
+ position: { x: '50%', y: '40%', width: '20%', height: '2px', anchor: 'center' },
72
+ });
73
+ layers.push({
74
+ type: 'shape',
75
+ id: 'line-bottom',
76
+ content: { shape: 'rect', fill: `${colors.text}33` },
77
+ position: { x: '50%', y: '60%', width: '20%', height: '2px', anchor: 'center' },
78
+ });
79
+ }
80
+
81
+ // Section title - white text with shadow for readability over images
82
+ layers.push({
83
+ type: 'text',
84
+ id: 'title',
85
+ content: {
86
+ text: title,
87
+ style: {
88
+ fontSize: titleFontSize,
89
+ fontWeight: 'bold',
90
+ color: imageSrc ? '#ffffff' : colors.text,
91
+ textAlign: 'center',
92
+ shadow: true,
93
+ },
94
+ },
95
+ position: {
96
+ x: '50%',
97
+ y: '50%',
98
+ anchor: 'center',
99
+ width: layout.maxTextWidth,
100
+ },
101
+ animation: { type: 'fadeIn', duration: 1.5 },
102
+ });
103
+
104
+ return layers;
105
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Stat Highlight Template
3
+ *
4
+ * Large statistic/number with description and optional detail.
5
+ * Great for emphasizing key data points.
6
+ * Adapts font sizes and positioning for different viewports.
7
+ *
8
+ * Supports optional accent images that appear as tasteful side/bottom strips.
9
+ *
10
+ * This is shared code used by both site and efb-app doc renderers.
11
+ */
12
+
13
+ import type { Layer } from '../../schemas/Doc.js';
14
+ import type { StatHighlightInput, TemplateContext } from '../../schemas/BlockTemplates.js';
15
+ import { scaledFontSize } from '../../schemas/BlockTemplates.js';
16
+ import { resolveColorScheme } from '../utils/themeUtils.js';
17
+ import { createAccentLayers, getAccentLayout, adjustY, DEFAULT_LAYOUT } from './accentImage.js';
18
+ import { createBackgroundLayer } from './captionUtils.js';
19
+
20
+ export function statHighlight(input: StatHighlightInput, context: TemplateContext): Layer[] {
21
+ const { stat, description, detail, colorScheme = 'blue', accentImage } = input;
22
+ const { theme } = context;
23
+ const colors = resolveColorScheme(context, colorScheme);
24
+
25
+ // Get layout adjustments if accent image is present
26
+ const accentLayout = accentImage ? getAccentLayout(accentImage.position) : DEFAULT_LAYOUT;
27
+
28
+ // Scale font sizes — stat is dramatically large, description is understated
29
+ const statFontSize = scaledFontSize(148, context, true);
30
+ const descFontSize = scaledFontSize(32, context, false);
31
+ const detailFontSize = scaledFontSize(26, context, false);
32
+
33
+ const layers: Layer[] = [
34
+ createBackgroundLayer(
35
+ 'bg',
36
+ `linear-gradient(180deg, ${theme.colors.background} 0%, #0f1520 100%)`,
37
+ ),
38
+ ];
39
+
40
+ // Add accent image layers (behind text, after background)
41
+ if (accentImage) {
42
+ layers.push(...createAccentLayers(accentImage, input.id));
43
+ }
44
+
45
+ // Big stat — hero element, dominates the slide
46
+ layers.push({
47
+ type: 'text',
48
+ id: 'stat',
49
+ content: {
50
+ text: stat,
51
+ style: {
52
+ fontSize: statFontSize,
53
+ fontWeight: 'bold',
54
+ color: colors.text,
55
+ shadow: !!accentImage,
56
+ },
57
+ },
58
+ position: {
59
+ x: accentLayout.textCenterX,
60
+ y: adjustY('32%', accentLayout),
61
+ anchor: 'center',
62
+ },
63
+ animation: { type: 'zoomIn', duration: 0.6 },
64
+ });
65
+
66
+ // Description — smaller and understated beneath the stat
67
+ layers.push({
68
+ type: 'text',
69
+ id: 'description',
70
+ content: {
71
+ text: description,
72
+ style: {
73
+ fontSize: descFontSize,
74
+ color: theme.colors.textMuted,
75
+ textAlign: 'center',
76
+ lineHeight: 1.6,
77
+ shadow: !!accentImage,
78
+ },
79
+ },
80
+ position: {
81
+ x: accentLayout.textCenterX,
82
+ y: adjustY('58%', accentLayout),
83
+ width: accentLayout.textWidth,
84
+ anchor: 'center',
85
+ },
86
+ animation: { type: 'fadeIn', duration: 1, delay: 0.3 },
87
+ });
88
+
89
+ // Add detail if provided
90
+ if (detail) {
91
+ layers.push({
92
+ type: 'text',
93
+ id: 'detail',
94
+ content: {
95
+ text: detail,
96
+ style: {
97
+ fontSize: detailFontSize,
98
+ color: colors.accent,
99
+ textAlign: 'center',
100
+ shadow: !!accentImage,
101
+ },
102
+ },
103
+ position: {
104
+ x: accentLayout.textCenterX,
105
+ y: adjustY('75%', accentLayout),
106
+ anchor: 'center',
107
+ },
108
+ animation: { type: 'fadeIn', duration: 1, delay: 1 },
109
+ });
110
+ }
111
+
112
+ return layers;
113
+ }