@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,110 @@
1
+ /**
2
+ * Registry merge — end-to-end: a doc that uses a custom template name
3
+ * resolves through `expandDocBlocks(opts.customTemplates)` and produces
4
+ * layers with tokens substituted from the source block.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import { buildRegistry, expandDocBlocks } from '../index';
9
+ import type { CustomTemplateDefinition } from '../../../schemas/CustomTemplates.js';
10
+ import type { Block, TextLayer } from '../../../schemas/Doc.js';
11
+
12
+ const heroDef: CustomTemplateDefinition = {
13
+ name: 'hero',
14
+ label: 'Hero',
15
+ viewport: { width: 1920, height: 1080 },
16
+ layers: [
17
+ {
18
+ id: 'title',
19
+ type: 'text',
20
+ position: { x: '5%', y: '10%', width: '90%' },
21
+ content: { text: '{title}', style: { fontSize: 72, color: '#000' } },
22
+ },
23
+ {
24
+ id: 'body',
25
+ type: 'text',
26
+ position: { x: '5%', y: '40%', width: '90%' },
27
+ content: { text: '{content}', style: { fontSize: 32, color: '#333' } },
28
+ },
29
+ ],
30
+ };
31
+
32
+ describe('buildRegistry', () => {
33
+ it('merges custom templates onto the built-in registry', () => {
34
+ const reg = buildRegistry([heroDef]);
35
+ expect(reg.hero).toBeDefined();
36
+ expect(reg.title).toBeDefined(); // built-in survives
37
+ });
38
+
39
+ it('does not let custom templates shadow built-in names', () => {
40
+ const collide: CustomTemplateDefinition = {
41
+ ...heroDef,
42
+ name: 'title', // collides with built-in
43
+ };
44
+ const reg = buildRegistry([collide]);
45
+ // Built-in `title` is preserved — the collide entry is silently
46
+ // dropped (callers shouldn't have created it). We verify by
47
+ // checking the function reference is the original built-in's.
48
+ const builtIn = buildRegistry();
49
+ expect(reg.title).toBe(builtIn.title);
50
+ });
51
+ });
52
+
53
+ describe('expandDocBlocks with customTemplates', () => {
54
+ it('expands a doc-defined template name into resolved layers', () => {
55
+ const block: Block = {
56
+ id: 'b1',
57
+ startTime: 0,
58
+ duration: 1,
59
+ audioSegment: 0,
60
+ title: 'Welcome',
61
+ template: 'hero',
62
+ contents: [
63
+ {
64
+ type: 'paragraph',
65
+ children: [{ type: 'text', value: 'A small editor for big ideas.' }],
66
+ },
67
+ ],
68
+ };
69
+
70
+ const expanded = expandDocBlocks([block], { customTemplates: [heroDef] });
71
+ expect(expanded).toHaveLength(1);
72
+ const layers = expanded[0].layers ?? [];
73
+ expect(layers).toHaveLength(2);
74
+ expect((layers[0] as TextLayer).content.text).toBe('Welcome');
75
+ expect((layers[1] as TextLayer).content.text).toBe('A small editor for big ideas.');
76
+ });
77
+
78
+ it('preserves %-based positions so the SSR renderer can resolve them per viewport', () => {
79
+ const block: Block = {
80
+ id: 'b1',
81
+ startTime: 0,
82
+ duration: 1,
83
+ audioSegment: 0,
84
+ title: 'X',
85
+ template: 'hero',
86
+ };
87
+ const expanded = expandDocBlocks([block], { customTemplates: [heroDef] });
88
+ expect(expanded[0].layers![0].position.x).toBe('5%');
89
+ expect(expanded[0].layers![0].position.width).toBe('90%');
90
+ });
91
+
92
+ it('falls back to a no-layer block (with a warning) when the template is unknown', () => {
93
+ // Suppress the expected console.warn from `expandTemplateBlock`.
94
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
95
+ const block: Block = {
96
+ id: 'b1',
97
+ startTime: 0,
98
+ duration: 1,
99
+ audioSegment: 0,
100
+ title: 'Y',
101
+ template: 'nonexistent',
102
+ };
103
+ const expanded = expandDocBlocks([block], { customTemplates: [heroDef] });
104
+ expect(expanded[0].layers).toBeUndefined();
105
+ expect(warnSpy).toHaveBeenCalled();
106
+ warnSpy.mockRestore();
107
+ });
108
+ });
109
+
110
+ import { vi } from 'vitest';
@@ -13,7 +13,13 @@
13
13
  * This is shared code used by both site and efb-app doc renderers.
14
14
  */
