@bendyline/squisq 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/dist/{Doc-BeSeH2iX.d.ts → Doc-BsMkmzH8.d.ts} +838 -268
  2. package/dist/{ImageEditDoc-BTIvw0oq.d.ts → ImageEditDoc-DvoeHMP9.d.ts} +11 -4
  3. package/dist/{chunk-ZTX4PFFT.js → chunk-2OIBZYKP.js} +75 -2
  4. package/dist/chunk-2OIBZYKP.js.map +1 -0
  5. package/dist/chunk-2UQOHYE2.js +45 -0
  6. package/dist/chunk-2UQOHYE2.js.map +1 -0
  7. package/dist/{chunk-B4EEON3N.js → chunk-4AKUQPEE.js} +536 -103
  8. package/dist/chunk-4AKUQPEE.js.map +1 -0
  9. package/dist/chunk-4ZFRZBU2.js +6582 -0
  10. package/dist/chunk-4ZFRZBU2.js.map +1 -0
  11. package/dist/{chunk-57CVQCFX.js → chunk-7MQGNR76.js} +79 -3
  12. package/dist/chunk-7MQGNR76.js.map +1 -0
  13. package/dist/{chunk-5WFRKURX.js → chunk-DLXZMS5K.js} +4 -2
  14. package/dist/chunk-DLXZMS5K.js.map +1 -0
  15. package/dist/{chunk-MYIH7FWD.js → chunk-FBKP5CAO.js} +1 -1
  16. package/dist/chunk-FBKP5CAO.js.map +1 -0
  17. package/dist/chunk-KZNXZ5SM.js +333 -0
  18. package/dist/chunk-KZNXZ5SM.js.map +1 -0
  19. package/dist/{chunk-WMOI7FN3.js → chunk-LRCV7N7F.js} +12 -2
  20. package/dist/{chunk-WMOI7FN3.js.map → chunk-LRCV7N7F.js.map} +1 -1
  21. package/dist/{chunk-3K5OG6KI.js → chunk-OH43RPTP.js} +403 -187
  22. package/dist/chunk-OH43RPTP.js.map +1 -0
  23. package/dist/{chunk-H3AXU4MJ.js → chunk-OKJOQXK4.js} +149 -2
  24. package/dist/chunk-OKJOQXK4.js.map +1 -0
  25. package/dist/chunk-PGGGKYWG.js +1 -0
  26. package/dist/chunk-PGGGKYWG.js.map +1 -0
  27. package/dist/{chunk-7UDSRZKG.js → chunk-Q3ROPT5H.js} +35 -14
  28. package/dist/chunk-Q3ROPT5H.js.map +1 -0
  29. package/dist/chunk-QE7TWGVT.js +178 -0
  30. package/dist/chunk-QE7TWGVT.js.map +1 -0
  31. package/dist/chunk-RUDYOTA6.js +345 -0
  32. package/dist/chunk-RUDYOTA6.js.map +1 -0
  33. package/dist/chunk-TVSGLUID.js +596 -0
  34. package/dist/chunk-TVSGLUID.js.map +1 -0
  35. package/dist/chunk-ZLQKNOZQ.js +1 -0
  36. package/dist/chunk-ZLQKNOZQ.js.map +1 -0
  37. package/dist/doc/index.d.ts +4 -4
  38. package/dist/doc/index.js +124 -6
  39. package/dist/generate/index.d.ts +9 -2
  40. package/dist/generate/index.js +1 -1
  41. package/dist/icons/index.d.ts +3 -26
  42. package/dist/icons/index.js +13 -0
  43. package/dist/icons/inlineIconMarker.d.ts +1 -0
  44. package/dist/icons/inlineIconMarker.js +15 -0
  45. package/dist/icons/inlineIconMarker.js.map +1 -0
  46. package/dist/imageEdit/index.d.ts +11 -5
  47. package/dist/imageEdit/index.js +4 -1
  48. package/dist/index.d.ts +11 -8
  49. package/dist/index.js +243 -24
  50. package/dist/inlineIconMarker-CiE8Hlvz.d.ts +59 -0
  51. package/dist/markdown/index.d.ts +212 -3
  52. package/dist/markdown/index.js +40 -7
  53. package/dist/recommend/index.d.ts +1 -1
  54. package/dist/recommend/index.js +3 -2
  55. package/dist/schemas/index.d.ts +28 -8
  56. package/dist/schemas/index.js +35 -5
  57. package/dist/spatial/index.d.ts +9 -4
  58. package/dist/spatial/index.js +1 -1
  59. package/dist/story/index.d.ts +906 -40
  60. package/dist/story/index.js +124 -6
  61. package/dist/{themeLibrary-BHjJ0Qx6.d.ts → themeLibrary-DQQKuRMx.d.ts} +1 -1
  62. package/dist/transform/index.d.ts +48 -10
  63. package/dist/transform/index.js +17 -481
  64. package/dist/transform/index.js.map +1 -1
  65. package/dist/{types-BdZkdJ3z.d.ts → types-DlAZ7MW4.d.ts} +115 -1
  66. package/package.json +6 -1
  67. package/src/__tests__/animationUtils.test.ts +40 -0
  68. package/src/__tests__/annotationCoercion.test.ts +243 -0
  69. package/src/__tests__/applyRenderStyle.test.ts +146 -0
  70. package/src/__tests__/atmosphereLayers.test.ts +108 -0
  71. package/src/__tests__/attrTokens.test.ts +162 -0
  72. package/src/__tests__/customTemplateSample.test.ts +83 -0
  73. package/src/__tests__/customTemplatesFrontmatter.test.ts +205 -0
  74. package/src/__tests__/customThemesFrontmatter.test.ts +118 -0
  75. package/src/__tests__/diagramBlock.test.ts +122 -0
  76. package/src/__tests__/diagramLayout.test.ts +134 -0
  77. package/src/__tests__/drawing.test.ts +109 -0
  78. package/src/__tests__/drawingBlock.test.ts +124 -0
  79. package/src/__tests__/drawingLayout.test.ts +171 -0
  80. package/src/__tests__/geohash.test.ts +6 -5
  81. package/src/__tests__/getLayers.test.ts +6 -2
  82. package/src/__tests__/getLayersFallback.test.ts +103 -0
  83. package/src/__tests__/imageEditExportSvg.test.ts +70 -0
  84. package/src/__tests__/imageEditVersions.test.ts +72 -0
  85. package/src/__tests__/inlineIconMarker.test.ts +36 -0
  86. package/src/__tests__/layoutBlock.test.ts +83 -0
  87. package/src/__tests__/layoutLayout.test.ts +169 -0
  88. package/src/__tests__/markdown.test.ts +23 -0
  89. package/src/__tests__/markdownSanitize.test.ts +64 -0
  90. package/src/__tests__/markdownToDoc.test.ts +223 -2
  91. package/src/__tests__/mediaAnnotations.test.ts +64 -0
  92. package/src/__tests__/mediaSchedule.test.ts +118 -0
  93. package/src/__tests__/pandocAttrParse.test.ts +187 -0
  94. package/src/__tests__/resolveDocTheme.test.ts +55 -0
  95. package/src/__tests__/rootExports.test.ts +43 -0
  96. package/src/__tests__/shapeGeometry.test.ts +89 -0
  97. package/src/__tests__/shapeVocabulary.test.ts +133 -0
  98. package/src/__tests__/structuredData.test.ts +179 -0
  99. package/src/__tests__/templateAnnotationParse.test.ts +108 -0
  100. package/src/__tests__/templateMetadata.test.ts +21 -0
  101. package/src/__tests__/templates.test.ts +145 -4
  102. package/src/__tests__/themeCompile.test.ts +42 -0
  103. package/src/__tests__/themeValidator.test.ts +6 -0
  104. package/src/__tests__/transformV2.test.ts +137 -0
  105. package/src/__tests__/transitionNormalize.test.ts +61 -0
  106. package/src/__tests__/validateDoc.test.ts +100 -0
  107. package/src/doc/customTemplatesFrontmatter.ts +308 -0
  108. package/src/doc/customThemesFrontmatter.ts +90 -0
  109. package/src/doc/docToMarkdown.ts +73 -4
  110. package/src/doc/getLayers.ts +40 -17
  111. package/src/doc/index.ts +35 -1
  112. package/src/doc/markdownToDoc.ts +333 -12
  113. package/src/doc/mediaAnnotations.ts +118 -0
  114. package/src/doc/resolveDocTheme.ts +55 -0
  115. package/src/doc/structuredData.ts +230 -0
  116. package/src/doc/templateInputs.ts +240 -0
  117. package/src/doc/templates/__tests__/customTemplate.test.ts +151 -0
  118. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +110 -0
  119. package/src/doc/templates/accentImage.ts +30 -6
  120. package/src/doc/templates/captionUtils.ts +23 -0
  121. package/src/doc/templates/comparisonBar.ts +26 -18
  122. package/src/doc/templates/coverBlock.ts +7 -4
  123. package/src/doc/templates/customTemplate.ts +45 -0
  124. package/src/doc/templates/dataTable.ts +37 -19
  125. package/src/doc/templates/dateEvent.ts +41 -20
  126. package/src/doc/templates/definitionCard.ts +26 -18
  127. package/src/doc/templates/diagramBlock.ts +204 -0
  128. package/src/doc/templates/diagramLayout.ts +159 -0
  129. package/src/doc/templates/drawingBlock.ts +325 -0
  130. package/src/doc/templates/drawingLayout.ts +539 -0
  131. package/src/doc/templates/factCard.ts +51 -25
  132. package/src/doc/templates/fallbackBlock.ts +123 -0
  133. package/src/doc/templates/featureBlock.ts +20 -13
  134. package/src/doc/templates/fullBleedQuote.ts +18 -8
  135. package/src/doc/templates/imageWithCaption.ts +22 -9
  136. package/src/doc/templates/index.ts +215 -46
  137. package/src/doc/templates/layoutBlock.ts +43 -0
  138. package/src/doc/templates/layoutLayout.ts +243 -0
  139. package/src/doc/templates/listBlock.ts +56 -30
  140. package/src/doc/templates/mapBlock.ts +15 -12
  141. package/src/doc/templates/metadata.ts +120 -0
  142. package/src/doc/templates/persistentLayers.ts +129 -11
  143. package/src/doc/templates/photoGrid.ts +23 -12
  144. package/src/doc/templates/pullQuote.ts +30 -12
  145. package/src/doc/templates/quoteBlock.ts +54 -29
  146. package/src/doc/templates/sectionHeader.ts +25 -11
  147. package/src/doc/templates/statHighlight.ts +39 -22
  148. package/src/doc/templates/titleBlock.ts +59 -21
  149. package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +221 -0
  150. package/src/doc/templates/tokens/resolveTokens.ts +0 -0
  151. package/src/doc/templates/twoColumn.ts +48 -30
  152. package/src/doc/templates/videoPullQuote.ts +22 -12
  153. package/src/doc/templates/videoWithCaption.ts +13 -8
  154. package/src/doc/utils/animationUtils.ts +267 -3
  155. package/src/doc/utils/applyRenderStyle.ts +113 -0
  156. package/src/doc/utils/imageTreatment.ts +70 -0
  157. package/src/doc/utils/shapeGeometry.ts +464 -0
  158. package/src/doc/utils/themeUtils.ts +75 -1
  159. package/src/doc/validate.ts +476 -0
  160. package/src/generate/slideshowGenerator.ts +7 -0
  161. package/src/icons/index.ts +8 -0
  162. package/src/icons/inlineIconMarker.ts +73 -0
  163. package/src/imageEdit/export.ts +85 -2
  164. package/src/imageEdit/index.ts +1 -0
  165. package/src/imageEdit/persistence.ts +51 -0
  166. package/src/index.ts +2 -0
  167. package/src/markdown/annotationCoercion.ts +295 -0
  168. package/src/markdown/attrTokens.ts +222 -0
  169. package/src/markdown/convert.ts +229 -26
  170. package/src/markdown/index.ts +36 -0
  171. package/src/markdown/sanitize.ts +298 -0
  172. package/src/markdown/stringify.ts +84 -3
  173. package/src/markdown/types.ts +66 -0
  174. package/src/markdown/utils.ts +49 -1
  175. package/src/recommend/templates.ts +24 -0
  176. package/src/schemas/BlockTemplates.ts +133 -4
  177. package/src/schemas/CustomTemplates.ts +74 -0
  178. package/src/schemas/Doc.ts +353 -29
  179. package/src/schemas/ImageEditDoc.ts +11 -4
  180. package/src/schemas/Media.ts +169 -0
  181. package/src/schemas/Theme.ts +23 -1
  182. package/src/schemas/Transitions.ts +224 -0
  183. package/src/schemas/colorUtils.ts +33 -0
  184. package/src/schemas/fontStacks.ts +5 -2
  185. package/src/schemas/index.ts +3 -0
  186. package/src/schemas/themeCompile.ts +23 -4
  187. package/src/schemas/themeValidator.ts +49 -11
  188. package/src/schemas/themes/cinematic.json +69 -12
  189. package/src/schemas/themes/documentary.json +58 -11
  190. package/src/schemas/themes/gezellig.json +60 -11
  191. package/src/schemas/themes/magazine.json +54 -9
  192. package/src/schemas/themes/morning-light.json +52 -9
  193. package/src/schemas/themes/standard-dark.json +48 -13
  194. package/src/schemas/themes/standard.json +44 -12
  195. package/src/schemas/themes/tech-dark.json +65 -12
  196. package/src/spatial/Geohash.ts +52 -21
  197. package/src/transform/applyTransform.ts +43 -4
  198. package/src/transform/index.ts +1 -0
  199. package/src/transform/registry.ts +44 -13
  200. package/src/transform/styles/dataDriven.ts +2 -1
  201. package/src/transform/styles/documentary.ts +2 -1
  202. package/src/transform/styles/magazine.ts +3 -1
  203. package/src/transform/styles/minimal.ts +2 -1
  204. package/src/transform/styles/narrative.ts +3 -1
  205. package/src/transform/templateSelector.ts +87 -11
  206. package/src/transform/types.ts +35 -2
  207. package/dist/chunk-3K5OG6KI.js.map +0 -1
  208. package/dist/chunk-57CVQCFX.js.map +0 -1
  209. package/dist/chunk-5WFRKURX.js.map +0 -1
  210. package/dist/chunk-7UDSRZKG.js.map +0 -1
  211. package/dist/chunk-B4EEON3N.js.map +0 -1
  212. package/dist/chunk-H3AXU4MJ.js.map +0 -1
  213. package/dist/chunk-KSWJR66U.js +0 -3706
  214. package/dist/chunk-KSWJR66U.js.map +0 -1
  215. package/dist/chunk-MYIH7FWD.js.map +0 -1
  216. package/dist/chunk-ZTX4PFFT.js.map +0 -1
