@bendyline/squisq 2.0.1 → 2.1.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 (79) hide show
  1. package/dist/{Doc-ylqWKDc_.d.ts → Doc-DynJleo-.d.ts} +159 -2
  2. package/dist/{ImageEditDoc-D828IXJ1.d.ts → ImageEditDoc-Ca4Tr9uN.d.ts} +1 -1
  3. package/dist/{chunk-PXLVXTLW.js → chunk-ILWDXLUK.js} +24 -6
  4. package/dist/chunk-ILWDXLUK.js.map +1 -0
  5. package/dist/{chunk-W4O2TX5I.js → chunk-JHZWYRSW.js} +569 -1
  6. package/dist/chunk-JHZWYRSW.js.map +1 -0
  7. package/dist/{chunk-X6DUONGT.js → chunk-JI77MGAK.js} +123 -10
  8. package/dist/chunk-JI77MGAK.js.map +1 -0
  9. package/dist/{chunk-TFJVXUHH.js → chunk-OTKVFVL6.js} +1403 -41
  10. package/dist/chunk-OTKVFVL6.js.map +1 -0
  11. package/dist/{chunk-MMPBOUK7.js → chunk-QUUPYOSP.js} +8 -2
  12. package/dist/chunk-QUUPYOSP.js.map +1 -0
  13. package/dist/chunk-VWCJ2WXC.js +215 -0
  14. package/dist/chunk-VWCJ2WXC.js.map +1 -0
  15. package/dist/{chunk-2PWOO4ZE.js → chunk-VYFRI7R6.js} +2 -2
  16. package/dist/{chunk-CY2R2VPR.js → chunk-XNLB57UD.js} +2 -2
  17. package/dist/doc/index.d.ts +144 -5
  18. package/dist/doc/index.js +24 -4
  19. package/dist/generate/index.d.ts +1 -1
  20. package/dist/imageEdit/index.d.ts +3 -3
  21. package/dist/index.d.ts +6 -5
  22. package/dist/index.js +66 -8
  23. package/dist/jsonForm/index.d.ts +1 -1
  24. package/dist/jsonForm/index.js +4 -4
  25. package/dist/materializePageSection-DDzubQXo.d.ts +223 -0
  26. package/dist/narration/index.d.ts +1 -1
  27. package/dist/narration/index.js +3 -3
  28. package/dist/schemas/index.d.ts +30 -5
  29. package/dist/schemas/index.js +41 -3
  30. package/dist/{themeLibrary-RjVgLpUi.d.ts → themeLibrary-B72m0W1y.d.ts} +1 -1
  31. package/dist/transform/index.d.ts +8 -9
  32. package/dist/transform/index.js +1 -1
  33. package/package.json +1 -1
  34. package/src/__tests__/materializeBlockLayers.test.ts +32 -0
  35. package/src/__tests__/materializePageSections.test.ts +404 -0
  36. package/src/__tests__/pageStyle.test.ts +258 -0
  37. package/src/__tests__/templateAnnotationParse.test.ts +13 -0
  38. package/src/doc/index.ts +34 -0
  39. package/src/doc/materializeBlockLayers.ts +170 -9
  40. package/src/doc/page/PageSection.ts +174 -0
  41. package/src/doc/page/materializePageSection.ts +616 -0
  42. package/src/doc/page/resolvePageBlock.ts +108 -0
  43. package/src/doc/page/sectionExtractors.ts +466 -0
  44. package/src/doc/pageCss.ts +628 -0
  45. package/src/doc/templateInputs.ts +1 -0
  46. package/src/schemas/Doc.ts +22 -1
  47. package/src/schemas/PageStyle.ts +213 -0
  48. package/src/schemas/Theme.ts +7 -0
  49. package/src/schemas/index.ts +2 -0
  50. package/src/schemas/pageStyleDefaults.ts +204 -0
  51. package/src/schemas/themeCompile.ts +7 -0
  52. package/src/schemas/themeValidator.ts +183 -0
  53. package/src/schemas/themes/bold.json +29 -0
  54. package/src/schemas/themes/cinematic.json +32 -0
  55. package/src/schemas/themes/documentary.json +29 -0
  56. package/src/schemas/themes/gezellig.json +30 -0
  57. package/src/schemas/themes/magazine.json +37 -0
  58. package/src/schemas/themes/minimalist.json +29 -0
  59. package/src/schemas/themes/morning-light.json +32 -0
  60. package/src/schemas/themes/standard-dark.json +24 -0
  61. package/src/schemas/themes/standard.json +24 -0
  62. package/src/schemas/themes/tech-dark.json +42 -0
  63. package/src/schemas/themes/warm-earth.json +24 -0
  64. package/src/transform/registry.ts +19 -0
  65. package/src/transform/styles/dataDriven.ts +1 -0
  66. package/src/transform/styles/documentary.ts +1 -0
  67. package/src/transform/styles/magazine.ts +1 -0
  68. package/src/transform/styles/minimal.ts +1 -0
  69. package/src/transform/styles/narrative.ts +1 -0
  70. package/src/transform/types.ts +7 -0
  71. package/dist/chunk-FVJUUTEM.js +0 -74
  72. package/dist/chunk-FVJUUTEM.js.map +0 -1
  73. package/dist/chunk-MMPBOUK7.js.map +0 -1
  74. package/dist/chunk-PXLVXTLW.js.map +0 -1
  75. package/dist/chunk-TFJVXUHH.js.map +0 -1
  76. package/dist/chunk-W4O2TX5I.js.map +0 -1
  77. package/dist/chunk-X6DUONGT.js.map +0 -1
  78. /package/dist/{chunk-2PWOO4ZE.js.map → chunk-VYFRI7R6.js.map} +0 -0
  79. /package/dist/{chunk-CY2R2VPR.js.map → chunk-XNLB57UD.js.map} +0 -0
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Page Style — the theme's art-direction layer for Page (linear) mode.
3
+ *
4
+ * Slides render through fixed-viewport SVG templates; pages render through
5
+ * a small closed vocabulary of variable-height HTML section layouts
6
+ * (`PageSectionKind`). A `ThemePageStyle` is how a theme art-directs that
7
+ * page rendition: it picks a design family, sets page-wide tokens
8
+ * (column widths, spacing, dividers, hero treatment, image framing …),
9
+ * and can override how individual section kinds or block templates map
10
+ * onto the section vocabulary.
11
+ *
12
+ * Design principles (mirrors the rest of the Theme schema):
13
+ * - Fully JSON-serializable — rides the `squisq-custom-themes` frontmatter
14
+ * codec unchanged.
15
+ * - Closed enums everywhere so renderers can switch exhaustively and the
16
+ * validator can reject typos.
17
+ * - Optional on `Theme`; `resolvePageStyle`/`defaultPageStyle` derive a
18
+ * complete style from existing theme fields when absent, so legacy and
19
+ * customizer themes keep working without edits.
20
+ */
21
+
22
+ // ============================================
23
+ // Section vocabulary
24
+ // ============================================
25
+
26
+ /**
27
+ * The closed set of page section layouts. Every block template maps onto
28
+ * one of these kinds (see `doc/page/sectionExtractors.ts`); themes may
29
+ * remap via `ThemePageStyle.sections` / `.templates`.
30
+ */
31
+ export const PAGE_SECTION_KINDS = [
32
+ 'hero',
33
+ 'banner',
34
+ 'stat-band',
35
+ 'quote-band',
36
+ 'feature-split',
37
+ 'media-figure',
38
+ 'gallery',
39
+ 'callout',
40
+ 'card-grid',
41
+ 'item-list',
42
+ 'timeline-rail',
43
+ 'table-section',
44
+ 'canvas-embed',
45
+ 'prose',
46
+ 'footer',
47
+ ] as const;
48
+
49
+ export type PageSectionKind = (typeof PAGE_SECTION_KINDS)[number];
50
+
51
+ /** Relative visual weight of a section within the page. */
52
+ export type PageEmphasis = 'lead' | 'strong' | 'standard' | 'quiet';
53
+
54
+ /** Which background treatment a section sits on. */
55
+ export type PageBackground = 'base' | 'alternate' | 'accent' | 'media';
56
+
57
+ // ============================================
58
+ // Design families + tokens
59
+ // ============================================
60
+
61
+ /**
62
+ * Broad page design personalities. A family selects the structural
63
+ * skeleton (how sections compose); tokens parameterize it per theme.
64
+ */
65
+ export const PAGE_DESIGN_FAMILIES = [
66
+ 'clean',
67
+ 'editorial',
68
+ 'brutalist',
69
+ 'terminal',
70
+ 'cinematic',
71
+ 'documentary',
72
+ 'organic',
73
+ 'soft',
74
+ ] as const;
75
+
76
+ export type PageDesignFamily = (typeof PAGE_DESIGN_FAMILIES)[number];
77
+
78
+ /** How headings announce themselves across the page. */
79
+ export interface PageHeadingTreatment {
80
+ /** Small label above headings: none, plain kicker, "01 —" numbering, or a mono tag. */
81
+ eyebrow: 'none' | 'kicker' | 'numbered' | 'mono-tag';
82
+ /** Overall heading size register. */
83
+ scale: 'regular' | 'display' | 'oversized';
84
+ /** Optional case transform for headings/eyebrows. */
85
+ case?: 'none' | 'uppercase';
86
+ /** Optional decoration under headings. */
87
+ underline?: 'none' | 'accent-bar' | 'full-rule';
88
+ }
89
+
90
+ /**
91
+ * Page-wide design tokens. Structural CSS reads these via
92
+ * `--squisq-page-*` custom properties (see `doc/pageCss.ts`).
93
+ */
94
+ export interface PageTokens {
95
+ /** Reading-column max width in px (prose, callouts, quotes). */
96
+ contentMaxWidth: number;
97
+ /** Wide-band inner max width in px (figures, tables, feature splits). */
98
+ wideMaxWidth: number;
99
+ /** Vertical rhythm between sections. */
100
+ sectionSpacing: 'compact' | 'comfortable' | 'generous';
101
+ /** Corner radius for cards, media frames, panels (px). */
102
+ cornerRadius: number;
103
+ /** Divider treatment between sections. */
104
+ divider: 'none' | 'gap-only' | 'hairline' | 'thick-rule' | 'double-rule' | 'dotted';
105
+ /** How section backgrounds vary down the page. */
106
+ backgroundRhythm: 'flat' | 'alternate' | 'accent-bands' | 'tinted-panels';
107
+ /** Hero (cover/title) composition. */
108
+ heroStyle: 'stacked' | 'split' | 'full-bleed' | 'letterbox' | 'oversized-type';
109
+ /** Heading voice across the page. */
110
+ headingTreatment: PageHeadingTreatment;
111
+ /** Frame treatment for photographic media. */
112
+ imageFraming: 'flush' | 'rounded' | 'bordered' | 'polaroid' | 'letterboxed' | 'circle-accent';
113
+ /** Shadow language for elevated elements. */
114
+ shadow: 'none' | 'soft' | 'crisp' | 'heavy';
115
+ /** Decoration on quote sections. */
116
+ quoteMark: 'none' | 'oversized-glyph' | 'accent-bar';
117
+ /** Treatment of large stat numerals. */
118
+ numeralStyle: 'plain' | 'oversized' | 'boxed' | 'mono';
119
+ /** Optional page background pattern. */
120
+ pattern?: 'none' | 'dots' | 'grid' | 'diagonal' | 'noise';
121
+ }
122
+
123
+ // ============================================
124
+ // Overrides + accent rotation
125
+ // ============================================
126
+
127
+ /**
128
+ * A theme's override for one section kind or one block template —
129
+ * remap the kind, force a variant, adjust emphasis/background, or pass
130
+ * freeform scalar hints the section renderer understands.
131
+ */
132
+ export interface PageSectionOverride {
133
+ kind?: PageSectionKind;
134
+ variant?: string;
135
+ emphasis?: PageEmphasis;
136
+ background?: PageBackground;
137
+ /** Freeform scalar hints (e.g. `dropCap: true`, `frame: "terminal"`). */
138
+ hints?: Record<string, string | number | boolean>;
139
+ }
140
+
141
+ /** How named color schemes rotate across accent-bearing sections. */
142
+ export interface PageAccentRotation {
143
+ strategy: 'primary-only' | 'cycle' | 'alternate-two' | 'none';
144
+ /**
145
+ * ColorScheme names (keys of `theme.colorSchemes`) to rotate through.
146
+ * Defaults to all schemes in insertion order.
147
+ */
148
+ schemes?: string[];
149
+ }
150
+
151
+ // ============================================
152
+ // ThemePageStyle
153
+ // ============================================
154
+
155
+ /**
156
+ * The complete page art direction for a theme. Optional on `Theme`;
157
+ * `defaultPageStyle(theme)` derives one from existing theme fields.
158
+ */
159
+ export interface ThemePageStyle {
160
+ family: PageDesignFamily;
161
+ tokens: PageTokens;
162
+ /** Per-section-kind overrides. */
163
+ sections?: Partial<Record<PageSectionKind, PageSectionOverride>>;
164
+ /** Per-template overrides (canonical template ids); win over `sections`. */
165
+ templates?: Record<string, PageSectionOverride>;
166
+ accentRotation: PageAccentRotation;
167
+ }
168
+
169
+ // ============================================
170
+ // Enum sets (shared by the validator + defaults)
171
+ // ============================================
172
+
173
+ export const PAGE_SECTION_SPACINGS = ['compact', 'comfortable', 'generous'] as const;
174
+ export const PAGE_DIVIDERS = [
175
+ 'none',
176
+ 'gap-only',
177
+ 'hairline',
178
+ 'thick-rule',
179
+ 'double-rule',
180
+ 'dotted',
181
+ ] as const;
182
+ export const PAGE_BACKGROUND_RHYTHMS = [
183
+ 'flat',
184
+ 'alternate',
185
+ 'accent-bands',
186
+ 'tinted-panels',
187
+ ] as const;
188
+ export const PAGE_HERO_STYLES = [
189
+ 'stacked',
190
+ 'split',
191
+ 'full-bleed',
192
+ 'letterbox',
193
+ 'oversized-type',
194
+ ] as const;
195
+ export const PAGE_EYEBROWS = ['none', 'kicker', 'numbered', 'mono-tag'] as const;
196
+ export const PAGE_HEADING_SCALES = ['regular', 'display', 'oversized'] as const;
197
+ export const PAGE_HEADING_CASES = ['none', 'uppercase'] as const;
198
+ export const PAGE_HEADING_UNDERLINES = ['none', 'accent-bar', 'full-rule'] as const;
199
+ export const PAGE_IMAGE_FRAMINGS = [
200
+ 'flush',
201
+ 'rounded',
202
+ 'bordered',
203
+ 'polaroid',
204
+ 'letterboxed',
205
+ 'circle-accent',
206
+ ] as const;
207
+ export const PAGE_SHADOWS = ['none', 'soft', 'crisp', 'heavy'] as const;
208
+ export const PAGE_QUOTE_MARKS = ['none', 'oversized-glyph', 'accent-bar'] as const;
209
+ export const PAGE_NUMERAL_STYLES = ['plain', 'oversized', 'boxed', 'mono'] as const;
210
+ export const PAGE_PATTERNS = ['none', 'dots', 'grid', 'diagonal', 'noise'] as const;
211
+ export const PAGE_EMPHASES = ['lead', 'strong', 'standard', 'quiet'] as const;
212
+ export const PAGE_BACKGROUNDS = ['base', 'alternate', 'accent', 'media'] as const;
213
+ export const PAGE_ACCENT_STRATEGIES = ['primary-only', 'cycle', 'alternate-two', 'none'] as const;
@@ -23,6 +23,7 @@
23
23
  import type { LayoutHints } from './LayoutStrategy.js';
