@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.
- package/dist/Types-sh2VRxfo.d.ts +15 -0
- package/dist/chunk-7UDSRZKG.js +103 -0
- package/dist/chunk-7UDSRZKG.js.map +1 -0
- package/dist/chunk-O5HCIUAD.js +577 -0
- package/dist/chunk-O5HCIUAD.js.map +1 -0
- package/dist/chunk-PJ7AODIM.js +3040 -0
- package/dist/chunk-PJ7AODIM.js.map +1 -0
- package/dist/chunk-QWVRN6I4.js +81 -0
- package/dist/chunk-QWVRN6I4.js.map +1 -0
- package/dist/chunk-S4F2RY37.js +92 -0
- package/dist/chunk-S4F2RY37.js.map +1 -0
- package/dist/chunk-URU6QMLY.js +888 -0
- package/dist/chunk-URU6QMLY.js.map +1 -0
- package/dist/chunk-VJN7UB2Z.js +145 -0
- package/dist/chunk-VJN7UB2Z.js.map +1 -0
- package/dist/doc/index.d.ts +3 -0
- package/dist/doc/index.js +126 -0
- package/dist/doc/index.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +217 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown/index.d.ts +237 -0
- package/dist/markdown/index.js +33 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/schemas/index.d.ts +70 -0
- package/dist/schemas/index.js +61 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/spatial/index.d.ts +111 -0
- package/dist/spatial/index.js +25 -0
- package/dist/spatial/index.js.map +1 -0
- package/dist/storage/index.d.ts +91 -0
- package/dist/storage/index.js +11 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/story/index.d.ts +691 -0
- package/dist/story/index.js +126 -0
- package/dist/story/index.js.map +1 -0
- package/dist/themeLibrary-DySHPcgj.d.ts +1352 -0
- package/dist/types-DHiv_Pnm.d.ts +496 -0
- package/package.json +91 -0
- package/src/__tests__/animationUtils.test.ts +77 -0
- package/src/__tests__/geohash.test.ts +109 -0
- package/src/__tests__/getLayers.test.ts +200 -0
- package/src/__tests__/haversine.test.ts +63 -0
- package/src/__tests__/localForageAdapter.test.ts +155 -0
- package/src/__tests__/markdown.test.ts +806 -0
- package/src/__tests__/markdownToDoc.test.ts +374 -0
- package/src/__tests__/storage.test.ts +60 -0
- package/src/__tests__/templates.test.ts +247 -0
- package/src/doc/docToMarkdown.ts +98 -0
- package/src/doc/getLayers.ts +152 -0
- package/src/doc/index.ts +8 -0
- package/src/doc/markdownToDoc.ts +277 -0
- package/src/doc/templates/accentImage.ts +338 -0
- package/src/doc/templates/captionUtils.ts +33 -0
- package/src/doc/templates/comparisonBar.ts +140 -0
- package/src/doc/templates/coverBlock.ts +156 -0
- package/src/doc/templates/dateEvent.ts +122 -0
- package/src/doc/templates/definitionCard.ts +129 -0
- package/src/doc/templates/factCard.ts +114 -0
- package/src/doc/templates/fullBleedQuote.ts +59 -0
- package/src/doc/templates/imageWithCaption.ts +167 -0
- package/src/doc/templates/index.ts +571 -0
- package/src/doc/templates/listBlock.ts +101 -0
- package/src/doc/templates/mapBlock.ts +132 -0
- package/src/doc/templates/persistentLayers.ts +522 -0
- package/src/doc/templates/photoGrid.ts +199 -0
- package/src/doc/templates/pullQuote.ts +119 -0
- package/src/doc/templates/quoteBlock.ts +112 -0
- package/src/doc/templates/sectionHeader.ts +105 -0
- package/src/doc/templates/statHighlight.ts +113 -0
- package/src/doc/templates/titleBlock.ts +100 -0
- package/src/doc/templates/twoColumn.ts +239 -0
- package/src/doc/templates/videoPullQuote.ts +128 -0
- package/src/doc/templates/videoWithCaption.ts +126 -0
- package/src/doc/utils/animationUtils.ts +135 -0
- package/src/doc/utils/themeUtils.ts +139 -0
- package/src/index.ts +5 -0
- package/src/markdown/convert.ts +897 -0
- package/src/markdown/htmlParse.ts +212 -0
- package/src/markdown/index.ts +110 -0
- package/src/markdown/parse.ts +103 -0
- package/src/markdown/stringify.ts +122 -0
- package/src/markdown/types.ts +605 -0
- package/src/markdown/utils.ts +165 -0
- package/src/ngeohash.d.ts +11 -0
- package/src/schemas/BlockTemplates.ts +706 -0
- package/src/schemas/Doc.ts +565 -0
- package/src/schemas/LayoutStrategy.ts +178 -0
- package/src/schemas/MediaProvider.ts +70 -0
- package/src/schemas/Theme.ts +235 -0
- package/src/schemas/Types.ts +15 -0
- package/src/schemas/Viewport.ts +91 -0
- package/src/schemas/index.ts +14 -0
- package/src/schemas/themeLibrary.ts +492 -0
- package/src/spatial/Geohash.ts +168 -0
- package/src/spatial/Haversine.ts +59 -0
- package/src/spatial/index.ts +2 -0
- package/src/storage/LocalForageAdapter.ts +103 -0
- package/src/storage/LocalStorageAdapter.ts +57 -0
- package/src/storage/MemoryStorageAdapter.ts +37 -0
- package/src/storage/Storage.ts +28 -0
- 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
|
+
}
|