@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
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Media-clip timing model.
3
+ *
4
+ * An additive layer on top of the legacy sequential `Doc.audio.segments[]`
5
+ * narration track. A {@link MediaClip} is a piece of audio or video whose
6
+ * timing is expressed *relative to its parent block* (or the whole document),
7
+ * with an optional `startAt` offset and an optional `spillover` past the
8
+ * block's end. {@link resolveMediaSchedule} flattens these into absolute,
9
+ * doc-timeline `ScheduledClip`s that playback and export consume.
10
+ *
11
+ * Authoring: a body-level `{[audio …]}` / `{[video …]}` annotation inside a
12
+ * block becomes a `block.media` clip; the same annotation in the preamble
13
+ * (before the first heading) with `anchor=document` becomes a
14
+ * `doc.documentMedia` clip that spans the whole timeline.
15
+ */
16
+
17
+ import type { Block, Doc } from './Doc.js';
18
+
19
+ /**
20
+ * A timed piece of media attached to a block (or the document).
21
+ */
22
+ export interface MediaClip {
23
+ /** Stable id (for React keys and timeline selection). */
24
+ id: string;
25
+ /** Source path (mp3/mp4/…), relative to the article media dir. */
26
+ src: string;
27
+ kind: 'audio' | 'video';
28
+ /**
29
+ * Seconds from the parent block's start when this clip begins. For an
30
+ * `anchor='document'` clip, seconds from the document start. Default 0.
31
+ */
32
+ startAt: number;
33
+ /** Source in-point within the file (seconds). Default 0. */
34
+ clipStart?: number;
35
+ /** Source out-point within the file (seconds). Default: file/block end. */
36
+ clipEnd?: number;
37
+ /**
38
+ * When false (default) the clip stops at its block's end. When true it
39
+ * keeps playing past the block boundary until the clip itself finishes.
40
+ * Ignored for `anchor='document'` clips (they already span the timeline).
41
+ */
42
+ spillover?: boolean;
43
+ /**
44
+ * `'block'` (default) — timed relative to the parent block. `'document'` —
45
+ * timed relative to the document start and able to span every block.
46
+ */
47
+ anchor: 'block' | 'document';
48
+ /**
49
+ * 1-based source line of the authoring annotation, when derived from
50
+ * markdown. Enables round-tripping edits (e.g. the timeline editor rewriting
51
+ * `startAt`) back to the exact line. Absent for programmatically built clips.
52
+ */
53
+ sourceLine?: number;
54
+ }
55
+
56
+ /**
57
+ * A {@link MediaClip} resolved to absolute document-timeline coordinates.
58
+ * Produced by {@link resolveMediaSchedule}; consumed by the playback
59
+ * scheduler and the MP4 export.
60
+ */
61
+ export interface ScheduledClip {
62
+ id: string;
63
+ src: string;
64
+ kind: 'audio' | 'video';
65
+ /** Absolute doc-timeline second the clip starts. */
66
+ absoluteStart: number;
67
+ /** Absolute doc-timeline second the clip ends (exclusive). */
68
+ absoluteEnd: number;
69
+ /** Source in-point to seek to at `absoluteStart`. */
70
+ sourceIn: number;
71
+ anchor: 'block' | 'document';
72
+ /** Owning block id for block-anchored clips (for video re-homing/export). */
73
+ blockId?: string;
74
+ /** 1-based source line of the authoring annotation, when known. */
75
+ sourceLine?: number;
76
+ }
77
+
78
+ /** Depth-first flatten of the block tree (local copy to avoid a doc-layer dep). */
79
+ function flatten(blocks: Block[], out: Block[] = []): Block[] {
80
+ for (const b of blocks) {
81
+ out.push(b);
82
+ if (b.children && b.children.length > 0) flatten(b.children, out);
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /** Played length of a clip when known from its in/out points, else null. */
88
+ function clipLength(clip: MediaClip): number | null {
89
+ if (clip.clipEnd == null) return null;
90
+ return Math.max(0, clip.clipEnd - (clip.clipStart ?? 0));
91
+ }
92
+
93
+ /**
94
+ * Base playback span ignoring media — the larger of the audio-segment sum
95
+ * and the last block's end. Document clips that don't pin a length run to
96
+ * this point.
97
+ */
98
+ function baseTimelineEnd(doc: Doc): number {
99
+ const blockEnd = flatten(doc.blocks).reduce(
100
+ (max, b) => Math.max(max, b.startTime + b.duration),
101
+ 0,
102
+ );
103
+ return Math.max(doc.duration ?? 0, blockEnd);
104
+ }
105
+
106
+ /**
107
+ * Flatten every block clip and document clip into absolute-timed
108
+ * `ScheduledClip`s. Pure — depends only on `doc`.
109
+ */
110
+ export function resolveMediaSchedule(doc: Doc): ScheduledClip[] {
111
+ const out: ScheduledClip[] = [];
112
+ const docEnd = baseTimelineEnd(doc);
113
+
114
+ for (const block of flatten(doc.blocks)) {
115
+ const blockEnd = block.startTime + block.duration;
116
+ for (const clip of block.media ?? []) {
117
+ const start = block.startTime + clip.startAt;
118
+ const len = clipLength(clip);
119
+ let end: number;
120
+ if (clip.spillover) {
121
+ // Continue past the block; fall back to the block end when the
122
+ // played length is unknown (no clipEnd authored).
123
+ end = len != null ? start + len : blockEnd;
124
+ } else {
125
+ end = len != null ? Math.min(start + len, blockEnd) : blockEnd;
126
+ }
127
+ out.push({
128
+ id: clip.id,
129
+ src: clip.src,
130
+ kind: clip.kind,
131
+ absoluteStart: start,
132
+ absoluteEnd: Math.max(start, end),
133
+ sourceIn: clip.clipStart ?? 0,
134
+ anchor: 'block',
135
+ blockId: block.id,
136
+ ...(clip.sourceLine != null ? { sourceLine: clip.sourceLine } : {}),
137
+ });
138
+ }
139
+ }
140
+
141
+ for (const clip of doc.documentMedia ?? []) {
142
+ const start = clip.startAt;
143
+ const len = clipLength(clip);
144
+ const end = len != null ? start + len : docEnd;
145
+ out.push({
146
+ id: clip.id,
147
+ src: clip.src,
148
+ kind: clip.kind,
149
+ absoluteStart: start,
150
+ absoluteEnd: Math.max(start, end),
151
+ sourceIn: clip.clipStart ?? 0,
152
+ anchor: 'document',
153
+ ...(clip.sourceLine != null ? { sourceLine: clip.sourceLine } : {}),
154
+ });
155
+ }
156
+
157
+ return out;
158
+ }
159
+
160
+ /**
161
+ * Total playback duration including any media that spills past the last
162
+ * block (or audio segment). Export uses this for the frame count and the
163
+ * player for its effective timeline length.
164
+ */
165
+ export function getDocPlaybackDuration(doc: Doc): number {
166
+ const base = baseTimelineEnd(doc);
167
+ const mediaEnd = resolveMediaSchedule(doc).reduce((max, c) => Math.max(max, c.absoluteEnd), 0);
168
+ return Math.max(base, mediaEnd);
169
+ }
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  import type { LayoutHints } from './LayoutStrategy.js';
24
- import type { AnimationType, TransitionType } from './Doc.js';
24
+ import type { AnimationType, ImageTreatment, TransitionType } from './Doc.js';
25
25
  import type { PersistentLayerConfig } from './BlockTemplates.js';
26
26
 
27
27
  // ============================================
@@ -32,6 +32,14 @@ import type { PersistentLayerConfig } from './BlockTemplates.js';
32
32
  export const THEME_SCHEMA_VERSION = '1' as const;
33
33
  export type ThemeSchemaVersion = typeof THEME_SCHEMA_VERSION;
34
34
 
35
+ /**
36
+ * Frontmatter key under which a document inlines its custom Theme
37
+ * definitions. The theme analog of `FRONTMATTER_CUSTOM_TEMPLATES_KEY`
38
+ * (custom block templates). See `doc/customThemesFrontmatter.ts` for the
39
+ * codec and `doc/resolveDocTheme.ts` for doc-scoped resolution.
40
+ */
41
+ export const FRONTMATTER_CUSTOM_THEMES_KEY = 'squisq-custom-themes';
42
+
35
43
  // ============================================
36
44
  // Color Palette
37
45
  // ============================================
@@ -146,6 +154,13 @@ export interface ThemeStyle {
146
154
  animationSpeed?: number;
147
155
  /** Default horizontal padding for text (percentage string, e.g. "5%") */
148
156
  blockPadding?: string;
157
+ /**
158
+ * Photographic grade applied to template imagery (full-bleed photos,
159
+ * feature halves, accent strips) so photos look native to the theme.
160
+ * Duotone tints default to the theme primary. Blocks opt out per
161
+ * template via `imageTreatment: 'none'`.
162
+ */
163
+ imageTreatment?: ImageTreatment;
149
164
  }
150
165
 
151
166
  // ============================================
@@ -195,6 +210,13 @@ export interface Theme {
195
210
  name: string;
196
211
  /** Short description for theme pickers */
197
212
  description?: string;
213
+ /**
214
+ * Id of the theme this custom theme was derived from, if any. Recorded
215
+ * by `compileTheme({ base })` so a customizer can re-open the theme and
216
+ * re-inherit the base's render style / color schemes / typography.
217
+ * Absent on built-ins.
218
+ */
219
+ basedOn?: string;
198
220
  /** Optional authoring seeds (customizer fills these; built-ins typically omit) */
199
221
  seedColors?: ThemeSeedColors;
200
222
  /** Color palette */
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Shared transition vocabulary for block-to-block slide changes.
3
+ *
4
+ * The canonical names are based on PowerPoint / PresentationML transition
5
+ * names where possible, with a few legacy renderer aliases kept so older
6
+ * Squisq documents continue to work.
7
+ */
8
+
9
+ export const DEFAULT_TRANSITION_DURATION_SECONDS = 0.7;
10
+
11
+ export const TRANSITION_TYPES = [
12
+ // Existing / common transitions
13
+ 'cut',
14
+ 'fade',
15
+ 'dissolve',
16
+ 'push',
17
+ 'wipe',
18
+ 'split',
19
+ 'reveal',
20
+ 'randomBars',
21
+ 'shape',
22
+ 'uncover',
23
+ 'cover',
24
+ 'zoom',
25
+ 'pan',
26
+
27
+ // PowerPoint subtle / exciting transitions
28
+ 'flash',
29
+ 'fallOver',
30
+ 'drape',
31
+ 'curtains',
32
+ 'wind',
33
+ 'prestige',
34
+ 'fracture',
35
+ 'crush',
36
+ 'peelOff',
37
+ 'pageCurl',
38
+ 'airplane',
39
+ 'origami',
40
+ 'checkerboard',
41
+ 'blinds',
42
+ 'clock',
43
+ 'ripple',
44
+ 'honeycomb',
45
+ 'glitter',
46
+ 'vortex',
47
+ 'shred',
48
+ 'switch',
49
+ 'flip',
50
+ 'gallery',
51
+ 'cube',
52
+ 'doors',
53
+ 'box',
54
+ 'ferrisWheel',
55
+ 'conveyor',
56
+ 'rotate',
57
+ 'window',
58
+ 'orbit',
59
+ 'flyThrough',
60
+ 'morph',
61
+
62
+ // PresentationML/Open XML precise names and legacy aliases
63
+ 'checker',
64
+ 'circle',
65
+ 'comb',
66
+ 'diamond',
67
+ 'newsflash',
68
+ 'plus',
69
+ 'pull',
70
+ 'random',
71
+ 'randomBar',
72
+ 'strips',
73
+ 'wedge',
74
+ 'wheel',
75
+ 'prism',
76
+ 'warp',
77
+ 'ferris',
78
+ 'flythrough',
79
+ 'wheelReverse',
80
+ 'pageCurlDouble',
81
+ 'pageCurlSingle',
82
+ 'slideLeft',
83
+ 'slideRight',
84
+ 'slideUp',
85
+ 'slideDown',
86
+ ] as const;
87
+
88
+ export type TransitionType = (typeof TRANSITION_TYPES)[number];
89
+
90
+ export const TRANSITION_DIRECTIONS = [
91
+ 'left',
92
+ 'right',
93
+ 'up',
94
+ 'down',
95
+ 'in',
96
+ 'out',
97
+ 'horizontal',
98
+ 'vertical',
99
+ ] as const;
100
+
101
+ export type TransitionDirection = (typeof TRANSITION_DIRECTIONS)[number];
102
+
103
+ /**
104
+ * Transition between blocks.
105
+ *
106
+ * A block's transition is the lead-in from the previous block. The same
107
+ * transition drives the entering block and the previous block's exit.
108
+ */
109
+ export interface Transition {
110
+ /** Transition type */
111
+ type: TransitionType;
112
+ /** Duration in seconds. Defaults to DEFAULT_TRANSITION_DURATION_SECONDS. */
113
+ duration?: number;
114
+ /** Optional direction/variant for directional transitions. */
115
+ direction?: TransitionDirection;
116
+ }
117
+
118
+ const TRANSITION_TYPE_SET = new Set<string>(TRANSITION_TYPES);
119
+
120
+ const TRANSITION_TYPE_ALIASES: Record<string, TransitionType> = {
121
+ none: 'cut',
122
+ randombars: 'randomBars',
123
+ randombar: 'randomBar',
124
+ // Self-referential spellings (`checkerboard`, `shape`, `uncover`) are
125
+ // intentionally absent: an exact valid type returns early from
126
+ // `normalizeTransitionType`, and any case variant resolves through
127
+ // `NORMALIZED_TRANSITION_TYPES`. Only the *plural* aliases need an entry.
128
+ checkerboards: 'checkerboard',
129
+ shapes: 'shape',
130
+ ferris: 'ferrisWheel',
131
+ ferriswheel: 'ferrisWheel',
132
+ flythrough: 'flyThrough',
133
+ flythru: 'flyThrough',
134
+ pagecurl: 'pageCurl',
135
+ pagecurldouble: 'pageCurlDouble',
136
+ pagecurlsingle: 'pageCurlSingle',
137
+ wheelreverse: 'wheelReverse',
138
+ slideleft: 'slideLeft',
139
+ slideright: 'slideRight',
140
+ slideup: 'slideUp',
141
+ slidedown: 'slideDown',
142
+ };
143
+
144
+ const NORMALIZED_TRANSITION_TYPES = new Map<string, TransitionType>(
145
+ TRANSITION_TYPES.map((type) => [normalizeToken(type), type]),
146
+ );
147
+
148
+ export function isTransitionType(value: string): value is TransitionType {
149
+ return TRANSITION_TYPE_SET.has(value);
150
+ }
151
+
152
+ export function normalizeTransitionType(value: string): TransitionType | null {
153
+ const trimmed = value.trim();
154
+ if (!trimmed) return null;
155
+ if (isTransitionType(trimmed)) return trimmed;
156
+ const normalized = normalizeToken(trimmed);
157
+ return TRANSITION_TYPE_ALIASES[normalized] ?? NORMALIZED_TRANSITION_TYPES.get(normalized) ?? null;
158
+ }
159
+
160
+ export function normalizeTransitionDirection(value: string): TransitionDirection | null {
161
+ const normalized = normalizeToken(value);
162
+ switch (normalized) {
163
+ case 'left':
164
+ case 'l':
165
+ return 'left';
166
+ case 'right':
167
+ case 'r':
168
+ return 'right';
169
+ case 'up':
170
+ case 'u':
171
+ return 'up';
172
+ case 'down':
173
+ case 'd':
174
+ return 'down';
175
+ case 'in':
176
+ return 'in';
177
+ case 'out':
178
+ return 'out';
179
+ case 'horizontal':
180
+ case 'horz':
181
+ case 'h':
182
+ return 'horizontal';
183
+ case 'vertical':
184
+ case 'vert':
185
+ case 'v':
186
+ return 'vertical';
187
+ default:
188
+ return null;
189
+ }
190
+ }
191
+
192
+ export function resolveTransitionDuration(
193
+ transition: Transition | undefined,
194
+ fallback: number = DEFAULT_TRANSITION_DURATION_SECONDS,
195
+ ): number {
196
+ const duration = transition?.duration;
197
+ return duration != null && Number.isFinite(duration) && duration > 0 ? duration : fallback;
198
+ }
199
+
200
+ /**
201
+ * Resolve the effective transition for a block: the authored value wins
202
+ * (markdown frontmatter and transform styles both write `block.transition`
203
+ * upstream), then the theme's `renderStyle.defaultTransition`, then
204
+ * `undefined` (the player's built-in default).
205
+ *
206
+ * Block 0 never gets a theme default — an entrance transition on the first
207
+ * block would dim the opening frames of every exported video.
208
+ *
209
+ * The theme parameter is structural (not `Theme`) to avoid a schema import
210
+ * cycle; pass any resolved theme.
211
+ */
212
+ export function resolveBlockTransition(
213
+ block: { transition?: Transition },
214
+ theme: { renderStyle: { defaultTransition?: Transition } },
215
+ blockIndex: number,
216
+ ): Transition | undefined {
217
+ if (block.transition) return block.transition;
218
+ if (blockIndex === 0) return undefined;
219
+ return theme.renderStyle.defaultTransition;
220
+ }
221
+
222
+ function normalizeToken(value: string): string {
223
+ return value.toLowerCase().replace(/[\s_-]+/g, '');
224
+ }
@@ -143,6 +143,39 @@ export function relativeLuminance(hex: string): number {
143
143
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
144
144
  }
145
145
 
146
+ /**
147
+ * Hue of a hex color in HSL degrees (0..360). Returns 0 for pure grays
148
+ * and on parse failure. Used by the duotone image treatment to aim its
149
+ * `hue-rotate()` at the theme's tint color.
150
+ */
151
+ export function hexHueDegrees(hex: string): number {
152
+ const rgb = parseHex(hex);
153
+ if (!rgb) return 0;
154
+ const [r, g, b] = rgb;
155
+ const max = Math.max(r, g, b);
156
+ const min = Math.min(r, g, b);
157
+ if (max === min) return 0;
158
+ const d = max - min;
159
+ let h: number;
160
+ if (max === r) h = ((g - b) / d) % 6;
161
+ else if (max === g) h = (b - r) / d + 2;
162
+ else h = (r - g) / d + 4;
163
+ return Math.round((h * 60 + 360) % 360);
164
+ }
165
+
166
+ /**
167
+ * Convert a hex color + alpha (0..1) to an `rgba()` string.
168
+ * Falls back to the input on parse failure (e.g. a value that is
169
+ * already an rgba()/gradient string).
170
+ */
171
+ export function withAlpha(hex: string, alpha: number): string {
172
+ const rgb = parseHex(hex);
173
+ if (!rgb) return hex;
174
+ const a = Math.max(0, Math.min(1, alpha));
175
+ const ch = (v: number) => Math.round(v * 255);
176
+ return `rgba(${ch(rgb[0])}, ${ch(rgb[1])}, ${ch(rgb[2])}, ${a})`;
177
+ }
178
+
146
179
  /**
147
180
  * WCAG contrast ratio between two hex colors. Returns 1..21.
148
181
  */
@@ -87,7 +87,10 @@ export const AVAILABLE_FONT_STACKS: FontStack[] = [
87
87
  {
88
88
  id: 'oswald',
89
89
  label: 'Oswald',
90
- family: '"Oswald", Impact, "Arial Black", sans-serif',
90
+ // Condensed grotesques make a far better stand-in for Oswald than
91
+ // Impact/Arial Black, whose extreme weight distorts every layout
92
+ // tuned for the real face.
93
+ family: '"Oswald", "Arial Narrow", "Helvetica Neue Condensed", "Roboto Condensed", sans-serif',
91
94
  kind: 'display',
92
95
  googleFontFamily: 'Oswald',
93
96
  },
@@ -185,7 +188,7 @@ const FALLBACK_BY_KIND: Record<FontFamilyKind, string> = {
185
188
  sans: 'system-ui, sans-serif',
186
189
  serif: 'Georgia, serif',
187
190
  mono: 'Consolas, monospace',
188
- display: 'Impact, Arial, sans-serif',
191
+ display: '"Arial Narrow", "Helvetica Neue", Arial, sans-serif',
189
192
  };
190
193
 
191
194
  /**
@@ -1,6 +1,9 @@
1
1
  export * from './Types.js';
2
2
  export * from './Doc.js';
3
+ export * from './Transitions.js';
4
+ export * from './Media.js';
3
5
  export * from './BlockTemplates.js';
6
+ export * from './CustomTemplates.js';
4
7
  export * from './Theme.js';
5
8
  export * from './themeLibrary.js';
6
9
  export * from './themeValidator.js';
@@ -140,17 +140,28 @@ export type ContrastPreset = 'subtle' | 'balanced' | 'high';
140
140
  export interface CompileOptions {
141
141
  /** Contrast level for OKLCh derivation (default 'balanced'). */
142
142
  contrast?: ContrastPreset;
143
+ /**
144
+ * Base theme to inherit from. When provided, the partial is deep-merged
145
+ * over this theme instead of the neutral `STARTER_THEME`, so the compiled
146
+ * theme inherits the base's render style, color schemes, typography, and
147
+ * persistent layers — and only the fields the user changed are overridden.
148
+ * The base's id is recorded on the result as `basedOn`.
149
+ */
150
+ base?: Theme;
143
151
  }
144
152
 
145
153
  /**
146
154
  * Compile a partial Theme into a complete one. Fills missing fields from
147
- * `STARTER_THEME`, derives missing color slots from `seedColors` (when
148
- * present), and validates the result.
155
+ * the base (`opts.base` when given, else `STARTER_THEME`), derives missing
156
+ * color slots from `seedColors` (when present), and validates the result.
149
157
  */
150
158
  export function compileTheme(partial: DeepPartial<Theme>, opts: CompileOptions = {}): Theme {
151
- // Step 1: deep-merge over the starter
152
- const merged = createTheme(STARTER_THEME, partial);
159
+ // Step 1: deep-merge over the base (a chosen theme, or the neutral starter)
160
+ const base = opts.base ?? STARTER_THEME;
161
+ const merged = createTheme(base, partial);
153
162
  merged.schemaVersion = THEME_SCHEMA_VERSION;
163
+ // Record which theme this was derived from so a customizer can re-inherit.
164
+ if (opts.base && !merged.basedOn) merged.basedOn = opts.base.id;
154
165
 
155
166
  // Step 1b: typography fonts are discriminated unions ({stackId} | {custom}),
156
167
  // so deep-merge would leave stale keys from the starter when the partial
@@ -168,6 +179,14 @@ export function compileTheme(partial: DeepPartial<Theme>, opts: CompileOptions =
168
179
  }
169
180
  }
170
181
 
182
+ // Step 1c: colorSchemes is a map the user edits wholesale (add / remove
183
+ // accents). Deep-merge can't delete keys, so replace it outright when the
184
+ // partial provides it — mirroring the font replacement above. This lets a
185
+ // removed accent actually disappear rather than lingering from the base.
186
+ if (partial.colorSchemes !== undefined) {
187
+ merged.colorSchemes = partial.colorSchemes as Record<string, ThemeColorScheme>;
188
+ }
189
+
171
190
  // Step 2: derive missing color slots from seeds, if present
172
191
  if (merged.seedColors) {
173
192
  const partialColors = (partial.colors ?? {}) as Partial<ThemeColorPalette>;
@@ -187,7 +206,7 @@ export function parseTheme(json: string): Theme {
187
206
  let parsed: unknown;
188
207
  try {
189
208
  parsed = JSON.parse(json);
190
- } catch (err) {
209
+ } catch (err: unknown) {
191
210
  const msg = err instanceof Error ? err.message : String(err);
192
211
  throw new Error(`Invalid theme JSON: ${msg}`);
193
212
  }
@@ -13,6 +13,7 @@
13
13
  import type { Theme, ThemeColorPalette } from './Theme.js';
14
14
  import { THEME_SCHEMA_VERSION } from './Theme.js';
15
15
  import { isHex } from './colorUtils.js';
16
+ import { isTransitionType } from './Transitions.js';
16
17
 
17
18
  export interface ValidationError {
18
19
  path: string;
@@ -28,16 +29,6 @@ export interface ValidationResult {
28
29
 
29
30
  const VALID_FALLBACK = new Set(['serif', 'sans-serif', 'monospace', 'system-ui']);
30
31
  const VALID_TITLE_WEIGHT = new Set(['normal', 'bold']);
31
- const VALID_TRANSITION_TYPES = new Set([
32
- 'cut',
33
- 'fade',
34
- 'dissolve',
35
- 'slideLeft',
36
- 'slideRight',
37
- 'slideUp',
38
- 'slideDown',
39
- 'zoom',
40
- ]);
41
32
  const VALID_GRADIENT_PRESETS = new Set([
42
33
  'dark-vignette',
43
34
  'radial-dark',
@@ -179,6 +170,24 @@ class V {
179
170
  if (v.blockPadding !== undefined && !this.isString(v.blockPadding)) {
180
171
  this.err(`${path}.blockPadding`, 'expected string (percentage e.g. "5%")');
181
172
  }
173
+ if (v.imageTreatment !== undefined) {
174
+ this.imageTreatment(`${path}.imageTreatment`, v.imageTreatment);
175
+ }
176
+ }
177
+
178
+ imageTreatment(path: string, v: unknown): void {
179
+ if (!this.isObject(v)) {
180
+ this.err(path, 'expected object');
181
+ return;
182
+ }
183
+ const kinds = ['none', 'mono', 'duotone', 'warm', 'cool'];
184
+ if (!this.isString(v.type) || !kinds.includes(v.type)) {
185
+ this.err(`${path}.type`, `expected one of ${kinds.join('|')}`);
186
+ }
187
+ if (v.strength !== undefined && !this.isNumber(v.strength)) {
188
+ this.err(`${path}.strength`, 'expected number (0..1)');
189
+ }
190
+ if (v.color !== undefined) this.hex(`${path}.color`, v.color);
182
191
  }
183
192
 
184
193
  renderStyle(path: string, v: unknown): void {
@@ -202,7 +211,7 @@ class V {
202
211
  if (!this.isObject(t)) {
203
212
  this.err(`${path}.defaultTransition`, 'expected object');
204
213
  } else {
205
- if (!this.isString(t.type) || !VALID_TRANSITION_TYPES.has(t.type)) {
214
+ if (!this.isString(t.type) || !isTransitionType(t.type)) {
206
215
  this.err(`${path}.defaultTransition.type`, 'expected valid transition type');
207
216
  }
208
217
  if (t.duration !== undefined && !this.isNumber(t.duration)) {
@@ -286,6 +295,32 @@ class V {
286
295
  'raw CSS gradient strings are not allowed in Theme JSON; use a `preset`',
287
296
  );
288
297
  }
298
+ if (v.template === 'patternBackground') {
299
+ const patterns = ['dots', 'grid', 'diagonal', 'noise'];
300
+ if (!this.isString(cfg.pattern) || !patterns.includes(cfg.pattern)) {
301
+ this.err(`${path}.config.pattern`, `expected one of ${patterns.join('|')}`);
302
+ }
303
+ for (const k of ['opacity', 'scale']) {
304
+ if (cfg[k] !== undefined && !this.isNumber(cfg[k])) {
305
+ this.err(`${path}.config.${k}`, 'expected number');
306
+ }
307
+ }
308
+ if (cfg.color !== undefined) this.hex(`${path}.config.color`, cfg.color);
309
+ }
310
+ if (v.template === 'vignette') {
311
+ if (cfg.strength !== undefined && !this.isNumber(cfg.strength)) {
312
+ this.err(`${path}.config.strength`, 'expected number (0..1)');
313
+ }
314
+ if (cfg.color !== undefined) this.hex(`${path}.config.color`, cfg.color);
315
+ }
316
+ if (v.template === 'ambientGradient') {
317
+ if (cfg.duration !== undefined && !this.isNumber(cfg.duration)) {
318
+ this.err(`${path}.config.duration`, 'expected number (seconds)');
319
+ }
320
+ for (const k of ['from', 'to']) {
321
+ if (cfg[k] !== undefined) this.hex(`${path}.config.${k}`, cfg[k]);
322
+ }
323
+ }
289
324
  }
290
325
  // Raw Layer form is permissive — no schema check here
291
326
  }
@@ -328,6 +363,9 @@ export function validateTheme(input: unknown): ValidationResult {
328
363
  if (input.description !== undefined && !v.isString(input.description)) {
329
364
  v.err('description', 'expected string');
330
365
  }
366
+ if (input.basedOn !== undefined && !v.isString(input.basedOn)) {
367
+ v.err('basedOn', 'expected string');
368
+ }
331
369
 
332
370
  if (input.seedColors !== undefined) v.seedColors('seedColors', input.seedColors);
333
371