24
24
  import type { AnimationType, ImageTreatment, TransitionType } from './Doc.js';
25
25
  import type { PersistentLayerConfig } from './BlockTemplates.js';
26
+ import type { ThemePageStyle } from './PageStyle.js';
26
27
  import { THEME_SCHEMA_VERSION } from './themeConstants.js';
27
28
  import { validateTheme } from './themeValidator.js';
28
29
  import { cloneAndFreezeData } from '../internal/immutable.js';
@@ -236,6 +237,12 @@ export interface Theme {
236
237
  colorSchemes: Record<string, ThemeColorScheme>;
237
238
  /** Optional persistent layers baked into the theme */
238
239
  persistentLayers?: PersistentLayerConfig;
240
+ /**
241
+ * Optional page art direction for Page (linear) mode. When absent,
242
+ * `resolvePageStyle`/`defaultPageStyle` derive one from the fields above,
243
+ * so legacy themes render pages without declaring this.
244
+ */
245
+ pageStyle?: ThemePageStyle;
239
246
  }
240
247
 
241
248
  // ============================================
@@ -5,6 +5,8 @@ export * from './Media.js';
5
5
  export * from './BlockTemplates.js';
6
6
  export * from './CustomTemplates.js';
7
7
  export * from './Theme.js';
8
+ export * from './PageStyle.js';
9
+ export * from './pageStyleDefaults.js';
8
10
  export * from './themeLibrary.js';
