@bendyline/squisq 1.4.1 → 1.5.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 (254) hide show
  1. package/README.md +14 -1
  2. package/dist/{ContentContainer-DNx460y8.d.ts → ContentContainer-BXUlIf18.d.ts} +6 -2
  3. package/dist/{Doc-BeSeH2iX.d.ts → Doc-BBVGq1_9.d.ts} +922 -273
  4. package/dist/{ImageEditDoc-BTIvw0oq.d.ts → ImageEditDoc-FuyGtt6o.d.ts} +11 -4
  5. package/dist/{chunk-ZTX4PFFT.js → chunk-2OIBZYKP.js} +75 -2
  6. package/dist/chunk-2OIBZYKP.js.map +1 -0
  7. package/dist/chunk-2UQOHYE2.js +45 -0
  8. package/dist/chunk-2UQOHYE2.js.map +1 -0
  9. package/dist/chunk-6ATE2PSM.js +344 -0
  10. package/dist/chunk-6ATE2PSM.js.map +1 -0
  11. package/dist/{chunk-57CVQCFX.js → chunk-A4O7GIWE.js} +74 -23
  12. package/dist/chunk-A4O7GIWE.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-ET53IIEP.js +1 -0
  16. package/dist/chunk-ET53IIEP.js.map +1 -0
  17. package/dist/chunk-HWVFJAAH.js +362 -0
  18. package/dist/chunk-HWVFJAAH.js.map +1 -0
  19. package/dist/{chunk-2TMAWB4Y.js → chunk-IMSCRLLV.js} +1 -1
  20. package/dist/chunk-IMSCRLLV.js.map +1 -0
  21. package/dist/{chunk-B4EEON3N.js → chunk-KKNUBQ6Y.js} +536 -103
  22. package/dist/chunk-KKNUBQ6Y.js.map +1 -0
  23. package/dist/chunk-LDQ2HJIX.js +596 -0
  24. package/dist/chunk-LDQ2HJIX.js.map +1 -0
  25. package/dist/chunk-LH7I6SH7.js +7208 -0
  26. package/dist/chunk-LH7I6SH7.js.map +1 -0
  27. package/dist/chunk-PGGGKYWG.js +1 -0
  28. package/dist/chunk-PGGGKYWG.js.map +1 -0
  29. package/dist/{chunk-7UDSRZKG.js → chunk-Q3ROPT5H.js} +35 -14
  30. package/dist/chunk-Q3ROPT5H.js.map +1 -0
  31. package/dist/chunk-QE7TWGVT.js +178 -0
  32. package/dist/chunk-QE7TWGVT.js.map +1 -0
  33. package/dist/chunk-RUDYOTA6.js +345 -0
  34. package/dist/chunk-RUDYOTA6.js.map +1 -0
  35. package/dist/{chunk-K32VJONL.js → chunk-T5UK6YOB.js} +41 -2
  36. package/dist/chunk-T5UK6YOB.js.map +1 -0
  37. package/dist/{chunk-3K5OG6KI.js → chunk-TEEEILMP.js} +412 -186
  38. package/dist/chunk-TEEEILMP.js.map +1 -0
  39. package/dist/{chunk-FR2RBTKO.js → chunk-TQWLI6S2.js} +93 -27
  40. package/dist/chunk-TQWLI6S2.js.map +1 -0
  41. package/dist/{chunk-WMOI7FN3.js → chunk-UY7KGQ5R.js} +12 -2
  42. package/dist/{chunk-WMOI7FN3.js.map → chunk-UY7KGQ5R.js.map} +1 -1
  43. package/dist/{chunk-3E5F2XMR.js → chunk-VWUFZ6ZG.js} +29 -2
  44. package/dist/chunk-VWUFZ6ZG.js.map +1 -0
  45. package/dist/chunk-ZLQKNOZQ.js +1 -0
  46. package/dist/chunk-ZLQKNOZQ.js.map +1 -0
  47. package/dist/doc/index.d.ts +5 -5
  48. package/dist/doc/index.js +135 -8
  49. package/dist/generate/index.d.ts +3 -74
  50. package/dist/generate/index.js +2 -7
  51. package/dist/icons/index.d.ts +3 -26
  52. package/dist/icons/index.js +13 -0
  53. package/dist/icons/inlineIconMarker.d.ts +1 -0
  54. package/dist/icons/inlineIconMarker.js +15 -0
  55. package/dist/icons/inlineIconMarker.js.map +1 -0
  56. package/dist/imageEdit/index.d.ts +12 -6
  57. package/dist/imageEdit/index.js +4 -1
  58. package/dist/index.d.ts +14 -11
  59. package/dist/index.js +265 -37
  60. package/dist/inlineIconMarker-CiE8Hlvz.d.ts +59 -0
  61. package/dist/jsonForm/index.d.ts +42 -1
  62. package/dist/jsonForm/index.js +9 -1
  63. package/dist/markdown/index.d.ts +228 -3
  64. package/dist/markdown/index.js +42 -7
  65. package/dist/recommend/index.d.ts +1 -1
  66. package/dist/recommend/index.js +3 -2
  67. package/dist/schemas/index.d.ts +28 -8
  68. package/dist/schemas/index.js +36 -8
  69. package/dist/spatial/index.d.ts +9 -4
  70. package/dist/spatial/index.js +1 -1
  71. package/dist/storage/index.d.ts +2 -2
  72. package/dist/storage/index.js +1 -1
  73. package/dist/story/index.d.ts +1021 -41
  74. package/dist/story/index.js +135 -8
  75. package/dist/{themeLibrary-BHjJ0Qx6.d.ts → themeLibrary-CehcJhzz.d.ts} +1 -1
  76. package/dist/timing/index.js +3 -5
  77. package/dist/transform/index.d.ts +48 -10
  78. package/dist/transform/index.js +17 -481
  79. package/dist/transform/index.js.map +1 -1
  80. package/dist/{types-BdZkdJ3z.d.ts → types-DlAZ7MW4.d.ts} +115 -1
  81. package/dist/versions/index.d.ts +1 -1
  82. package/package.json +6 -1
  83. package/src/__tests__/animationUtils.test.ts +40 -0
  84. package/src/__tests__/annotationCoercion.test.ts +243 -0
  85. package/src/__tests__/applyRenderStyle.test.ts +146 -0
  86. package/src/__tests__/atmosphereLayers.test.ts +108 -0
  87. package/src/__tests__/attrTokens.test.ts +162 -0
  88. package/src/__tests__/contentExtractor.test.ts +60 -0
  89. package/src/__tests__/coverBlock.test.ts +46 -1
  90. package/src/__tests__/customTemplateSample.test.ts +144 -0
  91. package/src/__tests__/customTemplatesFrontmatter.test.ts +236 -0
  92. package/src/__tests__/customThemesFrontmatter.test.ts +145 -0
  93. package/src/__tests__/diagramBlock.test.ts +122 -0
  94. package/src/__tests__/diagramLayout.test.ts +134 -0
  95. package/src/__tests__/drawing.test.ts +109 -0
  96. package/src/__tests__/drawingBlock.test.ts +124 -0
  97. package/src/__tests__/drawingLayout.test.ts +171 -0
  98. package/src/__tests__/geohash.test.ts +6 -5
  99. package/src/__tests__/getLayers.test.ts +6 -2
  100. package/src/__tests__/getLayersFallback.test.ts +103 -0
  101. package/src/__tests__/imageEditExportSvg.test.ts +70 -0
  102. package/src/__tests__/imageEditVersions.test.ts +72 -0
  103. package/src/__tests__/inlineIconMarker.test.ts +36 -0
  104. package/src/__tests__/inputDescriptors.test.ts +162 -0
  105. package/src/__tests__/jsonForm.tokens.test.ts +84 -0
  106. package/src/__tests__/layoutBlock.test.ts +83 -0
  107. package/src/__tests__/layoutLayout.test.ts +169 -0
  108. package/src/__tests__/markdown.test.ts +87 -0
  109. package/src/__tests__/markdownSanitize.test.ts +79 -0
  110. package/src/__tests__/markdownToDoc.test.ts +391 -2
  111. package/src/__tests__/mediaAnnotations.test.ts +91 -0
  112. package/src/__tests__/mediaSchedule.test.ts +118 -0
  113. package/src/__tests__/pandocAttrParse.test.ts +187 -0
  114. package/src/__tests__/resolveDocTheme.test.ts +55 -0
  115. package/src/__tests__/rootExports.test.ts +43 -0
  116. package/src/__tests__/shapeGeometry.test.ts +89 -0
  117. package/src/__tests__/shapeVocabulary.test.ts +133 -0
  118. package/src/__tests__/structuredData.test.ts +249 -0
  119. package/src/__tests__/templateAnnotationParse.test.ts +108 -0
  120. package/src/__tests__/templateMetadata.test.ts +31 -0
  121. package/src/__tests__/templates.test.ts +257 -4
  122. package/src/__tests__/themeCompile.test.ts +42 -0
  123. package/src/__tests__/themeValidator.test.ts +6 -0
  124. package/src/__tests__/transformV2.test.ts +137 -0
  125. package/src/__tests__/transitionNormalize.test.ts +61 -0
  126. package/src/__tests__/validateDoc.test.ts +260 -0
  127. package/src/doc/annotationBlocks.ts +117 -0
  128. package/src/doc/customTemplatesFrontmatter.ts +314 -0
  129. package/src/doc/customThemesFrontmatter.ts +95 -0
  130. package/src/doc/docToMarkdown.ts +104 -5
  131. package/src/doc/getLayers.ts +49 -17
  132. package/src/doc/index.ts +47 -1
  133. package/src/doc/markdownToDoc.ts +411 -13
  134. package/src/doc/mediaAnnotations.ts +100 -0
  135. package/src/doc/resolveDocTheme.ts +55 -0
  136. package/src/doc/standaloneAnnotation.ts +58 -0
  137. package/src/doc/structuredData.ts +301 -0
  138. package/src/doc/templateInputs.ts +240 -0
  139. package/src/doc/templates/__tests__/customTemplate.test.ts +170 -0
  140. package/src/doc/templates/__tests__/customTemplateRegistry.test.ts +110 -0
  141. package/src/doc/templates/accentImage.ts +30 -6
  142. package/src/doc/templates/captionUtils.ts +23 -0
  143. package/src/doc/templates/comparisonBar.ts +33 -18
  144. package/src/doc/templates/coverBlock.ts +35 -5
  145. package/src/doc/templates/customTemplate.ts +51 -0
  146. package/src/doc/templates/dataTable.ts +37 -19
  147. package/src/doc/templates/dateEvent.ts +41 -20
  148. package/src/doc/templates/definitionCard.ts +26 -18
  149. package/src/doc/templates/diagramBlock.ts +204 -0
  150. package/src/doc/templates/diagramLayout.ts +159 -0
  151. package/src/doc/templates/drawingBlock.ts +325 -0
  152. package/src/doc/templates/drawingLayout.ts +539 -0
  153. package/src/doc/templates/factCard.ts +54 -26
  154. package/src/doc/templates/fallbackBlock.ts +123 -0
  155. package/src/doc/templates/featureBlock.ts +20 -13
  156. package/src/doc/templates/fullBleedQuote.ts +18 -8
  157. package/src/doc/templates/imageWithCaption.ts +30 -15
  158. package/src/doc/templates/index.ts +225 -46
  159. package/src/doc/templates/inputDescriptors.ts +432 -0
  160. package/src/doc/templates/layoutBlock.ts +43 -0
  161. package/src/doc/templates/layoutLayout.ts +243 -0
  162. package/src/doc/templates/listBlock.ts +56 -30
  163. package/src/doc/templates/mapBlock.ts +15 -12
  164. package/src/doc/templates/metadata.ts +120 -0
  165. package/src/doc/templates/persistentLayers.ts +129 -11
  166. package/src/doc/templates/photoGrid.ts +22 -34
  167. package/src/doc/templates/pullQuote.ts +30 -12
  168. package/src/doc/templates/quoteBlock.ts +54 -29
  169. package/src/doc/templates/sectionHeader.ts +25 -11
  170. package/src/doc/templates/statHighlight.ts +39 -22
  171. package/src/doc/templates/titleBlock.ts +59 -21
  172. package/src/doc/templates/tokens/__tests__/resolveTokens.test.ts +487 -0
  173. package/src/doc/templates/tokens/resolveTokens.ts +453 -0
  174. package/src/doc/templates/twoColumn.ts +48 -30
  175. package/src/doc/templates/videoPullQuote.ts +22 -12
  176. package/src/doc/templates/videoWithCaption.ts +21 -15
  177. package/src/doc/utils/animationUtils.ts +267 -3
  178. package/src/doc/utils/applyRenderStyle.ts +113 -0
  179. package/src/doc/utils/imageTreatment.ts +70 -0
  180. package/src/doc/utils/nearest.ts +59 -0
  181. package/src/doc/utils/shapeGeometry.ts +464 -0
  182. package/src/doc/utils/themeUtils.ts +75 -1
  183. package/src/doc/validate.ts +575 -0
  184. package/src/generate/contentExtractor.ts +148 -35
  185. package/src/generate/index.ts +0 -3
  186. package/src/icons/index.ts +8 -0
  187. package/src/icons/inlineIconMarker.ts +73 -0
  188. package/src/imageEdit/export.ts +85 -2
  189. package/src/imageEdit/index.ts +1 -0
  190. package/src/imageEdit/persistence.ts +51 -0
  191. package/src/index.ts +2 -0
  192. package/src/jsonForm/index.ts +2 -0
  193. package/src/jsonForm/tokens.ts +78 -0
  194. package/src/markdown/annotationCoercion.ts +295 -0
  195. package/src/markdown/attrTokens.ts +241 -0
  196. package/src/markdown/convert.ts +229 -26
  197. package/src/markdown/index.ts +37 -0
  198. package/src/markdown/sanitize.ts +318 -0
  199. package/src/markdown/stringify.ts +96 -6
  200. package/src/markdown/types.ts +66 -0
  201. package/src/markdown/utils.ts +111 -2
  202. package/src/recommend/templates.ts +24 -0
  203. package/src/schemas/BlockTemplates.ts +133 -4
  204. package/src/schemas/CustomTemplates.ts +126 -0
  205. package/src/schemas/Doc.ts +387 -65
  206. package/src/schemas/ImageEditDoc.ts +11 -4
  207. package/src/schemas/Media.ts +169 -0
  208. package/src/schemas/Theme.ts +23 -1
  209. package/src/schemas/Transitions.ts +224 -0
  210. package/src/schemas/colorUtils.ts +33 -0
  211. package/src/schemas/fontStacks.ts +5 -2
  212. package/src/schemas/index.ts +3 -0
  213. package/src/schemas/themeCompile.ts +24 -5
  214. package/src/schemas/themeValidator.ts +49 -11
  215. package/src/schemas/themes/cinematic.json +69 -12
  216. package/src/schemas/themes/documentary.json +58 -11
  217. package/src/schemas/themes/gezellig.json +60 -11
  218. package/src/schemas/themes/magazine.json +54 -9
  219. package/src/schemas/themes/morning-light.json +52 -9
  220. package/src/schemas/themes/standard-dark.json +48 -13
  221. package/src/schemas/themes/standard.json +44 -12
  222. package/src/schemas/themes/tech-dark.json +65 -12
  223. package/src/spatial/Geohash.ts +52 -21
  224. package/src/storage/ContentContainer.ts +6 -2
  225. package/src/transform/applyTransform.ts +43 -4
  226. package/src/transform/index.ts +1 -0
  227. package/src/transform/registry.ts +44 -13
  228. package/src/transform/styles/dataDriven.ts +2 -1
  229. package/src/transform/styles/documentary.ts +2 -1
  230. package/src/transform/styles/magazine.ts +3 -1
  231. package/src/transform/styles/minimal.ts +2 -1
  232. package/src/transform/styles/narrative.ts +3 -1
  233. package/src/transform/templateSelector.ts +87 -11
  234. package/src/transform/types.ts +35 -2
  235. package/dist/chunk-2TMAWB4Y.js.map +0 -1
  236. package/dist/chunk-3E5F2XMR.js.map +0 -1
  237. package/dist/chunk-3K5OG6KI.js.map +0 -1
  238. package/dist/chunk-4X3JQXNM.js +0 -35
  239. package/dist/chunk-4X3JQXNM.js.map +0 -1
  240. package/dist/chunk-57CVQCFX.js.map +0 -1
  241. package/dist/chunk-5WFRKURX.js.map +0 -1
  242. package/dist/chunk-7UDSRZKG.js.map +0 -1
  243. package/dist/chunk-B4EEON3N.js.map +0 -1
  244. package/dist/chunk-FR2RBTKO.js.map +0 -1
  245. package/dist/chunk-H3AXU4MJ.js +0 -179
  246. package/dist/chunk-H3AXU4MJ.js.map +0 -1
  247. package/dist/chunk-K32VJONL.js.map +0 -1
  248. package/dist/chunk-KSWJR66U.js +0 -3706
  249. package/dist/chunk-KSWJR66U.js.map +0 -1
  250. package/dist/chunk-MYIH7FWD.js +0 -145
  251. package/dist/chunk-MYIH7FWD.js.map +0 -1
  252. package/dist/chunk-ZTX4PFFT.js.map +0 -1
  253. package/src/__tests__/slideshowGenerator.test.ts +0 -128
  254. package/src/generate/slideshowGenerator.ts +0 -273