@@ -0,0 +1,221 @@
1
+ /**
2
+ * resolveTokens — placeholder substitution unit tests.
3
+ *
4
+ * Covers each supported token, the escape syntax, and the edge cases
5
+ * called out in the plan (missing data, out-of-range image index,
6
+ * unknown token left literal).
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest';
10
+ import { resolveTokens } from '../resolveTokens';
11
+ import type { Block, TextLayer, ImageLayer } from '../../../../schemas/Doc.js';
12
+
13
+ function textLayer(id: string, text: string): TextLayer {
14
+ return {
15
+ id,
16
+ type: 'text',
17
+ position: { x: 0, y: 0, width: 100, height: 50 },
18
+ content: { text, style: { fontSize: 24, color: '#000' } },
19
+ };
20
+ }
21
+
22
+ function imageLayer(id: string, src: string, alt = ''): ImageLayer {
23
+ return {
24
+ id,
25
+ type: 'image',
26
+ position: { x: 0, y: 0, width: 100, height: 100 },
27
+ content: { src, alt },
28
+ };
29
+ }
30
+
31
+ function block(overrides: Partial<Block> = {}): Block {
32
+ return {
33
+ id: 'b',
34
+ startTime: 0,
35
+ duration: 1,
36
+ audioSegment: 0,
37
+ title: 'Block title',
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ describe('resolveTokens — TextLayer', () => {
43
+ it('substitutes {title} with block.title', () => {
44
+ const layers = resolveTokens([textLayer('a', 'Hello, {title}!')], block({ title: 'World' }));
45
+ const out = layers[0] as TextLayer;
46
+ expect(out.content.text).toBe('Hello, World!');
47
+ });
48
+
49
+ it('collapses {title} to empty when block has no title', () => {
50
+ const layers = resolveTokens([textLayer('a', '[{title}]')], block({ title: undefined }));
51
+ expect((layers[0] as TextLayer).content.text).toBe('[]');
52
+ });
53
+
54
+ it('substitutes {content} from block.contents (plain text of body)', () => {
55
+ const layers = resolveTokens(
56
+ [textLayer('a', 'Body: {content}')],
57
+ block({
58
+ contents: [
59
+ {
60
+ type: 'paragraph',
61
+ children: [{ type: 'text', value: 'Hello there' }],
62
+ },
63
+ ],
64
+ }),
65
+ );
66
+ expect((layers[0] as TextLayer).content.text).toBe('Body: Hello there');
67
+ });
68
+
69
+ it('joins multiple paragraphs in {content} with single spaces', () => {
70
+ const layers = resolveTokens(
71
+ [textLayer('a', '{content}')],
72
+ block({
73
+ contents: [
74
+ { type: 'paragraph', children: [{ type: 'text', value: 'First.' }] },
75
+ { type: 'paragraph', children: [{ type: 'text', value: 'Second.' }] },
76
+ ],
77
+ }),
78
+ );
79
+ expect((layers[0] as TextLayer).content.text).toBe('First. Second.');
80
+ });
81
+
82
+ it('joins child titles for {children}', () => {
83
+ const layers = resolveTokens(
84
+ [textLayer('a', 'Sections: {children}')],
85
+ block({
86
+ children: [
87
+ block({ id: 'c1', title: 'Alpha' }),
88
+ block({ id: 'c2', title: 'Beta' }),
89
+ block({ id: 'c3', title: 'Gamma' }),
90
+ ],
91
+ }),
92
+ );
93
+ expect((layers[0] as TextLayer).content.text).toBe('Sections: Alpha, Beta, Gamma');
94
+ });
95
+
96
+ it('substitutes {image:N} with the Nth image alt text', () => {
97
+ const layers = resolveTokens(
98
+ [textLayer('a', 'Caption: {image:0}')],
99
+ block({
100
+ contents: [
101
+ {
102
+ type: 'paragraph',
103
+ children: [{ type: 'image', url: '/a.png', alt: 'Alpha image' }],
104
+ },
105
+ ],
106
+ }),
107
+ );
108
+ expect((layers[0] as TextLayer).content.text).toBe('Caption: Alpha image');
109
+ });
110
+
111
+ it('preserves {{title}} as literal {title}', () => {
112
+ const layers = resolveTokens(
113
+ [textLayer('a', 'Use {{title}} as a token, or {title} to expand')],
114
+ block({ title: 'My Doc' }),
115
+ );
116
+ expect((layers[0] as TextLayer).content.text).toBe(
117
+ 'Use {title} as a token, or My Doc to expand',
118
+ );
119
+ });
120
+
121
+ it('leaves unknown tokens literal', () => {
122
+ const layers = resolveTokens([textLayer('a', 'Hello {nope} world')], block());
123
+ expect((layers[0] as TextLayer).content.text).toBe('Hello {nope} world');
124
+ });
125
+
126
+ it('substitutes multiple tokens in a single string', () => {
127
+ const layers = resolveTokens(
128
+ [textLayer('a', '{title} — {content}')],
129
+ block({
130
+ title: 'Doc',
131
+ contents: [{ type: 'paragraph', children: [{ type: 'text', value: 'body' }] }],
132
+ }),
133
+ );
134
+ expect((layers[0] as TextLayer).content.text).toBe('Doc — body');
135
+ });
136
+ });
137
+
138
+ describe('resolveTokens — ImageLayer', () => {
139
+ it('substitutes {image:0} with the Nth image URL in src', () => {
140
+ const layers = resolveTokens(
141
+ [imageLayer('a', '{image:0}')],
142
+ block({
143
+ contents: [
144
+ {
145
+ type: 'paragraph',
146
+ children: [{ type: 'image', url: '/hero.png', alt: 'hero' }],
147
+ },
148
+ ],
149
+ }),
150
+ );
151
+ expect(layers).toHaveLength(1);
152
+ expect((layers[0] as ImageLayer).content.src).toBe('/hero.png');
153
+ });
154
+
155
+ it('drops the layer entirely when the requested image index is out of range', () => {
156
+ const layers = resolveTokens(
157
+ [imageLayer('a', '{image:99}'), imageLayer('b', '/keep.png')],
158
+ block({
159
+ contents: [
160
+ {
161
+ type: 'paragraph',
162
+ children: [{ type: 'image', url: '/one.png', alt: '' }],
163
+ },
164
+ ],
165
+ }),
166
+ );
167
+ expect(layers).toHaveLength(1);
168
+ expect(layers[0].id).toBe('b');
169
+ });
170
+
171
+ it('substitutes tokens in the alt text when the src is also a token', () => {
172
+ const layers = resolveTokens(
173
+ [imageLayer('a', '{image:0}', 'Image for {title}')],
174
+ block({
175
+ title: 'My Doc',
176
+ contents: [
177
+ {
178
+ type: 'paragraph',
179
+ children: [{ type: 'image', url: '/x.png', alt: '' }],
180
+ },
181
+ ],
182
+ }),
183
+ );
184
+ const img = layers[0] as ImageLayer;
185
+ expect(img.content.src).toBe('/x.png');
186
+ expect(img.content.alt).toBe('Image for My Doc');
187
+ });
188
+
189
+ it('passes through ImageLayer with no token in src', () => {
190
+ const layers = resolveTokens([imageLayer('a', '/static.png')], block());
191
+ expect((layers[0] as ImageLayer).content.src).toBe('/static.png');
192
+ });
193
+ });
194
+
195
+ describe('resolveTokens — other layer types', () => {
196
+ it('passes ShapeLayer / PathLayer through unchanged', () => {
197
+ const shape = {
198
+ id: 's',
199
+ type: 'shape' as const,
200
+ position: { x: 0, y: 0, width: 50, height: 50 },
201
+ content: { shape: 'rect' as const, fill: '#fff' },
202
+ };
203
+ const path = {
204
+ id: 'p',
205
+ type: 'path' as const,
206
+ position: { x: 0, y: 0, width: 100, height: 100 },
207
+ content: { d: 'M 0 0 L 100 100', stroke: '#000' },
208
+ };
209
+ const layers = resolveTokens([shape, path], block());
210
+ expect(layers).toEqual([shape, path]);
211
+ });
212
+ });
213
+
214
+ describe('resolveTokens — purity', () => {
215
+ it('does not mutate the input layers array or the layers themselves', () => {
216
+ const original = textLayer('a', '{title}');
217
+ const before = JSON.stringify(original);
218
+ resolveTokens([original], block({ title: 'X' }));
219
+ expect(JSON.stringify(original)).toBe(before);
220
+ });
221
+ });
@@ -12,8 +12,14 @@
12
12
 
13
13
  import type { Layer } from '../../schemas/Doc.js';
14
14
  import type { TwoColumnInput, TemplateContext } from '../../schemas/BlockTemplates.js';
15
- import { scaledFontSize } from '../../schemas/BlockTemplates.js';
16
- import { resolveColorScheme, getThemeFont } from '../utils/themeUtils.js';
15
+ import {
16
+ resolveColorScheme,
17
+ getThemeFont,
18
+ themedEntrance,
19
+ themedFontSize,
20
+ themedSurfaceGradient,
21
+ } from '../utils/themeUtils.js';
22
+ import { withAlpha } from '../../schemas/colorUtils.js';
17
23
  import { getTwoColumnPositions } from '../../schemas/LayoutStrategy.js';
18
24
 
19
25
  export function twoColumn(input: TwoColumnInput, context: TemplateContext): Layer[] {
@@ -33,10 +39,17 @@ export function twoColumn(input: TwoColumnInput, context: TemplateContext): Laye
33
39
  // Column width ~42% for side-by-side, ~85% for stacked
34
40
  const columnWidth = isStacked ? '85%' : '42%';
35
41
 
36
- // Calculate font sizes based on viewport
37
- const headerFontSize = scaledFontSize(32, context, false);
38
- const labelFontSize = scaledFontSize(44, context, true);
39
- const sublabelFontSize = scaledFontSize(22, context, false);
42
+ // Calculate font sizes based on viewport. The header is the block's
43
+ // title, so it sits a step ABOVE the column labels in the hierarchy —
44
+ // it used to render smaller and dimmer than the labels, which read as
45
+ // an inverted hierarchy.
46
+ const headerFontSize = themedFontSize(36, context, true);
47
+ const labelFontSize = themedFontSize(44, context, true);
48
+ const sublabelFontSize = themedFontSize(22, context, false);
49
+
50
+ // Panels drop below the header band so the title never overlaps them.
51
+ const panelTop = header && !isStacked ? 22 : 15;
52
+ const panelHeight = header && !isStacked ? 64 : 70;
40
53
 
41
54
  const layers: Layer[] = [
42
55
  // Background — subtle gradient to add depth
@@ -45,38 +58,41 @@ export function twoColumn(input: TwoColumnInput, context: TemplateContext): Laye
45
58
  id: 'bg',
46
59
  content: {
47
60
  shape: 'rect',
48
- fill: `linear-gradient(135deg, ${theme.colors.background} 0%, #16202e 100%)`,
61
+ fill: themedSurfaceGradient(context, 135),
49
62
  },
50
63
  position: { x: 0, y: 0, width: '100%', height: '100%' },
51
64
  },
52
65
  ];
53
66
 
54
67
  if (!isStacked) {
55
- // Left column background panel
68
+ // Column background panels — tinted from the theme text color so
69
+ // they stay visible on light and dark surfaces alike.
70
+ const panelFill = withAlpha(theme.colors.text, 0.05);
56
71
  layers.push({
57
72
  type: 'shape',
58
73
  id: 'left-panel',
59
74
  content: {
60
75
  shape: 'rect',
61
- fill: 'rgba(255, 255, 255, 0.04)',
76
+ fill: panelFill,
77
+ borderRadius: 12,
62
78
  },
63
- position: { x: '3%', y: '15%', width: '44%', height: '70%' },
79
+ position: { x: '3%', y: `${panelTop}%`, width: '44%', height: `${panelHeight}%` },
64
80
  });
65
81
 
66
- // Right column background panel
67
82
  layers.push({
68
83
  type: 'shape',
69
84
  id: 'right-panel',
70
85
  content: {
71
86
  shape: 'rect',
72
- fill: 'rgba(255, 255, 255, 0.04)',
87
+ fill: panelFill,
88
+ borderRadius: 12,
73
89
  },
74
- position: { x: '53%', y: '15%', width: '44%', height: '70%' },
90
+ position: { x: '53%', y: `${panelTop}%`, width: '44%', height: `${panelHeight}%` },
75
91
  });
76
92
  }
77
93
 
78
- // Header position adjusts based on layout
79
- const headerY = isStacked ? '12%' : '22%';
94
+ // Header position adjusts based on layout — always clear of the panels.
95
+ const headerY = isStacked ? '8%' : '12%';
80
96
 
81
97
  // Add header if provided
82
98
  if (header) {
@@ -88,12 +104,13 @@ export function twoColumn(input: TwoColumnInput, context: TemplateContext): Laye
88
104
  style: {
89
105
  fontSize: headerFontSize,
90
106
  fontFamily: getThemeFont(context, 'title'),
91
- color: theme.colors.textMuted,
107
+ fontWeight: 'bold',
108
+ color: theme.colors.text,
92
109
  textAlign: 'center',
93
110
  },
94
111
  },
95
112
  position: { x: '50%', y: headerY, anchor: 'center' },
96
- animation: { type: 'fadeIn', duration: 0.8 },
113
+ animation: themedEntrance(context, 'text', { type: 'fadeIn', duration: 0.8 }),
97
114
  });
98
115
  }
99
116
 
@@ -110,13 +127,12 @@ export function twoColumn(input: TwoColumnInput, context: TemplateContext): Laye
110
127
  rightY = header ? '60%' : '55%';
111
128
  rightSublabelY = header ? '70%' : '65%';
112
129
  } else {
113
- // Landscape/square: side by side — vertically centered in panels (15%-85%)
114
- const baseY = header ? '47%' : '45%';
115
- const sublabelOffset = header ? '58%' : '56%';
116
- leftY = baseY;
117
- leftSublabelY = sublabelOffset;
118
- rightY = baseY;
119
- rightSublabelY = sublabelOffset;
130
+ // Landscape/square: side by side — vertically centered in the panels.
131
+ const panelCenter = panelTop + panelHeight / 2;
132
+ leftY = `${panelCenter - 4}%`;
133
+ leftSublabelY = `${panelCenter + 7}%`;
134
+ rightY = `${panelCenter - 4}%`;
135
+ rightSublabelY = `${panelCenter + 7}%`;
120
136
  }
121
137
 
122
138
  // Left column (or top in portrait)
@@ -140,7 +156,7 @@ export function twoColumn(input: TwoColumnInput, context: TemplateContext): Laye
140
156
  anchor: 'center',
141
157
  width: columnWidth,
142
158
  },
143
- animation: { type: 'fadeIn', duration: 1 },
159
+ animation: themedEntrance(context, 'text', { type: 'fadeIn', duration: 1 }),
144
160
  });
145
161
 
146
162
  if (left.sublabel) {
@@ -167,9 +183,11 @@ export function twoColumn(input: TwoColumnInput, context: TemplateContext): Laye
167
183
  });
168
184
  }
169
185
 
170
- // Visual connector between columns (bold arrow)
171
- const connectorFontSize = scaledFontSize(120, context, true);
172
- const connectorY = isStacked ? '48%' : '50%'; // Centered in viewport
186
+ // Visual connector between columns sized to fit inside the gutter
187
+ // between the panels (a 120px glyph used to cross both panel edges),
188
+ // tinted from the theme text color so it shows on any surface.
189
+ const connectorFontSize = themedFontSize(64, context, true);
190
+ const connectorY = isStacked ? '48%' : `${panelTop + panelHeight / 2}%`;
173
191
  const connectorSymbol = isStacked ? '\u2193' : '\u2192'; // ↓ for stacked, → for side-by-side
174
192
 
175
193
  layers.push({
@@ -181,7 +199,7 @@ export function twoColumn(input: TwoColumnInput, context: TemplateContext): Laye
181
199
  fontSize: connectorFontSize,
182
200
  fontFamily: getThemeFont(context, 'title'),
183
201
  fontWeight: 'bold',
184
- color: 'rgba(255, 255, 255, 0.6)',
202
+ color: withAlpha(theme.colors.text, 0.45),
185
203
  textAlign: 'center',
186
204
  },
187
205
  },
@@ -214,7 +232,7 @@ export function twoColumn(input: TwoColumnInput, context: TemplateContext): Laye
214
232
  anchor: 'center',
215
233
  width: columnWidth,
216
234
  },
217
- animation: { type: 'fadeIn', duration: 1, delay: 0.5 },
235
+ animation: themedEntrance(context, 'text', { type: 'fadeIn', duration: 1, delay: 0.5 }),
218
236
  });
219
237
 
220
238
  if (right.sublabel) {
@@ -17,18 +17,21 @@
17
17
 
18
18
  import type { Layer } from '../../schemas/Doc.js';
19
19
  import type { VideoPullQuoteInput, TemplateContext } from '../../schemas/BlockTemplates.js';
20
- import { scaledFontSize } from '../../schemas/BlockTemplates.js';
21
- import { getThemeFont } from '../utils/themeUtils.js';
20
+ import { getThemeFont, themedEntrance, themedFontSize } from '../utils/themeUtils.js';
21
+ import { estimateTextHeight } from './captionUtils.js';
22
22
 
23
23
  export function videoPullQuote(input: VideoPullQuoteInput, context: TemplateContext): Layer[] {
24
24
  const { text, attribution, backgroundVideo } = input;
25
+ const { viewport } = context;
25
26
 
26
27
  // Guard: backgroundVideo is required
27
28
  if (!backgroundVideo?.src) return [];
28
29
 
29
- const quoteFontSize = scaledFontSize(52, context, true);
30
- const attrFontSize = scaledFontSize(24, context, false);
31
- const decoFontSize = scaledFontSize(200, context, true);
30
+ const quoteFontSize = themedFontSize(52, context, true);
31
+ const attrFontSize = themedFontSize(26, context, false);
32
+ const decoFontSize = themedFontSize(200, context, true);
33
+ const quoteLineHeight = 1.4;
34
+ const quoteYPct = attribution ? 45 : 50;
32
35
 
33
36
  const layers: Layer[] = [
34
37
  // Full-bleed background video clip
@@ -90,22 +93,29 @@ export function videoPullQuote(input: VideoPullQuoteInput, context: TemplateCont
90
93
  fontFamily: getThemeFont(context, 'title'),
91
94
  color: '#ffffff',
92
95
  textAlign: 'center',
93
- lineHeight: 1.5,
96
+ lineHeight: quoteLineHeight,
94
97
  shadow: true,
95
98
  },
96
99
  },
97
100
  position: {
98
101
  x: '50%',
99
- y: attribution ? '45%' : '50%',
102
+ y: `${quoteYPct}%`,
100
103
  anchor: 'center',
101
- width: '80%',
104
+ width: '72%',
102
105
  },
103
- animation: { type: 'fadeIn', duration: 2 },
106
+ animation: themedEntrance(context, 'text', { type: 'fadeIn', duration: 2 }),
104
107
  },
105
108
  ];
106
109
 
107
- // Attribution
110
+ // Attribution \u2014 hangs just below the quote's estimated bottom edge so
111
+ // the two read as one lockup instead of stranding near the bottom.
108
112
  if (attribution) {
113
+ const quoteWidthPx = 0.72 * viewport.width;
114
+ const quoteHeightPx = estimateTextHeight(text, quoteFontSize, quoteWidthPx, quoteLineHeight);
115
+ const attrYPct = Math.min(
116
+ 82,
117
+ quoteYPct + ((quoteHeightPx / 2 + quoteFontSize * 1.7) / viewport.height) * 100,
118
+ );
109
119
  layers.push({
110
120
  type: 'text',
111
121
  id: 'attribution',
@@ -114,14 +124,14 @@ export function videoPullQuote(input: VideoPullQuoteInput, context: TemplateCont
114
124
  style: {
115
125
  fontSize: attrFontSize,
116
126
  fontFamily: getThemeFont(context, 'body'),
117
- color: 'rgba(255, 255, 255, 0.7)',
127
+ color: 'rgba(255, 255, 255, 0.85)',
118
128
  textAlign: 'center',
119
129
  shadow: true,
120
130
  },
121
131
  },
122
132
  position: {
123
133
  x: '50%',
124
- y: '72%',
134
+ y: `${attrYPct}%`,
125
135
  anchor: 'center',
126
136
  },
127
137
  animation: { type: 'fadeIn', duration: 1, delay: 1.5 },
@@ -17,8 +17,8 @@
17
17
 
18
18
  import type { Layer } from '../../schemas/Doc.js';
19
19
  import type { VideoWithCaptionInput, TemplateContext } from '../../schemas/BlockTemplates.js';
20
- import { scaledFontSize } from '../../schemas/BlockTemplates.js';
21
- import { getThemeFont } from '../utils/themeUtils.js';
20
+ import { getThemeFont, shouldUseShadow, themedFontSize } from '../utils/themeUtils.js';
21
+ import { withAlpha } from '../../schemas/colorUtils.js';
22
22
  import { cleanCaption } from './captionUtils.js';
23
23
 
24
24
  export function videoWithCaption(input: VideoWithCaptionInput, context: TemplateContext): Layer[] {
@@ -37,8 +37,8 @@ export function videoWithCaption(input: VideoWithCaptionInput, context: Template
37
37
  const caption = rawCaption ? cleanCaption(rawCaption) : rawCaption;
38
38
  const { theme, layout } = context;
39
39
 
40
- const captionFontSize = scaledFontSize(36, context, false);
41
- const creditFontSize = scaledFontSize(16, context, false);
40
+ const captionFontSize = themedFontSize(36, context, false);
41
+ const creditFontSize = themedFontSize(16, context, false);
42
42
 
43
43
  const layers: Layer[] = [
44
44
  // Background video clip
@@ -60,14 +60,19 @@ export function videoWithCaption(input: VideoWithCaptionInput, context: Template
60
60
  },
61
61
  ];
62
62
 
63
- // Caption text at bottom with gradient for readability
63
+ // Caption text at bottom with gradient for readability. The scrim is
64
+ // tinted from the theme background so the theme text color on top of it
65
+ // stays readable in light and dark themes alike; it fades toward the
66
+ // frame edge the caption sits on.
64
67
  if (caption) {
68
+ const bg = theme.colors.background;
69
+ const scrimAngle = captionPosition === 'top' ? 180 : 0;
65
70
  layers.push({
66
71
  type: 'shape',
67
72
  id: 'caption-gradient',
68
73
  content: {
69
74
  shape: 'rect',
70
- fill: 'linear-gradient(0deg, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.3) 60%, transparent 100%)',
75
+ fill: `linear-gradient(${scrimAngle}deg, ${withAlpha(bg, 0.8)} 0%, ${withAlpha(bg, 0.35)} 60%, ${withAlpha(bg, 0)} 100%)`,
71
76
  },
72
77
  position: {
73
78
  x: 0,
@@ -90,7 +95,7 @@ export function videoWithCaption(input: VideoWithCaptionInput, context: Template
90
95
  fontFamily: getThemeFont(context, 'body'),
91
96
  color: theme.colors.text,
92
97
  textAlign: 'center',
93
- shadow: true,
98
+ shadow: shouldUseShadow(context),
94
99
  },
95
100
  },
96
101
  position: {
@@ -113,7 +118,7 @@ export function videoWithCaption(input: VideoWithCaptionInput, context: Template
113
118
  style: {
114
119
  fontSize: creditFontSize,
115
120
  fontFamily: getThemeFont(context, 'body'),
116
- color: 'rgba(255, 255, 255, 0.5)',
121
+ color: withAlpha(theme.colors.text, 0.65),
117
122
  textAlign: 'right',
118
123
  },
119
124
  },