9
11
  export * from './themeValidator.js';
10
12
  export * from './themeCompile.js';
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Page Style Defaults
3
+ *
4
+ * Derives a complete `ThemePageStyle` from a theme's existing fields so
5
+ * legacy themes, customizer themes, and file-inferred themes get a
6
+ * coherent page art direction without declaring one. Built-in themes
7
+ * ship explicit `pageStyle` blocks in their JSON; this derivation is the
8
+ * fallback used by `compileTheme` and by `resolvePageStyle` at read time.
9
+ *
10
+ * Derivation signals:
11
+ * - `renderStyle.name` → design family (documentary → documentary,
12
+ * magazine → editorial, bold → brutalist, tech-dark → terminal, …)
13
+ * - `style.borderRadius` → cornerRadius + rounded vs flush image framing
14
+ * - `style.imageTreatment` (mono/duotone) → letterboxed framing
15
+ * - `style.textShadow` → shadow language
16
+ * - `persistentLayers` patternBackground → page pattern
17
+ */
18
+
19
+ import type { Theme } from './Theme.js';
20
+ import type {
21
+ PageDesignFamily,
22
+ PageTokens,
23
+ ThemePageStyle,
24
+ PageHeadingTreatment,
25
+ } from './PageStyle.js';
26
+
27
+ /** renderStyle.name → design family. Unknown names fall back to 'clean'. */
28
+ const FAMILY_BY_RENDER_STYLE: Record<string, PageDesignFamily> = {
29
+ standard: 'clean',
30
+ minimalist: 'clean',
31
+ documentary: 'documentary',
32
+ magazine: 'editorial',
33
+ bold: 'brutalist',
34
+ 'tech-dark': 'terminal',
35
+ cinematic: 'cinematic',
36
+ 'warm-earth': 'organic',
37
+ 'morning-light': 'soft',
38
+ gezellig: 'soft',
39
+ };
40
+
41
+ interface FamilyDefaults {
42
+ sectionSpacing: PageTokens['sectionSpacing'];
43
+ divider: PageTokens['divider'];
44
+ backgroundRhythm: PageTokens['backgroundRhythm'];
45
+ heroStyle: PageTokens['heroStyle'];
46
+ headingTreatment: PageHeadingTreatment;
47
+ quoteMark: PageTokens['quoteMark'];
48
+ numeralStyle: PageTokens['numeralStyle'];
49
+ accentStrategy: ThemePageStyle['accentRotation']['strategy'];
50
+ }
51
+
52
+ const FAMILY_DEFAULTS: Record<PageDesignFamily, FamilyDefaults> = {
53
+ clean: {
54
+ sectionSpacing: 'comfortable',
55
+ divider: 'hairline',
56
+ backgroundRhythm: 'alternate',
57
+ heroStyle: 'stacked',
58
+ headingTreatment: { eyebrow: 'kicker', scale: 'regular', underline: 'none' },
59
+ quoteMark: 'accent-bar',
60
+ numeralStyle: 'plain',
61
+ accentStrategy: 'cycle',
62
+ },
63
+ editorial: {
64
+ sectionSpacing: 'comfortable',
65
+ divider: 'double-rule',
66
+ backgroundRhythm: 'flat',
67
+ heroStyle: 'split',
68
+ headingTreatment: { eyebrow: 'kicker', scale: 'display', underline: 'none' },
69
+ quoteMark: 'oversized-glyph',
70
+ numeralStyle: 'oversized',
71
+ accentStrategy: 'alternate-two',
72
+ },
73
+ brutalist: {
74
+ sectionSpacing: 'compact',
75
+ divider: 'thick-rule',
76
+ backgroundRhythm: 'accent-bands',
77
+ heroStyle: 'oversized-type',
78
+ headingTreatment: { eyebrow: 'kicker', scale: 'oversized', case: 'uppercase' },
79
+ quoteMark: 'none',
80
+ numeralStyle: 'oversized',
81
+ accentStrategy: 'cycle',
82
+ },
83
+ terminal: {
84
+ sectionSpacing: 'compact',
85
+ divider: 'hairline',
86
+ backgroundRhythm: 'tinted-panels',
87
+ heroStyle: 'stacked',
88
+ headingTreatment: { eyebrow: 'mono-tag', scale: 'regular', underline: 'none' },
89
+ quoteMark: 'none',
90
+ numeralStyle: 'mono',
91
+ accentStrategy: 'primary-only',
92
+ },
93
+ cinematic: {
94
+ sectionSpacing: 'generous',
95
+ divider: 'gap-only',
96
+ backgroundRhythm: 'flat',
97
+ heroStyle: 'full-bleed',
98
+ headingTreatment: { eyebrow: 'kicker', scale: 'display', case: 'uppercase' },
99
+ quoteMark: 'none',
100
+ numeralStyle: 'oversized',
101
+ accentStrategy: 'primary-only',
102
+ },
103
+ documentary: {
104
+ sectionSpacing: 'generous',
105
+ divider: 'thick-rule',
106
+ backgroundRhythm: 'flat',
107
+ heroStyle: 'letterbox',
108
+ headingTreatment: { eyebrow: 'numbered', scale: 'display', underline: 'none' },
109
+ quoteMark: 'accent-bar',
110
+ numeralStyle: 'boxed',
111
+ accentStrategy: 'primary-only',
112
+ },
113
+ organic: {
114
+ sectionSpacing: 'comfortable',
115
+ divider: 'thick-rule',
116
+ backgroundRhythm: 'tinted-panels',
117
+ heroStyle: 'stacked',
118
+ headingTreatment: { eyebrow: 'kicker', scale: 'regular', underline: 'accent-bar' },
119
+ quoteMark: 'oversized-glyph',
120
+ numeralStyle: 'boxed',
121
+ accentStrategy: 'alternate-two',
122
+ },
123
+ soft: {
124
+ sectionSpacing: 'comfortable',
125
+ divider: 'dotted',
126
+ backgroundRhythm: 'tinted-panels',
127
+ heroStyle: 'split',
128
+ headingTreatment: { eyebrow: 'kicker', scale: 'regular', underline: 'none' },
129
+ quoteMark: 'oversized-glyph',
130
+ numeralStyle: 'plain',
131
+ accentStrategy: 'alternate-two',
132
+ },
133
+ };
134
+
135
+ /** Read the first patternBackground pattern from a theme's persistent layers. */
136
+ function derivePattern(theme: Theme): PageTokens['pattern'] {
137
+ const layers = [
138
+ ...(theme.persistentLayers?.bottomLayers ?? []),
139
+ ...(theme.persistentLayers?.topLayers ?? []),
140
+ ];
141
+ for (const layer of layers) {
142
+ if (
143
+ typeof layer === 'object' &&
144
+ layer !== null &&
145
+ 'template' in layer &&
146
+ (layer as { template?: unknown }).template === 'patternBackground'
147
+ ) {
148
+ const config = (layer as { config?: { pattern?: unknown } }).config;
149
+ const pattern = config?.pattern;
150
+ if (
151
+ pattern === 'dots' ||
152
+ pattern === 'grid' ||
153
+ pattern === 'diagonal' ||
154
+ pattern === 'noise'
155
+ ) {
156
+ return pattern;
157
+ }
158
+ }
159
+ }
160
+ return undefined;
161
+ }
162
+
163
+ /**
164
+ * Derive a complete `ThemePageStyle` from a theme's existing fields.
165
+ * Deterministic and read-only; explicit `theme.pageStyle` always wins
166
+ * (callers check before deriving — see `resolvePageStyle`).
167
+ */
168
+ export function defaultPageStyle(theme: Theme): ThemePageStyle {
169
+ const family = FAMILY_BY_RENDER_STYLE[theme.renderStyle?.name ?? ''] ?? 'clean';
170
+ const defaults = FAMILY_DEFAULTS[family];
171
+
172
+ const cornerRadius = theme.style?.borderRadius ?? 6;
173
+ const treatmentType = theme.style?.imageTreatment?.type;
174
+ const imageFraming: PageTokens['imageFraming'] =
175
+ treatmentType === 'mono' || treatmentType === 'duotone'
176
+ ? 'letterboxed'
177
+ : cornerRadius > 0
178
+ ? 'rounded'
179
+ : 'flush';
180
+
181
+ const tokens: PageTokens = {
182
+ contentMaxWidth: 760,
183
+ wideMaxWidth: 1100,
184
+ sectionSpacing: defaults.sectionSpacing,
185
+ cornerRadius,
186
+ divider: defaults.divider,
187
+ backgroundRhythm: defaults.backgroundRhythm,
188
+ heroStyle: defaults.heroStyle,
189
+ headingTreatment: { ...defaults.headingTreatment },
190
+ imageFraming,
191
+ shadow: theme.style?.textShadow ? 'soft' : 'none',
192
+ quoteMark: defaults.quoteMark,
193
+ numeralStyle: defaults.numeralStyle,
194
+ };
195
+
196
+ const pattern = derivePattern(theme);
197
+ if (pattern) tokens.pattern = pattern;
198
+
199
+ return {
200
+ family,
201
+ tokens,
202
+ accentRotation: { strategy: defaults.accentStrategy },
203
+ };
204
+ }
@@ -22,6 +22,7 @@ import type {
22
22
  } from './Theme.js';