@@ -20,12 +20,15 @@ import type {
20
20
  GradientBackgroundConfig,
21
21
  ImageBackgroundConfig,
22
22
  PatternBackgroundConfig,
23
+ VignetteConfig,
24
+ AmbientGradientConfig,
23
25
  TitleCaptionConfig,
24
26
  CornerBrandingConfig,
25
27
  ProgressIndicatorConfig,
26
28
  } from '../../schemas/BlockTemplates.js';
27
29
  import type { Theme } from '../../schemas/Theme.js';
28
30
  import { isPersistentLayerTemplate } from '../../schemas/BlockTemplates.js';
31
+ import { oklchDarken, withAlpha } from '../../schemas/colorUtils.js';
29
32
 
30
33
  // ============================================
31
34
  // Gradient Presets
@@ -35,9 +38,11 @@ const GRADIENT_PRESETS: Record<string, string> = {
35
38
  'dark-vignette':
36
39
  'radial-gradient(ellipse at center, rgba(26,32,44,0.8) 0%, rgba(0,0,0,0.95) 100%)',
37
40
  'radial-dark': 'radial-gradient(ellipse at center, #1a202c 0%, #000000 100%)',
38
- 'warm-sunset': 'linear-gradient(135deg, rgba(124,58,48,0.9) 0%, rgba(26,32,44,0.95) 100%)',
41
+ // Warm presets deepen within their own hue — ending in the shared navy
42
+ // made warm themes drift cold halfway down the frame.
43
+ 'warm-sunset': 'linear-gradient(135deg, rgba(124,58,48,0.9) 0%, rgba(46,20,16,0.95) 100%)',
39
44
  'cool-blue': 'linear-gradient(135deg, rgba(26,54,93,0.9) 0%, rgba(26,32,44,0.95) 100%)',
40
- 'earth-tones': 'linear-gradient(135deg, rgba(68,51,34,0.9) 0%, rgba(26,32,44,0.95) 100%)',
45
+ 'earth-tones': 'linear-gradient(135deg, rgba(68,51,34,0.9) 0%, rgba(30,22,15,0.95) 100%)',
41
46
  };