15
15
 
16
- import type { Layer, ImageLayer, ShapeLayer, Animation } from '../../schemas/Doc.js';
16
+ import type {
17
+ Layer,
18
+ ImageLayer,
19
+ ImageTreatment,
20
+ ShapeLayer,
21
+ Animation,
22
+ } from '../../schemas/Doc.js';
17
23
  import type { AccentImage, AccentPosition } from '../../schemas/BlockTemplates.js';
18
24
 
19
25
  const PERCENTAGE_RE = /^(\d+(?:\.\d+)?)\s*%?$/;
@@ -125,28 +131,40 @@ export function getAccentLayout(position: AccentPosition): AccentLayout {
125
131
  * Create layers for an accent image.
126
132
  * Returns the image layer and any overlay/gradient layers needed.
127
133
  */
128
- export function createAccentLayers(accent: AccentImage, slideId: string): Layer[] {
134
+ export function createAccentLayers(
135
+ accent: AccentImage,
136
+ slideId: string,
137
+ treatment?: ImageTreatment,
138
+ ): Layer[] {
129
139
  const layers: Layer[] = [];
130
140
  const { src, alt, position, ambientMotion, credit, license } = accent;
131
141
 
132
142
  switch (position) {
133
143
  case 'left-strip':
134
- layers.push(createStripImage(src, alt, 'left', slideId, ambientMotion, credit, license));
144
+ layers.push(
145
+ createStripImage(src, alt, 'left', slideId, ambientMotion, credit, license, treatment),
146
+ );
135
147
  layers.push(createStripGradient('left', slideId));
136
148
  break;
137
149
 
138
150
  case 'right-strip':
139
- layers.push(createStripImage(src, alt, 'right', slideId, ambientMotion, credit, license));
151
+ layers.push(
152
+ createStripImage(src, alt, 'right', slideId, ambientMotion, credit, license, treatment),
153
+ );
140
154
  layers.push(createStripGradient('right', slideId));
141
155
  break;
142
156
 
143
157
  case 'bottom-strip':
144
- layers.push(createBottomStripImage(src, alt, slideId, ambientMotion, credit, license));
158
+ layers.push(
159
+ createBottomStripImage(src, alt, slideId, ambientMotion, credit, license, treatment),
160
+ );
145
161
  layers.push(createBottomStripGradient(slideId));
146
162
  break;
147
163
 
148
164
  case 'corner-inset':
149
- layers.push(createCornerInsetImage(src, alt, slideId, ambientMotion, credit, license));
165
+ layers.push(
166
+ createCornerInsetImage(src, alt, slideId, ambientMotion, credit, license, treatment),
167
+ );
150
168
  layers.push(createCornerVignette(slideId));
151
169
  break;
152
170
  }
@@ -165,6 +183,7 @@ function createStripImage(
165
183
  ambientMotion?: AccentImage['ambientMotion'],
166
184
  credit?: string,
167
185
  license?: string,
186
+ treatment?: ImageTreatment,
168
187
  ): ImageLayer {
169
188
  return {
170
189
  type: 'image',
@@ -175,6 +194,7 @@ function createStripImage(
175
194
  fit: 'cover',
176
195
  credit,
177
196
  license,
197
+ ...(treatment ? { treatment } : {}),
178
198
  },
179
199
  position: {
180
200
  x: side === 'left' ? 0 : `${100 - STRIP_SIZE}%`,
@@ -221,6 +241,7 @@ function createBottomStripImage(
221
241
  ambientMotion?: AccentImage['ambientMotion'],
222
242
  credit?: string,
223
243
  license?: string,
244
+ treatment?: ImageTreatment,
224
245
  ): ImageLayer {
225
246
  return {
226
247
  type: 'image',
@@ -231,6 +252,7 @@ function createBottomStripImage(
231
252
  fit: 'cover',
232
253
  credit,
233
254
  license,
255
+ ...(treatment ? { treatment } : {}),
234
256
  },
235
257
  position: {
236
258
  x: 0,
@@ -272,6 +294,7 @@ function createCornerInsetImage(
272
294
  ambientMotion?: AccentImage['ambientMotion'],
273
295
  credit?: string,
274
296
  license?: string,
297
+ treatment?: ImageTreatment,
275
298
  ): ImageLayer {
276
299
  return {
277
300
  type: 'image',
@@ -282,6 +305,7 @@ function createCornerInsetImage(
282
305
  fit: 'cover',
283
306
  credit,
284
307
  license,
308
+ ...(treatment ? { treatment } : {}),
285
309
  },
286
310
  position: {
287
311
  x: '70%',
@@ -31,3 +31,26 @@ export function createBackgroundLayer(id: string, fill: string): Layer {
31
31
  position: { x: 0, y: 0, width: '100%', height: '100%' },
32
32
  };
33
33
  }
34
+
35
+ /**
36
+ * Estimate the rendered height (px) of a wrapped text block.
37
+ *
38
+ * Templates are pure functions with no access to real text metrics, but
39
+ * stacked lockups (quote + attribution, fact + explanation + source) need
40
+ * to place each element relative to the previous one instead of at fixed
41
+ * slots — fixed slots leave 200px voids for short content and collide for
42
+ * long content. The 0.52 average-glyph-width factor is tuned for the
43
+ * sans/serif faces the built-in themes use; it only needs to be right
44
+ * within ~20% for spacing purposes.
45
+ */
46
+ export function estimateTextHeight(
47
+ text: string,
48
+ fontSizePx: number,
49
+ maxWidthPx: number,
50
+ lineHeight: number,
51
+ ): number {
52
+ const avgCharWidth = fontSizePx * 0.52;
53
+ const charsPerLine = Math.max(8, Math.floor(maxWidthPx / avgCharWidth));
54
+ const lines = Math.max(1, Math.ceil(text.length / charsPerLine));
55
+ return lines * fontSizePx * lineHeight;
56
+ }
@@ -10,28 +10,20 @@
10
10
 
11
11
  import type { Layer } from '../../schemas/Doc.js';
12
12
  import type { ComparisonBarInput, TemplateContext } from '../../schemas/BlockTemplates.js';
13
- import { scaledFontSize } from '../../schemas/BlockTemplates.js';
14
- import { resolveColorScheme, getThemeFont } from '../utils/themeUtils.js';
13
+ import {
14
+ resolveColorScheme,
15
+ getThemeFont,
16
+ themedFontSize,
17
+ themedSurfaceGradient,
18
+ } from '../utils/themeUtils.js';
15
19
 
16
20
  export function comparisonBar(input: ComparisonBarInput, context: TemplateContext): Layer[] {
17
21
  const { leftLabel, leftValue, rightLabel, rightValue, unit, colorScheme = 'blue' } = input;
18
- const { theme } = context;
22
+ const { theme, viewport } = context;
19
23
  const colors = resolveColorScheme(context, colorScheme);
20
24
 
21
- const labelFontSize = scaledFontSize(28, context, false);
22
- const valueFontSize = scaledFontSize(48, context, true);
23
-
24
- // Calculate proportional bar widths (max bar = 65% of viewport width)
25
- const maxValue = Math.max(leftValue, rightValue, 1);
26
- const maxBarWidth = 65;
27
- const leftBarWidth = (leftValue / maxValue) * maxBarWidth;
28
- const rightBarWidth = (rightValue / maxValue) * maxBarWidth;
29
-
30
- // Bar positioning
31
- const barStartX = 15;
32
- const barHeight = 6; // % of viewport height
33
- const topBarY = 36;
34
- const bottomBarY = 58;
25
+ const labelFontSize = themedFontSize(28, context, false);
26
+ const valueFontSize = themedFontSize(48, context, true);
35
27
 
36
28
  // Format values for display
37
29
  const formatValue = (v: number): string => {
@@ -43,6 +35,22 @@ export function comparisonBar(input: ComparisonBarInput, context: TemplateContex
43
35
  const leftDisplay = unit ? `${formatValue(leftValue)} ${unit}` : formatValue(leftValue);
44
36
  const rightDisplay = unit ? `${formatValue(rightValue)} ${unit}` : formatValue(rightValue);
45
37
 
38
+ // Bar positioning
39
+ const barStartX = 15;
40
+ const barHeight = 6; // % of viewport height
41
+ const topBarY = 36;
42
+ const bottomBarY = 58;
43
+
44
+ // Calculate proportional bar widths. The widest bar must leave room
45
+ // for its trailing value label inside a 96% safe area — otherwise a
46
+ // long label ("84 clarity score") runs off the right edge of the block.
47
+ const maxValue = Math.max(leftValue, rightValue, 1);
48
+ const longestLabelPx = Math.max(leftDisplay.length, rightDisplay.length) * valueFontSize * 0.58;
49
+ const labelWidthPct = (longestLabelPx / viewport.width) * 100;
50
+ const maxBarWidth = Math.max(20, Math.min(65, 96 - barStartX - labelWidthPct - 2));
51
+ const leftBarWidth = (leftValue / maxValue) * maxBarWidth;
52
+ const rightBarWidth = (rightValue / maxValue) * maxBarWidth;
53
+
46
54
  return [
47
55
  // Background
48
56
  {
@@ -50,7 +58,7 @@ export function comparisonBar(input: ComparisonBarInput, context: TemplateContex
50
58
  id: 'bg',
51
59
  content: {
52
60
  shape: 'rect',
53
- fill: `linear-gradient(180deg, ${theme.colors.background} 0%, #0f1520 100%)`,
61
+ fill: themedSurfaceGradient(context, 180),
54
62
  },
55
63
  position: { x: 0, y: 0, width: '100%', height: '100%' },
56
64
  },
@@ -16,9 +16,8 @@
16
16
 
17
17
  import type { Layer } from '../../schemas/Doc.js';
18
18
  import type { TemplateContext } from '../../schemas/BlockTemplates.js';
19
- import { scaledFontSize } from '../../schemas/BlockTemplates.js';
20
19
  import type { StartBlockConfig } from '../../schemas/Doc.js';
21
- import { getThemeFont } from '../utils/themeUtils.js';
20
+ import { getThemeFont, themedFontSize, themedImageTreatment } from '../utils/themeUtils.js';
22
21
  import { mapAmbientMotion } from './accentImage.js';
23
22
 
24
23
  /**
@@ -39,18 +38,21 @@ export interface CoverBlockInput {
39
38
  heroCredit?: string;
40
39
  /** License identifier */
41
40
  heroLicense?: string;
41
+ /** Per-block override for the theme's photographic image grade. */
42
+ imageTreatment?: 'none' | 'mono' | 'duotone' | 'warm' | 'cool';
42
43
  }
43
44
 
44
45
  /**
45
46
  * Generate cover block layers from StartBlockConfig.
46
47
  */
47
48
  export function coverBlock(input: CoverBlockInput, context: TemplateContext): Layer[] {
49
+ const treatment = themedImageTreatment(context, input.imageTreatment);
48
50
  const { heroSrc, heroAlt, title, subtitle, ambientMotion, heroCredit, heroLicense } = input;
49
51
  const { theme, layout } = context;
50
52
 
51
53
  // Scale font sizes for viewport - cover titles are larger than regular title blocks
52
- const titleFontSize = scaledFontSize(120, context, true);
53
- const subtitleFontSize = scaledFontSize(40, context, false);
54
+ const titleFontSize = themedFontSize(120, context, true);
55
+ const subtitleFontSize = themedFontSize(40, context, false);
54
56
 
55
57
  const layers: Layer[] = [];
56
58
 
@@ -68,6 +70,7 @@ export function coverBlock(input: CoverBlockInput, context: TemplateContext): La
68
70
  fit: 'cover',
69
71
  credit: heroCredit,
70
72
  license: heroLicense,
73
+ ...(treatment ? { treatment } : {}),
71
74
  },
72
75
  position: { x: 0, y: 0, width: '100%', height: '100%' },
73
76
  animation: imageAnimation,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Custom template factory.
3
+ *
4
+ * Turns a `CustomTemplateDefinition` (user-authored visual design with
5
+ * placeholder tokens) into a runtime `TemplateFunction` that can be
6
+ * dropped into the template registry alongside built-ins.
7
+ *
8
+ * The generated function's job is small: read the source `Block` from
9
+ * `context.block` and hand the definition's layers off to
10
+ * `resolveTokens` for placeholder substitution. The hard work (drag-
11
+ * to-position, token grammar, image lookup) happens in the designer
12
+ * UI and the resolver, not here.
13
+ */
14
+
15
+ import type { Layer } from '../../schemas/Doc.js';
16
+ import type {
17
+ RawLayersInput,
18
+ TemplateContext,
19
+ TemplateFunction,
20
+ } from '../../schemas/BlockTemplates.js';
21
+ import type { CustomTemplateDefinition } from '../../schemas/CustomTemplates.js';
22
+ import { resolveTokens } from './tokens/resolveTokens.js';
23
+
24
+ /**
25
+ * Build a template function for a user-authored custom template.
26
+ *
27
+ * The returned function is registered under `def.name` in the merged
28
+ * registry (see `buildRegistry` in `templates/index.ts`). At expansion
29
+ * time it receives the standard `TemplateBlock` + `TemplateContext`
30
+ * pair; the context's `block` field carries the source markdown-derived
31
+ * block whose data drives token resolution.
32
+ *
33
+ * When `context.block` is missing (shouldn't happen in the normal
34
+ * pipeline but defensively handled), the layers are returned with
35
+ * tokens left unresolved — better visible placeholders than a thrown
36
+ * error.
37
+ */
38
+ export function makeCustomTemplateFn(
39
+ def: CustomTemplateDefinition,
40
+ ): TemplateFunction<RawLayersInput> {
41
+ return (_input: RawLayersInput, context: TemplateContext): Layer[] => {
42
+ if (!context.block) return def.layers.slice();
43
+ return resolveTokens(def.layers, context.block);
44
+ };
45
+ }
@@ -11,24 +11,35 @@
11
11
 
12
12
  import type { Layer } from '../../schemas/Doc.js';
13
13
  import type { DataTableInput, TemplateContext } from '../../schemas/BlockTemplates.js';
14
- import { scaledFontSize } from '../../schemas/BlockTemplates.js';
15
- import { getThemeFont, resolveColorScheme } from '../utils/themeUtils.js';
14
+ import {
15
+ getThemeFont,
16
+ resolveColorScheme,
17
+ shouldUseShadow,
18
+ themedFontSize,
19
+ themedSurfaceGradient,
20
+ } from '../utils/themeUtils.js';
21
+ import { pickContrastingText, withAlpha } from '../../schemas/colorUtils.js';
16
22
  import { createBackgroundLayer } from './captionUtils.js';
17
23
 
18
24
  export function dataTable(input: DataTableInput, context: TemplateContext): Layer[] {
19
25
  const { title, headers, rows, align, colorScheme } = input;
20
- const { theme } = context;
26
+ const { theme, viewport } = context;
21
27
 
22
28
  const colors = resolveColorScheme(context, colorScheme);
23
- const titleFontSize = scaledFontSize(48, context, true);
24
- const tableFontSize = scaledFontSize(28, context, false);
29
+ const titleFontSize = themedFontSize(48, context, true);
30
+ const tableFontSize = themedFontSize(28, context, false);
25
31
 
26
- const layers: Layer[] = [
27
- createBackgroundLayer(
28
- 'bg',
29
- `linear-gradient(170deg, ${theme.colors.background} 0%, #0f1520 100%)`,
30
- ),
31
- ];
32
+ const layers: Layer[] = [createBackgroundLayer('bg', themedSurfaceGradient(context, 170))];
33
+
34
+ // Size the table band from its natural content height (header + rows at
35
+ // ~2. line height for cell padding) and center the title+table as one
36
+ // group — a full-height band left a ~200px orphan gap under the title.
37
+ // `rows` is required by the schema but may be missing on partially-
38
+ // authored blocks in live preview; treat it as empty rather than throwing.
39
+ const rowCount = (Array.isArray(rows) ? rows.length : 0) + 1;
40
+ const naturalTableHPct = Math.min(74, ((rowCount * tableFontSize * 2.4) / viewport.height) * 100);
41
+ const titleBandPct = title ? (titleFontSize * 2.2 * 100) / viewport.height : 0;
42
+ const groupTopPct = Math.max(8, (100 - titleBandPct - naturalTableHPct) / 2);
32
43
 
33
44
  // Optional title above the table
34
45
  if (title) {
@@ -43,15 +54,22 @@ export function dataTable(input: DataTableInput, context: TemplateContext): Laye
43
54
  fontWeight: 'bold',
44
55
  color: theme.colors.text,
45
56
  textAlign: 'center',
46
- shadow: true,
57
+ shadow: shouldUseShadow(context),
47
58
  },
48
59
  },
49
- position: { x: '50%', y: '10%', width: '80%', anchor: 'center' },
60
+ position: {
61
+ x: '50%',
62
+ y: `${groupTopPct + titleBandPct / 2}%`,
63
+ width: '80%',
64
+ anchor: 'center',
65
+ },
50
66
  animation: { type: 'fadeIn', duration: 0.8 },
51
67
  });
52
68
  }
53
69
 
54
- // Table layer
70
+ // Table layer. Header text color is picked for contrast against the
71
+ // header fill — pairing two mid-tones from the same scheme made the
72
+ // header row read as a smudge in most themes.
55
73
  layers.push({
56
74
  type: 'table',
57
75
  id: 'table',
@@ -61,10 +79,10 @@ export function dataTable(input: DataTableInput, context: TemplateContext): Laye
61
79
  align,
62
80
  style: {
63
81
  headerBackground: colors.accent,
64
- headerColor: colors.text,
65
- cellBackground: 'rgba(255,255,255,0.05)',
82
+ headerColor: pickContrastingText(colors.accent),
83
+ cellBackground: withAlpha(theme.colors.text, 0.04),
66
84
  cellColor: theme.colors.text,
67
- borderColor: 'rgba(255,255,255,0.12)',
85
+ borderColor: withAlpha(theme.colors.text, 0.15),
68
86
  fontSize: tableFontSize,
69
87
  fontFamily: getThemeFont(context, 'body'),
70
88
  headerFontFamily: getThemeFont(context, 'title'),
@@ -73,9 +91,9 @@ export function dataTable(input: DataTableInput, context: TemplateContext): Laye
73
91
  },
74
92
  position: {
75
93
  x: '10%',
76
- y: title ? '18%' : '8%',
94
+ y: `${groupTopPct + titleBandPct}%`,
77
95
  width: '80%',
78
- height: title ? '74%' : '84%',
96
+ height: `${naturalTableHPct}%`,
79
97
  },
80
98
  animation: { type: 'fadeIn', duration: 1, delay: title ? 0.4 : 0 },
81
99
  });
@@ -12,37 +12,52 @@
12
12
 
13
13
  import type { Layer } from '../../schemas/Doc.js';
14
14
  import type { DateEventInput, TemplateContext } from '../../schemas/BlockTemplates.js';
15
- import { scaledFontSize } from '../../schemas/BlockTemplates.js';
16
- import { getThemeFont } from '../utils/themeUtils.js';
15
+ import {
16
+ getThemeFont,
17
+ shouldUseShadow,
18
+ themedFontSize,
19
+ themedSurfaceGradient,
20
+ themedImageTreatment,
21
+ } from '../utils/themeUtils.js';
22
+ import { oklchDarken, relativeLuminance } from '../../schemas/colorUtils.js';
17
23
  import { createAccentLayers, getAccentLayout, adjustY, DEFAULT_LAYOUT } from './accentImage.js';
18
24
 
19
- const MOOD_COLORS = {
20
- neutral: { bg: '#1a202c', date: '#63b3ed', text: '#ffffff' },
21
- somber: { bg: '#1a1a2e', date: '#e53e3e', text: '#a0aec0' },
22
- celebratory: { bg: '#1a365d', date: '#68d391', text: '#ffffff' },
25
+ /**
26
+ * Mood accents tint the hero date, not the surface — the surface always
27
+ * comes from the theme. (Hard-coded dark mood panels used to turn light
28
+ * and warm themes into a cold navy slide.)
29
+ */
30
+ const MOOD_ACCENTS = {
31
+ neutral: '#63b3ed',
32
+ somber: '#e53e3e',
33
+ celebratory: '#68d391',
23
34
  };
24
35
 
25
36
  export function dateEvent(input: DateEventInput, context: TemplateContext): Layer[] {
26
37
  const { date, description, footer, mood = 'neutral', accentImage } = input;
27
38
  const { theme } = context;
28
- const colors = MOOD_COLORS[mood];
39
+ // Deepen the mood accent on light surfaces so the hero date keeps contrast.
40
+ const moodAccent = MOOD_ACCENTS[mood];
41
+ const dateColor =
42
+ relativeLuminance(theme.colors.background) > 0.5 ? oklchDarken(moodAccent, 0.25) : moodAccent;
29
43
 
30
44
  // Get layout adjustments if accent image is present
31
45
  const accentLayout = accentImage ? getAccentLayout(accentImage.position) : DEFAULT_LAYOUT;
32
46
 
33
47
  // Scale font sizes — date is the hero element
34
- const dateFontSize = scaledFontSize(96, context, true);
35
- const descFontSize = scaledFontSize(30, context, false);
36
- const footerFontSize = scaledFontSize(26, context, false);
48
+ const dateFontSize = themedFontSize(96, context, true);
49
+ const descFontSize = themedFontSize(30, context, false);
50
+ const footerFontSize = themedFontSize(26, context, false);
37
51
 
38
52
  const layers: Layer[] = [
39
- // Background — unique diagonal gradient to differentiate from other dark templates
53
+ // Background — theme surface gradient (diagonal to differentiate
54
+ // from other text templates)
40
55
  {
41
56
  type: 'shape',
42
57
  id: 'bg',
43
58
  content: {
44
59
  shape: 'rect',
45
- fill: `linear-gradient(135deg, ${colors.bg} 0%, #0d1117 100%)`,
60
+ fill: themedSurfaceGradient(context, 135),
46
61
  },
47
62
  position: { x: 0, y: 0, width: '100%', height: '100%' },
48
63
  },
@@ -50,7 +65,13 @@ export function dateEvent(input: DateEventInput, context: TemplateContext): Laye
50
65
 
51
66
  // Add accent image layers (behind text, after background)
52
67
  if (accentImage) {
53
- layers.push(...createAccentLayers(accentImage, input.id));
68
+ layers.push(
69
+ ...createAccentLayers(
70
+ accentImage,
71
+ input.id,
72
+ themedImageTreatment(context, input.imageTreatment),
73
+ ),
74
+ );
54
75
  }
55
76
 
56
77
  // Date — hero element, much larger
@@ -63,8 +84,8 @@ export function dateEvent(input: DateEventInput, context: TemplateContext): Laye
63
84
  fontSize: dateFontSize,
64
85
  fontFamily: getThemeFont(context, 'title'),
65
86
  fontWeight: 'bold',
66
- color: colors.date,
67
- shadow: !!accentImage,
87
+ color: dateColor,
88
+ shadow: shouldUseShadow(context),
68
89
  },
69
90
  },
70
91
  position: {
@@ -84,10 +105,10 @@ export function dateEvent(input: DateEventInput, context: TemplateContext): Laye
84
105
  style: {
85
106
  fontSize: descFontSize,
86
107
  fontFamily: getThemeFont(context, 'body'),
87
- color: colors.text,
108
+ color: theme.colors.text,
88
109
  textAlign: 'center',
89
- lineHeight: 1.8,
90
- shadow: !!accentImage,
110
+ lineHeight: 1.6,
111
+ shadow: shouldUseShadow(context),
91
112
  },
92
113
  },
93
114
  position: {
@@ -110,12 +131,12 @@ export function dateEvent(input: DateEventInput, context: TemplateContext): Laye
110
131
  fontSize: footerFontSize,
111
132
  fontFamily: getThemeFont(context, 'body'),
112
133
  color: theme.colors.textMuted,
113
- shadow: !!accentImage,
134
+ shadow: shouldUseShadow(context),
114
135
  },
115
136
  },
116
137
  position: {
117
138
  x: accentLayout.textCenterX,
118
- y: adjustY('82%', accentLayout),
139
+ y: adjustY('72%', accentLayout),
119
140
  anchor: 'center',
120
141
  },
121
142
  animation: { type: 'fadeIn', duration: 1, delay: 3 },