23
23
  import { THEME_SCHEMA_VERSION, createTheme } from './Theme.js';
24
24
  import { assertTheme } from './themeValidator.js';
25
+ import { defaultPageStyle } from './pageStyleDefaults.js';
25
26
  import {
26
27
  oklchLighten,
27
28
  oklchDarken,
@@ -210,6 +211,12 @@ export function compileTheme(partial: DeepPartial<Theme>, opts: CompileOptions =
210
211
  });
211
212
  }
212
213
 
214
+ // Step 2b: fill page art direction from existing fields when absent, so
215
+ // every compiled theme carries an explicit, serializable pageStyle.
216
+ if (!merged.pageStyle) {
217
+ merged.pageStyle = defaultPageStyle(merged);
218
+ }
219
+
213
220
  // Step 3: validate
214
221
  return assertTheme(merged, `compiled theme "${merged.id}"`);
215
222
  }
@@ -14,6 +14,26 @@ import type { Theme, ThemeColorPalette } from './Theme.js';
14
14
  import { THEME_SCHEMA_VERSION } from './themeConstants.js';
15
15
  import { isHex } from './colorUtils.js';
16
16
  import { isTransitionType } from './Transitions.js';
17
+ import {
18
+ PAGE_SECTION_KINDS,
19
+ PAGE_DESIGN_FAMILIES,
20
+ PAGE_SECTION_SPACINGS,
21
+ PAGE_DIVIDERS,
22
+ PAGE_BACKGROUND_RHYTHMS,
23
+ PAGE_HERO_STYLES,
24
+ PAGE_EYEBROWS,
25
+ PAGE_HEADING_SCALES,
26
+ PAGE_HEADING_CASES,
27
+ PAGE_HEADING_UNDERLINES,
28
+ PAGE_IMAGE_FRAMINGS,
29
+ PAGE_SHADOWS,
30
+ PAGE_QUOTE_MARKS,
31
+ PAGE_NUMERAL_STYLES,
32
+ PAGE_PATTERNS,
33
+ PAGE_EMPHASES,
34
+ PAGE_BACKGROUNDS,
35
+ PAGE_ACCENT_STRATEGIES,
36
+ } from './PageStyle.js';
17
37
 