42
47
 
43
48
  // ============================================
@@ -84,7 +89,9 @@ function expandImageBackground(config: ImageBackgroundConfig): Layer[] {
84
89
  const layers: Layer[] = [];
85
90
  const opacity = config.opacity ?? 0.4;
86
91
 
87
- // Base image layer
92
+ // Base image layer. `blur` softens the photo into an atmosphere layer
93
+ // (the ImageLayer renderer over-scans blurred images so soft edges never
94
+ // reveal the frame).
88
95
  const imageLayer: Layer = {
89
96
  type: 'image',
90
97
  id: 'persistent-bg-image',
@@ -92,6 +99,7 @@ function expandImageBackground(config: ImageBackgroundConfig): Layer[] {
92
99
  src: config.src,
93
100
  alt: 'Background',
94
101
  fit: 'cover',
102
+ ...(config.blur != null && config.blur > 0 ? { blur: config.blur } : {}),
95
103
  },
96
104
  position: { x: 0, y: 0, width: '100%', height: '100%' },
97
105
  animation: config.ambientMotion
@@ -122,22 +130,79 @@ function expandImageBackground(config: ImageBackgroundConfig): Layer[] {
122
130
  * Expand a pattern background config to a Layer.
123
131
  */
124
132
  function expandPatternBackground(config: PatternBackgroundConfig): Layer {
125
- const opacity = config.opacity ?? 0.1;
126
- const color = config.color ?? `rgba(255,255,255,${opacity})`;
133
+ // 'noise' is film grain: a full-bleed rect whose paint is replaced by a
134
+ // static feTurbulence field clipped to the rect (see ShapeFilter).
135
+ if (config.pattern === 'noise') {
136
+ return {
137
+ type: 'shape',
138
+ id: 'persistent-grain',
139
+ content: {
140
+ shape: 'rect',
141
+ fill: '#000000',
142
+ filter: { type: 'noise', opacity: config.opacity ?? 0.05 },
143
+ },
144
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
145
+ };
146
+ }
127
147
 
128
- // Pattern as SVG data URI for rect patterns
129
- // For simplicity, use a solid subtle color with reduced opacity
148
+ // dots / grid / diagonal render as native SVG <pattern> fills.
149
+ const opacity = config.opacity ?? 0.06;
130
150
  return {
131
151
  type: 'shape',
132
152
  id: 'persistent-bg-pattern',
133
153
  content: {
134
154
  shape: 'rect',
135
- fill: color,
155
+ pattern: {
156
+ kind: config.pattern,
157
+ color: config.color ?? '#ffffff',
158
+ size: Math.round(24 * (config.scale ?? 1)),
159
+ opacity,
160
+ },
136
161
  },
137
162
  position: { x: 0, y: 0, width: '100%', height: '100%' },
138
163
  };
139
164
  }
140
165
 
166
+ /**
167
+ * Expand a vignette config: transparent center darkening toward the frame
168
+ * edges. Rides the CSS-gradient rect path in the renderer.
169
+ */
170
+ function expandVignette(config: VignetteConfig): Layer {
171
+ const strength = Math.max(0, Math.min(1, config.strength ?? 0.3));
172
+ const color = config.color ?? '#000000';
173
+ const edge = withAlpha(color, strength);
174
+ const clear = withAlpha(color, 0);
175
+ return {
176
+ type: 'shape',
177
+ id: 'persistent-vignette',
178
+ content: {
179
+ shape: 'rect',
180
+ fill: `radial-gradient(ellipse at center, ${clear} 55%, ${edge} 100%)`,
181
+ },
182
+ position: { x: 0, y: 0, width: '100%', height: '100%' },
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Expand an ambient drifting gradient: an oversized surface gradient with
188
+ * a very slow Ken Burns loop. Deterministic (no randomness).
189
+ */
190
+ function expandAmbientGradient(config: AmbientGradientConfig, theme?: Theme): Layer {
191
+ const from = config.from ?? theme?.colors.backgroundLight ?? '#1a202c';
192
+ const to = config.to ?? (theme ? oklchDarken(theme.colors.background, 0.04) : '#0f141d');
193
+ return {
194
+ type: 'shape',
195
+ id: 'persistent-ambient-gradient',
196
+ content: {
197
+ shape: 'rect',
198
+ fill: `linear-gradient(150deg, ${from} 0%, ${to} 100%)`,
199
+ },
200
+ // Oversized so the slow pan never reveals the frame edge.
201
+ position: { x: '-5%', y: '-5%', width: '110%', height: '110%' },
202
+ animation: { type: 'slowZoom', panDirection: 'left', duration: config.duration ?? 40 },
203
+ };
204
+ }
205
+
141
206
  // ============================================
142
207
  // Overlay Layer Expansion
143
208
  // ============================================
@@ -349,7 +414,7 @@ function expandProgressIndicator(config: ProgressIndicatorConfig): Layer {
349
414
  /**
350
415
  * Expand a single persistent layer (template or raw) to raw Layer(s).
351
416
  */
352
- export function expandPersistentLayer(layer: PersistentLayer): Layer[] {
417
+ export function expandPersistentLayer(layer: PersistentLayer, theme?: Theme): Layer[] {
353
418
  // If already a raw Layer, return as-is
354
419
  if (!isPersistentLayerTemplate(layer)) {
355
420
  return [layer as Layer];
@@ -367,6 +432,10 @@ export function expandPersistentLayer(layer: PersistentLayer): Layer[] {
367
432
  return expandImageBackground(config);
368
433
  case 'patternBackground':
369
434
  return [expandPatternBackground(config)];
435
+ case 'vignette':
436
+ return [expandVignette(config)];
437
+ case 'ambientGradient':
438
+ return [expandAmbientGradient(config, theme)];
370
439
  case 'titleCaption':
371
440
  return expandTitleCaption(config);
372
441
  case 'cornerBranding':
@@ -382,13 +451,19 @@ export function expandPersistentLayer(layer: PersistentLayer): Layer[] {
382
451
  /**
383
452
  * Expand all persistent layers in a config to raw Layer arrays.
384
453
  */
385
- export function expandPersistentLayers(layers: PersistentLayer[] | undefined): Layer[] {
454
+ export function expandPersistentLayers(
455
+ layers: PersistentLayer[] | undefined,
456
+ theme?: Theme,
457
+ ): Layer[] {
386
458
  if (!layers || layers.length === 0) {
387
459
  return [];
388
460
  }
389
461
 
390
462
  // Use reduce+concat instead of flatMap for Coherent GT compatibility (ES2017)
391
- return layers.reduce<Layer[]>((acc, layer) => acc.concat(expandPersistentLayer(layer)), []);
463
+ return layers.reduce<Layer[]>(
464
+ (acc, layer) => acc.concat(expandPersistentLayer(layer, theme)),
465
+ [],
466
+ );
392
467
  }
393
468
 
394
469
  // ============================================
@@ -520,3 +595,46 @@ export function getDocStyleConfig(
520
595
  export function getPersistentLayersFromTheme(theme: Theme): PersistentLayerConfig {
521
596
  return theme.persistentLayers ?? {};
522
597
  }
598
+
599
+ /**
600
+ * Resolve the effective persistent-layer config for a doc: the doc's own
601
+ * config wins **wholesale** when present; only docs with no persistent
602
+ * layers inherit the theme's.
603
+ *
604
+ * Doc-wins-wholesale (rather than merging) protects pre-baked documents
605
+ * that carry their own background/branding layers — merging a theme's
606
+ * atmosphere on top of those would double up backgrounds and stack
607
+ * overlays the doc author never saw.
608
+ */
609
+ export function resolvePersistentLayers(
610
+ doc: { persistentLayers?: PersistentLayerConfig },
611
+ theme: Theme,
612
+ ): PersistentLayerConfig | undefined {
613
+ const docConfig = doc.persistentLayers;
614
+ const hasDocLayers =
615
+ !!docConfig &&
616
+ ((docConfig.bottomLayers?.length ?? 0) > 0 || (docConfig.topLayers?.length ?? 0) > 0);
617
+ if (hasDocLayers) return docConfig;
618
+ const themeConfig = theme.persistentLayers;
619
+ const hasThemeLayers =
620
+ !!themeConfig &&
621
+ ((themeConfig.bottomLayers?.length ?? 0) > 0 || (themeConfig.topLayers?.length ?? 0) > 0);
622
+ return hasThemeLayers ? themeConfig : docConfig;
623
+ }
624
+
625
+ /**
626
+ * Compose a block's layers between pre-expanded persistent bottom/top
627
+ * layers, honoring the per-block `useBottomLayer` / `useTopLayer` opt-outs.
628
+ * Single shared implementation for `expandDocBlocks` and `getLayers`.
629
+ */
630
+ export function wrapWithPersistentLayers(
631
+ layers: Layer[],
632
+ block: { useBottomLayer?: boolean; useTopLayer?: boolean },
633
+ bottomLayers: Layer[],
634
+ topLayers: Layer[],
635
+ ): Layer[] {
636
+ if (bottomLayers.length === 0 && topLayers.length === 0) return layers;
637
+ const useBottom = block.useBottomLayer !== false;
638
+ const useTop = block.useTopLayer !== false;
639
+ return [...(useBottom ? bottomLayers : []), ...layers, ...(useTop ? topLayers : [])];
640
+ }
@@ -17,12 +17,12 @@
17
17
 
18
18
  import type { Layer } from '../../schemas/Doc.js';
19
19
  import type { PhotoGridInput, TemplateContext } from '../../schemas/BlockTemplates.js';
20
- import { scaledFontSize } from '../../schemas/BlockTemplates.js';
21
- import { getThemeFont } from '../utils/themeUtils.js';
20
+ import { getThemeFont, themedFontSize, themedImageTreatment } from '../utils/themeUtils.js';
21
+ import { withAlpha } from '../../schemas/colorUtils.js';
22
22
  import { cleanCaption } from './captionUtils.js';
23
23
 
24
24
  /** Gap between images as percentage */
25
- const GAP = 0.5;
25
+ const GAP = 1.2;
26
26
 
27
27
  export function photoGrid(input: PhotoGridInput, context: TemplateContext): Layer[] {
28
28
  const { images, caption: rawCaption, ambientMotion } = input;
@@ -33,25 +33,23 @@ export function photoGrid(input: PhotoGridInput, context: TemplateContext): Laye
33
33
  const caption = rawCaption ? cleanCaption(rawCaption) : rawCaption;
34
34
  const { theme, layout } = context;
35
35
 
36
+ const treatment = themedImageTreatment(context, input.imageTreatment);
37
+
36
38
  const layers: Layer[] = [
37
- // Black background (visible in gaps between images)
39
+ // Theme surface background (visible in gaps between images)
38
40
  {
39
41
  type: 'shape',
40
42
  id: 'bg',
41
- content: { shape: 'rect', fill: '#000000' },
43
+ content: { shape: 'rect', fill: theme.colors.background },
42
44
  position: { x: 0, y: 0, width: '100%', height: '100%' },
43
45
  },
44
46
  ];
45
47
 
46
- // Reserve bottom space for caption — more room in landscape for larger text
47
- const captionHeight = caption ? (layout.stackColumns ? 18 : 22) : 0;
48
- const gridHeight = 100 - captionHeight;
48
+ const gridHeight = 100;
49
49
 
50
50
  // Generate image positions based on count; stack vertically in portrait
51
51
  const positions = getGridPositions(images.length, gridHeight, layout.stackColumns);
52
52
 
53
- const altFontSize = scaledFontSize(24, context, false);
54
-
55
53
  for (let i = 0; i < Math.min(images.length, 4); i++) {
56
54
  const img = images[i];
57
55
  const pos = positions[i];
@@ -64,24 +62,6 @@ export function photoGrid(input: PhotoGridInput, context: TemplateContext): Laye
64
62
  position: { x: `${pos.x}%`, y: `${pos.y}%`, width: `${pos.w}%`, height: `${pos.h}%` },
65
63
  });
66
64
 
67
- // Alt-text label on placeholder
68
- if (img.alt) {
69
- layers.push({
70
- type: 'text',
71
- id: `grid-alt-${i}`,
72
- content: {
73
- text: img.alt,
74
- style: {
75
- fontSize: altFontSize,
76
- fontFamily: getThemeFont(context, 'body'),
77
- color: theme.colors.textMuted,
78
- textAlign: 'center' as const,
79
- },
80
- },
81
- position: { x: `${pos.x + pos.w / 2}%`, y: `${pos.y + pos.h / 2}%`, anchor: 'center' },
82
- });
83
- }
84
-
85
65
  // Image layer (covers placeholder when loaded)
86
66
  layers.push({
87
67
  type: 'image',
@@ -92,6 +72,7 @@ export function photoGrid(input: PhotoGridInput, context: TemplateContext): Laye
92
72
  fit: 'cover',
93
73
  credit: img.credit,
94
74
  license: img.license,
75
+ ...(treatment ? { treatment } : {}),
95
76
  },
96
77
  position: {
97
78
  x: `${pos.x}%`,
@@ -107,18 +88,23 @@ export function photoGrid(input: PhotoGridInput, context: TemplateContext): Laye
107
88
  });
108
89
  }
109
90
 
110
- // Caption with gradient overlay
91
+ // Caption in a compact translucent chip above player controls. Long
92
+ // archival/Wikimedia descriptions are intentionally clamped.
111
93
  if (caption) {
112
- const captionFontSize = scaledFontSize(layout.stackColumns ? 28 : 40, context, false);
94
+ const captionFontSize = themedFontSize(layout.stackColumns ? 24 : 26, context, false);
95
+ const captionY = layout.stackColumns ? '78%' : '74%';
96
+ const captionBgY = layout.stackColumns ? '72%' : '68%';
97
+ const captionHeight = layout.stackColumns ? '13%' : '16%';
98
+ const bg = theme.colors.background;
113
99
 
114
100
  layers.push({
115
101
  type: 'shape',
116
102
  id: 'caption-bg',
117
103
  content: {
118
104
  shape: 'rect',
119
- fill: 'linear-gradient(transparent, rgba(0,0,0,0.8))',
105
+ fill: `linear-gradient(90deg, ${withAlpha(bg, 0)}, ${withAlpha(bg, 0.78)} 16%, ${withAlpha(bg, 0.78)} 84%, ${withAlpha(bg, 0)})`,
120
106
  },
121
- position: { x: 0, y: `${gridHeight - 4}%`, width: '100%', height: `${captionHeight + 4}%` },
107
+ position: { x: 0, y: captionBgY, width: '100%', height: captionHeight },
122
108
  });
123
109
 
124
110
  layers.push({
@@ -132,13 +118,15 @@ export function photoGrid(input: PhotoGridInput, context: TemplateContext): Laye
132
118
  color: theme.colors.text,
133
119
  textAlign: 'center',
134
120
  shadow: true,
121
+ lineHeight: 1.18,
122
+ maxLines: layout.stackColumns ? 2 : 1,
135
123
  },
136
124
  },
137
125
  position: {
138
126
  x: '50%',
139
- y: `${gridHeight + (layout.stackColumns ? captionHeight / 2 : captionHeight * 0.3)}%`,
127
+ y: captionY,
140
128
  anchor: 'center',
141
- width: '90%',
129
+ width: '78%',
142
130
  },
143
131
  animation: { type: 'fadeIn', duration: 1, delay: 0.5 },
144
132
  });
@@ -10,18 +10,28 @@
10
10
 
11
11
  import type { Layer } from '../../schemas/Doc.js';
12
12
  import type { PullQuoteInput, TemplateContext } from '../../schemas/BlockTemplates.js';
13
- import { scaledFontSize } from '../../schemas/BlockTemplates.js';
14
- import { getThemeFont } from '../utils/themeUtils.js';
13
+ import {
14
+ getThemeFont,
15
+ themedEntrance,
16
+ themedFontSize,
17
+ themedImageTreatment,
18
+ } from '../utils/themeUtils.js';
19
+ import { estimateTextHeight } from './captionUtils.js';
15
20
 
16
21
  export function pullQuote(input: PullQuoteInput, context: TemplateContext): Layer[] {
17
22
  const { text, attribution, backgroundImage, ambientMotion } = input;
23
+ const { viewport } = context;
24
+
25
+ const treatment = themedImageTreatment(context, input.imageTreatment);
18
26
 
19
27
  // Guard: backgroundImage is required
20
28
  if (!backgroundImage?.src) return [];
21
29
 
22
- const quoteFontSize = scaledFontSize(52, context, true);
23
- const attrFontSize = scaledFontSize(24, context, false);
24
- 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;
25
35
 
26
36
  const layers: Layer[] = [
27
37
  // Full-bleed background image
@@ -34,6 +44,7 @@ export function pullQuote(input: PullQuoteInput, context: TemplateContext): Laye
34
44
  fit: 'cover',
35
45
  credit: backgroundImage.credit,
36
46
  license: backgroundImage.license,
47
+ ...(treatment ? { treatment } : {}),
37
48
  },
38
49
  position: { x: 0, y: 0, width: '100%', height: '100%' },
39
50
  animation: ambientMotion ? { type: ambientMotion, duration: 15 } : undefined,
@@ -81,22 +92,29 @@ export function pullQuote(input: PullQuoteInput, context: TemplateContext): Laye
81
92
  fontFamily: getThemeFont(context, 'title'),
82
93
  color: '#ffffff',
83
94
  textAlign: 'center',
84
- lineHeight: 1.5,
95
+ lineHeight: quoteLineHeight,
85
96
  shadow: true,
86
97
  },
87
98
  },
88
99
  position: {
89
100
  x: '50%',
90
- y: attribution ? '45%' : '50%',
101
+ y: `${quoteYPct}%`,
91
102
  anchor: 'center',
92
- width: '80%',
103
+ width: '72%',
93
104
  },
94
- animation: { type: 'fadeIn', duration: 2 },
105
+ animation: themedEntrance(context, 'text', { type: 'fadeIn', duration: 2 }),
95
106
  },
96
107
  ];
97
108
 
98
- // Attribution
109
+ // Attribution \u2014 hangs just below the quote's estimated bottom edge so
110
+ // the two read as one lockup instead of stranding near the bottom.
99
111
  if (attribution) {
112
+ const quoteWidthPx = 0.72 * viewport.width;
113
+ const quoteHeightPx = estimateTextHeight(text, quoteFontSize, quoteWidthPx, quoteLineHeight);
114
+ const attrYPct = Math.min(
115
+ 82,
116
+ quoteYPct + ((quoteHeightPx / 2 + quoteFontSize * 1.7) / viewport.height) * 100,
117
+ );
100
118
  layers.push({
101
119
  type: 'text',
102
120
  id: 'attribution',
@@ -105,14 +123,14 @@ export function pullQuote(input: PullQuoteInput, context: TemplateContext): Laye
105
123
  style: {
106
124
  fontSize: attrFontSize,
107
125
  fontFamily: getThemeFont(context, 'body'),
108
- color: 'rgba(255, 255, 255, 0.7)',
126
+ color: 'rgba(255, 255, 255, 0.85)',
109
127
  textAlign: 'center',
110
128
  shadow: true,
111
129
  },
112
130
  },
113
131
  position: {
114
132
  x: '50%',
115
- y: '72%',
133
+ y: `${attrYPct}%`,
116
134
  anchor: 'center',
117
135
  },
118
136
  animation: { type: 'fadeIn', duration: 1, delay: 1.5 },
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Quote Block Template
3
3
  *
4
- * Large centered quote with optional attribution.
5
- * Good for impactful statements or descriptions.
4
+ * Large centered quote with optional attribution, composed as one
5
+ * vertically-centered lockup: the attribution hangs a fixed distance
6
+ * below the quote's estimated bottom edge instead of being pinned to a
7
+ * far-away layout slot.
6
8
  * Adapts font sizes and positioning for different viewports.
7
9
  *
8
10
  * Supports optional accent images that appear as tasteful side/bottom strips.
@@ -12,59 +14,72 @@
12
14
 
13
15
  import type { Layer } from '../../schemas/Doc.js';
14
16
  import type { QuoteBlockInput, TemplateContext } from '../../schemas/BlockTemplates.js';
15
- import { scaledFontSize } from '../../schemas/BlockTemplates.js';
16
- import { getThemeFont } from '../utils/themeUtils.js';
17
+ import {
18
+ getThemeFont,
19
+ shouldUseShadow,
20
+ themedEntrance,
21
+ themedFontSize,
22
+ themedSurfaceGradient,
23
+ themedImageTreatment,
24
+ } from '../utils/themeUtils.js';
25
+ import { withAlpha } from '../../schemas/colorUtils.js';
17
26
  import { createAccentLayers, getAccentLayout, adjustY, DEFAULT_LAYOUT } from './accentImage.js';
18
- import { createBackgroundLayer } from './captionUtils.js';
27
+ import { createBackgroundLayer, estimateTextHeight } from './captionUtils.js';
19
28
 
20
29
  export function quoteBlock(input: QuoteBlockInput, context: TemplateContext): Layer[] {
21
30
  const { quote, attribution, accentImage } = input;
22
- const { theme, layout } = context;
31
+ const { theme, viewport } = context;
23
32
 
24
33
  // Get layout adjustments if accent image is present
25
34
  const accentLayout = accentImage ? getAccentLayout(accentImage.position) : DEFAULT_LAYOUT;
26
35
 
27
36
  // Scale font sizes for viewport
28
- const quoteFontSize = scaledFontSize(48, context, true);
29
- const attrFontSize = scaledFontSize(24, context, false);
37
+ const quoteFontSize = themedFontSize(48, context, true);
38
+ const attrFontSize = themedFontSize(26, context, false);
39
+ const quoteLineHeight = Math.max(theme.typography.titleLineHeight ?? 1.4, 1.25);
30
40
 
31
41
  // Decorative quotation mark font size
32
- const decorativeQuoteFontSize = scaledFontSize(280, context, true);
42
+ const decorativeQuoteFontSize = themedFontSize(280, context, true);
33
43
 
34
- const layers: Layer[] = [
35
- createBackgroundLayer(
36
- 'bg',
37
- `linear-gradient(160deg, ${theme.colors.backgroundLight} 0%, #1e2636 100%)`,
38
- ),
39
- ];
44
+ const layers: Layer[] = [createBackgroundLayer('bg', themedSurfaceGradient(context, 160))];
40
45
 
41
46
  // Add accent image layers (behind text, after background)
42
47
  if (accentImage) {
43
- layers.push(...createAccentLayers(accentImage, input.id));
48
+ layers.push(
49
+ ...createAccentLayers(
50
+ accentImage,
51
+ input.id,
52
+ themedImageTreatment(context, input.imageTreatment),
53
+ ),
54
+ );
44
55
  }
45
56
 
46
- // Decorative opening quotation mark — oversized, low-opacity behind quote
57
+ // Decorative opening quotation mark — oversized, low-opacity behind the
58
+ // quote. Derived from the theme text color so it stays a subtle ornament
59
+ // on light and dark surfaces alike (a hard-coded white glyph was
60
+ // invisible on both).
47
61
  layers.push({
48
62
  type: 'text',
49
63
  id: 'deco-quote',
50
64
  content: {
51
- text: '\u201C',
65
+ text: '',
52
66
  style: {
53
67
  fontSize: decorativeQuoteFontSize,
54
68
  fontFamily: getThemeFont(context, 'title'),
55
- color: 'rgba(255, 255, 255, 0.06)',
69
+ color: withAlpha(theme.colors.text, 0.09),
56
70
  textAlign: 'center',
57
71
  },
58
72
  },
59
73
  position: {
60
74
  x: accentLayout.textCenterX,
61
- y: adjustY('18%', accentLayout),
75
+ y: adjustY('26%', accentLayout),
62
76
  anchor: 'center',
63
77
  },
64
78
  });
65
79
 
66
- // Quote text - positioned based on accent layout
67
- const quoteY = attribution ? layout.primaryY : '50%';
80
+ // Quote text optically centered, nudged up slightly when an
81
+ // attribution hangs below it.
82
+ const quoteYPct = attribution ? 45 : 50;
68
83
  layers.push({
69
84
  type: 'text',
70
85
  id: 'quote',
@@ -75,21 +90,31 @@ export function quoteBlock(input: QuoteBlockInput, context: TemplateContext): La
75
90
  fontFamily: getThemeFont(context, 'title'),
76
91
  color: theme.colors.text,
77
92
  textAlign: 'center',
78
- lineHeight: 1.6,
79
- shadow: true,
93
+ lineHeight: quoteLineHeight,
94
+ shadow: shouldUseShadow(context),
80
95
  },
81
96
  },
82
97
  position: {
83
98
  x: accentLayout.textCenterX,
84
- y: adjustY(quoteY, accentLayout),
99
+ y: adjustY(`${quoteYPct}%`, accentLayout),
85
100
  anchor: 'center',
86
101
  width: accentLayout.textWidth,
87
102
  },
88
- animation: { type: 'fadeIn', duration: 2 },
103
+ animation: themedEntrance(context, 'text', { type: 'fadeIn', duration: 2 }),
89
104
  });
90
105
 
91
- // Add attribution if provided
106
+ // Attribution hangs just below the quote's estimated bottom edge, so
107
+ // the two read as one lockup instead of the attribution stranding near
108
+ // the bottom of the block.
92
109
  if (attribution) {
110
+ const quoteWidthPx = (parseFloat(accentLayout.textWidth) / 100) * viewport.width;
111
+ const quoteHeightPx = estimateTextHeight(quote, quoteFontSize, quoteWidthPx, quoteLineHeight);
112
+ const gapPx = quoteFontSize * 1.7;
113
+ const attrYPct = Math.min(
114
+ 82,
115
+ quoteYPct + ((quoteHeightPx / 2 + gapPx) / viewport.height) * 100,
116
+ );
117
+
93
118
  layers.push({
94
119
  type: 'text',
95
120
  id: 'attribution',
@@ -100,12 +125,12 @@ export function quoteBlock(input: QuoteBlockInput, context: TemplateContext): La
100
125
  fontFamily: getThemeFont(context, 'body'),
101
126
  color: theme.colors.textMuted,
102
127
  textAlign: 'center',
103
- shadow: !!accentImage,
128
+ shadow: shouldUseShadow(context),
104
129
  },
105
130
  },
106
131
  position: {
107
132
  x: accentLayout.textCenterX,
108
- y: adjustY(layout.captionY, accentLayout),
133
+ y: adjustY(`${attrYPct}%`, accentLayout),
109
134
  anchor: 'center',
110
135
  },
111
136
  animation: { type: 'fadeIn', duration: 1, delay: 1.5 },