18
38
  export interface ValidationError {
19
39
  path: string;
@@ -28,6 +48,28 @@ export interface ValidationResult {
28
48
  }
29
49
 
30
50
  const VALID_FALLBACK = new Set(['serif', 'sans-serif', 'monospace', 'system-ui']);
51
+ const VALID_SECTION_KINDS = new Set<string>(PAGE_SECTION_KINDS);
52
+ const VALID_DESIGN_FAMILIES = new Set<string>(PAGE_DESIGN_FAMILIES);
53
+ const VALID_PAGE_EMPHASES = new Set<string>(PAGE_EMPHASES);
54
+ const VALID_PAGE_BACKGROUNDS = new Set<string>(PAGE_BACKGROUNDS);
55
+ const VALID_ACCENT_STRATEGIES = new Set<string>(PAGE_ACCENT_STRATEGIES);
56
+ /** PageTokens enum fields → their allowed values. */
57
+ const PAGE_TOKEN_ENUMS: Record<string, Set<string>> = {
58
+ sectionSpacing: new Set<string>(PAGE_SECTION_SPACINGS),
59
+ divider: new Set<string>(PAGE_DIVIDERS),
60
+ backgroundRhythm: new Set<string>(PAGE_BACKGROUND_RHYTHMS),
61
+ heroStyle: new Set<string>(PAGE_HERO_STYLES),
62
+ imageFraming: new Set<string>(PAGE_IMAGE_FRAMINGS),
63
+ shadow: new Set<string>(PAGE_SHADOWS),
64
+ quoteMark: new Set<string>(PAGE_QUOTE_MARKS),
65
+ numeralStyle: new Set<string>(PAGE_NUMERAL_STYLES),
66
+ };
67
+ const HEADING_TREATMENT_ENUMS: Record<string, { values: Set<string>; required: boolean }> = {
68
+ eyebrow: { values: new Set<string>(PAGE_EYEBROWS), required: true },
69
+ scale: { values: new Set<string>(PAGE_HEADING_SCALES), required: true },
70
+ case: { values: new Set<string>(PAGE_HEADING_CASES), required: false },
71
+ underline: { values: new Set<string>(PAGE_HEADING_UNDERLINES), required: false },
72
+ };
31
73
  const VALID_TITLE_WEIGHT = new Set(['normal', 'bold']);
32
74
  const VALID_GRADIENT_PRESETS = new Set([
33
75
  'dark-vignette',
@@ -340,6 +382,143 @@ class V {
340
382
  arr.forEach((entry, i) => this.persistentLayer(`${path}.${side}[${i}]`, entry));
341
383
  }
342
384
  }
385
+
386
+ scalarHints(path: string, v: unknown): void {
387
+ if (!this.isObject(v)) {
388
+ this.err(path, 'expected object');
389
+ return;
390
+ }
391
+ for (const key of Object.keys(v)) {
392
+ const t = typeof v[key];
393
+ if (t !== 'string' && t !== 'number' && t !== 'boolean') {
394
+ this.err(`${path}.${key}`, `expected string|number|boolean, got ${t}`);
395
+ }
396
+ }
397
+ }
398
+
399
+ pageSectionOverride(path: string, v: unknown): void {
400
+ if (!this.isObject(v)) {
401
+ this.err(path, 'expected object');
402
+ return;
403
+ }
404
+ if (v.kind !== undefined && (!this.isString(v.kind) || !VALID_SECTION_KINDS.has(v.kind))) {
405
+ this.err(`${path}.kind`, `expected one of: ${PAGE_SECTION_KINDS.join(', ')}`);
406
+ }
407
+ if (v.variant !== undefined && !this.isString(v.variant)) {
408
+ this.err(`${path}.variant`, 'expected string');
409
+ }
410
+ if (
411
+ v.emphasis !== undefined &&
412
+ (!this.isString(v.emphasis) || !VALID_PAGE_EMPHASES.has(v.emphasis))
413
+ ) {
414
+ this.err(`${path}.emphasis`, `expected one of: ${PAGE_EMPHASES.join(', ')}`);
415
+ }
416
+ if (
417
+ v.background !== undefined &&
418
+ (!this.isString(v.background) || !VALID_PAGE_BACKGROUNDS.has(v.background))
419
+ ) {
420
+ this.err(`${path}.background`, `expected one of: ${PAGE_BACKGROUNDS.join(', ')}`);
421
+ }
422
+ if (v.hints !== undefined) this.scalarHints(`${path}.hints`, v.hints);
423
+ }
424
+
425
+ pageTokens(path: string, v: unknown): void {
426
+ if (!this.isObject(v)) {
427
+ this.err(path, 'expected object');
428
+ return;
429
+ }
430
+ for (const k of ['contentMaxWidth', 'wideMaxWidth', 'cornerRadius']) {
431
+ if (!this.isNumber(v[k])) {
432
+ this.err(`${path}.${k}`, 'expected number');
433
+ }
434
+ }
435
+ for (const [k, values] of Object.entries(PAGE_TOKEN_ENUMS)) {
436
+ if (!this.isString(v[k]) || !values.has(v[k] as string)) {
437
+ this.err(`${path}.${k}`, `expected one of: ${Array.from(values).join(', ')}`);
438
+ }
439
+ }
440
+ if (v.pattern !== undefined) {
441
+ if (!this.isString(v.pattern) || !(PAGE_PATTERNS as readonly string[]).includes(v.pattern)) {
442
+ this.err(`${path}.pattern`, `expected one of: ${PAGE_PATTERNS.join(', ')}`);
443
+ }
444
+ }
445
+ const ht = v.headingTreatment;
446
+ if (!this.isObject(ht)) {
447
+ this.err(`${path}.headingTreatment`, 'expected object');
448
+ return;
449
+ }
450
+ for (const [k, spec] of Object.entries(HEADING_TREATMENT_ENUMS)) {
451
+ const val = ht[k];
452
+ if (val === undefined) {
453
+ if (spec.required) this.err(`${path}.headingTreatment.${k}`, 'required');
454
+ continue;
455
+ }
456
+ if (!this.isString(val) || !spec.values.has(val)) {
457
+ this.err(
458
+ `${path}.headingTreatment.${k}`,
459
+ `expected one of: ${Array.from(spec.values).join(', ')}`,
460
+ );
461
+ }
462
+ }
463
+ }
464
+
465
+ pageStyle(path: string, v: unknown): void {
466
+ if (!this.isObject(v)) {
467
+ this.err(path, 'expected object');
468
+ return;
469
+ }
470
+ if (!this.isString(v.family) || !VALID_DESIGN_FAMILIES.has(v.family)) {
471
+ this.err(`${path}.family`, `expected one of: ${PAGE_DESIGN_FAMILIES.join(', ')}`);
472
+ }
473
+ if (v.tokens === undefined) {
474
+ this.err(`${path}.tokens`, 'required');
475
+ } else {
476
+ this.pageTokens(`${path}.tokens`, v.tokens);
477
+ }
478
+ if (v.sections !== undefined) {
479
+ if (!this.isObject(v.sections)) {
480
+ this.err(`${path}.sections`, 'expected object map');
481
+ } else {
482
+ for (const key of Object.keys(v.sections)) {
483
+ if (!VALID_SECTION_KINDS.has(key)) {
484
+ this.err(`${path}.sections.${key}`, `unknown section kind`);
485
+ continue;
486
+ }
487
+ this.pageSectionOverride(`${path}.sections.${key}`, v.sections[key]);
488
+ }
489
+ }
490
+ }
491
+ if (v.templates !== undefined) {
492
+ if (!this.isObject(v.templates)) {
493
+ this.err(`${path}.templates`, 'expected object map');
494
+ } else {
495
+ for (const key of Object.keys(v.templates)) {
496
+ this.pageSectionOverride(`${path}.templates.${key}`, v.templates[key]);
497
+ }
498
+ }
499
+ }
500
+ if (v.accentRotation === undefined) {
501
+ this.err(`${path}.accentRotation`, 'required');
502
+ } else if (!this.isObject(v.accentRotation)) {
503
+ this.err(`${path}.accentRotation`, 'expected object');
504
+ } else {
505
+ const rotation = v.accentRotation;
506
+ if (!this.isString(rotation.strategy) || !VALID_ACCENT_STRATEGIES.has(rotation.strategy)) {
507
+ this.err(
508
+ `${path}.accentRotation.strategy`,
509
+ `expected one of: ${PAGE_ACCENT_STRATEGIES.join(', ')}`,
510
+ );
511
+ }
512
+ if (rotation.schemes !== undefined) {
513
+ if (
514
+ !Array.isArray(rotation.schemes) ||
515
+ rotation.schemes.some((s) => typeof s !== 'string')
516
+ ) {
517
+ this.err(`${path}.accentRotation.schemes`, 'expected array of strings');
518
+ }
519
+ }
520
+ }
521
+ }
343
522
  }
344
523
 
345
524
  export function validateTheme(input: unknown): ValidationResult {
@@ -403,6 +582,10 @@ export function validateTheme(input: unknown): ValidationResult {
403
582
  v.persistentLayers('persistentLayers', input.persistentLayers);
404
583
  }
405
584
 
585
+ if (input.pageStyle !== undefined) {
586
+ v.pageStyle('pageStyle', input.pageStyle);
587
+ }
588
+
406
589
  if (v.errors.length === 0) {
407
590
  return { valid: true, errors: [], theme: input as unknown as Theme };
408
591
  }