@bendyline/squisq 2.0.0 → 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 (85) 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-6PJRB2CF.js → chunk-JHZWYRSW.js} +570 -2
  6. package/dist/chunk-JHZWYRSW.js.map +1 -0
  7. package/dist/{chunk-GF3BVNLU.js → chunk-JI77MGAK.js} +195 -27
  8. package/dist/chunk-JI77MGAK.js.map +1 -0
  9. package/dist/{chunk-QVANWDPH.js → chunk-OTKVFVL6.js} +1403 -41
  10. package/dist/chunk-OTKVFVL6.js.map +1 -0
  11. package/dist/{chunk-LYNJEOAC.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-QTSJOY54.js → chunk-VYFRI7R6.js} +2 -2
  16. package/dist/{chunk-T3TI7GSZ.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__/customThemesFrontmatter.test.ts +1 -0
  35. package/src/__tests__/markdown.test.ts +13 -4
  36. package/src/__tests__/markdownSanitize.test.ts +7 -5
  37. package/src/__tests__/materializeBlockLayers.test.ts +32 -0
  38. package/src/__tests__/materializePageSections.test.ts +404 -0
  39. package/src/__tests__/pageStyle.test.ts +258 -0
  40. package/src/__tests__/templateAnnotationParse.test.ts +13 -0
  41. package/src/doc/index.ts +34 -0
  42. package/src/doc/markdownToDoc.ts +3 -1
  43. package/src/doc/materializeBlockLayers.ts +170 -9
  44. package/src/doc/page/PageSection.ts +174 -0
  45. package/src/doc/page/materializePageSection.ts +616 -0
  46. package/src/doc/page/resolvePageBlock.ts +108 -0
  47. package/src/doc/page/sectionExtractors.ts +466 -0
  48. package/src/doc/pageCss.ts +628 -0
  49. package/src/doc/templateInputs.ts +1 -0
  50. package/src/doc/templates/__tests__/listBlock.test.ts +64 -0
  51. package/src/doc/templates/listBlock.ts +88 -30
  52. package/src/schemas/Doc.ts +22 -1
  53. package/src/schemas/PageStyle.ts +213 -0
  54. package/src/schemas/Theme.ts +7 -0
  55. package/src/schemas/index.ts +2 -0
  56. package/src/schemas/pageStyleDefaults.ts +204 -0
  57. package/src/schemas/themeCompile.ts +7 -0
  58. package/src/schemas/themeValidator.ts +183 -0
  59. package/src/schemas/themes/bold.json +29 -0
  60. package/src/schemas/themes/cinematic.json +32 -0
  61. package/src/schemas/themes/documentary.json +29 -0
  62. package/src/schemas/themes/gezellig.json +31 -1
  63. package/src/schemas/themes/magazine.json +37 -0
  64. package/src/schemas/themes/minimalist.json +29 -0
  65. package/src/schemas/themes/morning-light.json +32 -0
  66. package/src/schemas/themes/standard-dark.json +24 -0
  67. package/src/schemas/themes/standard.json +24 -0
  68. package/src/schemas/themes/tech-dark.json +42 -0
  69. package/src/schemas/themes/warm-earth.json +24 -0
  70. package/src/transform/registry.ts +19 -0
  71. package/src/transform/styles/dataDriven.ts +1 -0
  72. package/src/transform/styles/documentary.ts +1 -0
  73. package/src/transform/styles/magazine.ts +1 -0
  74. package/src/transform/styles/minimal.ts +1 -0
  75. package/src/transform/styles/narrative.ts +1 -0
  76. package/src/transform/types.ts +7 -0
  77. package/dist/chunk-6PJRB2CF.js.map +0 -1
  78. package/dist/chunk-FVJUUTEM.js +0 -74
  79. package/dist/chunk-FVJUUTEM.js.map +0 -1
  80. package/dist/chunk-GF3BVNLU.js.map +0 -1
  81. package/dist/chunk-LYNJEOAC.js.map +0 -1
  82. package/dist/chunk-PXLVXTLW.js.map +0 -1
  83. package/dist/chunk-QVANWDPH.js.map +0 -1
  84. /package/dist/{chunk-QTSJOY54.js.map → chunk-VYFRI7R6.js.map} +0 -0
  85. /package/dist/{chunk-T3TI7GSZ.js.map → chunk-XNLB57UD.js.map} +0 -0
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createTemplateContext } from '../../../schemas/BlockTemplates.js';
3
+ import type { TextLayer } from '../../../schemas/Doc.js';
4
+ import { DEFAULT_THEME } from '../../../schemas/themeLibrary.js';
5
+ import { VIEWPORT_PRESETS } from '../../../schemas/Viewport.js';
6
+ import { listBlock } from '../listBlock.js';
7
+
8
+ const ITEMS = [
9
+ 'We launched the scripting platform for Minecraft, which was essential for launching new categories of content for Minecraft (add-ons)',
10
+ "I spec'ed and largely built the Minecraft Creator Tools suite, including a website for beginning to advanced creators and set of NPM commands and tools.",
11
+ 'I manage and craft the Minecraft samples and documentation suite',
12
+ 'I produce and create the Minecraft Creator Channel on YouTube',
13
+ ];
14
+
15
+ describe('listBlock', () => {
16
+ it('reserves vertical space for wrapped list items', () => {
17
+ const context = createTemplateContext(DEFAULT_THEME, 0, 1, VIEWPORT_PRESETS.landscape);
18
+ const layers = listBlock(
19
+ {
20
+ template: 'list',
21
+ id: 'minecraft-platform',
22
+ duration: 10,
23
+ audioSegment: 0,
24
+ title: 'Minecraft Platform',
25
+ items: ITEMS,
26
+ },
27
+ context,
28
+ );
29
+ const itemLayers = layers.filter(
30
+ (layer): layer is TextLayer => layer.type === 'text' && /^item-\d+$/.test(layer.id),
31
+ );
32
+ const markerLayers = layers.filter(
33
+ (layer): layer is TextLayer => layer.type === 'text' && /^item-\d+-marker$/.test(layer.id),
34
+ );
35
+ const yPositions = itemLayers.map((layer) => parseFloat(String(layer.position.y)));
36
+
37
+ expect(itemLayers).toHaveLength(4);
38
+ expect(markerLayers).toHaveLength(4);
39
+ expect(yPositions[0]).toBe(34);
40
+
41
+ // The marker has its own right-aligned column, while all body lines are
42
+ // rendered by one layer beginning after that column (a hanging indent).
43
+ expect(markerLayers[0]!.content.text).toBe('1.');
44
+ expect(markerLayers[0]!.content.style.textAlign).toBe('right');
45
+ expect(itemLayers[0]!.content.text).toBe(ITEMS[0]);
46
+ const markerRightPx =
47
+ (parseFloat(String(markerLayers[0]!.position.x)) / 100) * context.viewport.width +
48
+ Number(markerLayers[0]!.position.width);
49
+ const bodyLeftPx =
50
+ (parseFloat(String(itemLayers[0]!.position.x)) / 100) * context.viewport.width;
51
+ expect(bodyLeftPx).toBeGreaterThan(markerRightPx);
52
+
53
+ // The first two entries wrap to two lines. Their following baselines
54
+ // therefore advance by two line-heights plus the inter-item gap.
55
+ const fontSize = itemLayers[0]!.content.style.fontSize;
56
+ const twoLineAdvance = ((2 * fontSize * 1.2 + 18) / context.viewport.height) * 100;
57
+ expect(yPositions[1]! - yPositions[0]!).toBeCloseTo(twoLineAdvance);
58
+ expect(yPositions[2]! - yPositions[1]!).toBeCloseTo(twoLineAdvance);
59
+
60
+ // The third entry fits on one line, so the final advance is compact.
61
+ const oneLineAdvance = ((fontSize * 1.2 + 18) / context.viewport.height) * 100;
62
+ expect(yPositions[3]! - yPositions[2]!).toBeCloseTo(oneLineAdvance);
63
+ });
64
+ });
@@ -19,6 +19,43 @@ import {
19
19
  } from '../utils/themeUtils.js';
20
20
  import { createAccentLayers, getAccentLayout, adjustY, DEFAULT_LAYOUT } from './accentImage.js';
21
21
 
22
+ const LIST_ITEM_LINE_HEIGHT = 1.2;
23
+ const LIST_ITEM_GAP_PX = 18;
24
+
25
+ /**
26
+ * Mirror the renderer's character-based wrapping closely enough to reserve
27
+ * vertical space for each independently positioned text layer.
28
+ */
29
+ function estimateWrappedLineCount(text: string, fontSize: number, maxWidth: number): number {
30
+ if (!text.trim()) return 1;
31
+
32
+ const charsPerLine = Math.floor(maxWidth / (fontSize * 0.5));
33
+ if (charsPerLine <= 0) return 1;
34
+
35
+ let lineCount = 0;
36
+ let currentLineLength = 0;
37
+
38
+ for (const word of text.split(/\s+/)) {
39
+ const testLineLength = currentLineLength ? currentLineLength + 1 + word.length : word.length;
40
+
41
+ if (testLineLength <= charsPerLine) {
42
+ currentLineLength = testLineLength;
43
+ continue;
44
+ }
45
+
46
+ if (currentLineLength) lineCount += 1;
47
+
48
+ let remainingLength = word.length;
49
+ while (remainingLength > charsPerLine) {
50
+ lineCount += 1;
51
+ remainingLength -= charsPerLine;
52
+ }
53
+ currentLineLength = remainingLength;
54
+ }
55
+
56
+ return Math.max(1, lineCount + (currentLineLength ? 1 : 0));
57
+ }
58
+
22
59
  export function listBlock(input: ListBlockInput, context: TemplateContext): Layer[] {
23
60
  const { title, accentImage } = input;
24
61
  // `items` is required by the schema, but malformed / partially-authored
@@ -66,8 +103,9 @@ export function listBlock(input: ListBlockInput, context: TemplateContext): Laye
66
103
  // defensive against future edits to the accent-layout types).
67
104
  const centerX = parseFloat(accentLayout.textCenterX);
68
105
  const widthPct = parseFloat(accentLayout.textWidth);
69
- const leftX =
70
- Number.isFinite(centerX) && Number.isFinite(widthPct) ? `${centerX - widthPct / 2}%` : '8%';
106
+ const leftPct =
107
+ Number.isFinite(centerX) && Number.isFinite(widthPct) ? centerX - widthPct / 2 : 8;
108
+ const leftX = `${leftPct}%`;
71
109
 
72
110
  // Title if provided
73
111
  const startY = title ? 34 : 26;
@@ -95,28 +133,49 @@ export function listBlock(input: ListBlockInput, context: TemplateContext): Laye
95
133
  });
96
134
  }
97
135
 
98
- // Stack items with a fixed compact gap rather than stretching them
99
- // across the available band. Distributing items across (startY 80%)
100
- // left big vertical gaps for short lists and made the slide read as a
101
- // sparse menu instead of a tight enumeration; conversely a bare
102
- // line-height gap read as a cramped paragraph — 18px of air keeps each
103
- // entry its own line without breaking the group.
104
- //
105
- // Spacing = item line-height (34px base × 1.2) + 18px gap, expressed
106
- // as % of the 1080px design canvas (~5.4%). Wrapped items push the
107
- // next entry down via their own line-height, so this sets the
108
- // minimum baseline-to-baseline distance for unwrapped items.
109
- const LIST_ITEM_BASE_PX = 34;
110
- const LIST_ITEM_LINE_HEIGHT = 1.2;
111
- const LIST_ITEM_GAP_PX = 18;
112
- const DESIGN_HEIGHT_PX = 1080;
113
- const spacing =
114
- ((LIST_ITEM_BASE_PX * LIST_ITEM_LINE_HEIGHT + LIST_ITEM_GAP_PX) / DESIGN_HEIGHT_PX) * 100;
136
+ // Render the number and body in separate columns. The body is therefore a
137
+ // single text layer whose wrapped lines all share the same x coordinate,
138
+ // producing a hanging indent instead of wrapping underneath the number.
139
+ const textWidthPx = (Number.isFinite(widthPct) ? widthPct / 100 : 0.85) * context.viewport.width;
140
+ const markerText = `${items.length}.`;
141
+ const markerWidthPx = Math.max(itemFontSize, markerText.length * itemFontSize * 0.5);
142
+ const markerGapPx = itemFontSize * 0.35;
143
+ const bodyIndentPx = markerWidthPx + markerGapPx;
144
+ const bodyLeftX = `${leftPct + (bodyIndentPx / context.viewport.width) * 100}%`;
145
+ const bodyWidthPx = Math.max(itemFontSize, textWidthPx - bodyIndentPx);
146
+ let itemY = startY;
115
147
 
116
148
  // List items with staggered animation
117
149
  for (let i = 0; i < items.length; i++) {
118
- const y = startY + spacing * i;
119
- const itemText = `${i + 1}. ${items[i]}`;
150
+ const itemText = items[i]!;
151
+ const lineCount = estimateWrappedLineCount(itemText, itemFontSize, bodyWidthPx);
152
+ const animation = themedEntrance(context, 'text', {
153
+ type: 'fadeIn',
154
+ duration: 0.8,
155
+ delay: 0.3 + 0.3 * i,
156
+ });
157
+
158
+ layers.push({
159
+ type: 'text',
160
+ id: `item-${i}-marker`,
161
+ content: {
162
+ text: `${i + 1}.`,
163
+ style: {
164
+ fontSize: itemFontSize,
165
+ fontFamily: getThemeFont(context, 'body'),
166
+ color: theme.colors.text,
167
+ textAlign: 'right',
168
+ lineHeight: LIST_ITEM_LINE_HEIGHT,
169
+ shadow: shouldUseShadow(context),
170
+ },
171
+ },
172
+ position: {
173
+ x: leftX,
174
+ y: adjustY(`${itemY}%`, accentLayout),
175
+ width: markerWidthPx,
176
+ },
177
+ animation,
178
+ });
120
179
 
121
180
  layers.push({
122
181
  type: 'text',
@@ -128,21 +187,20 @@ export function listBlock(input: ListBlockInput, context: TemplateContext): Laye
128
187
  fontFamily: getThemeFont(context, 'body'),
129
188
  color: theme.colors.text,
130
189
  textAlign: 'left',
131
- lineHeight: 1.2,
190
+ lineHeight: LIST_ITEM_LINE_HEIGHT,
132
191
  shadow: shouldUseShadow(context),
133
192
  },
134
193
  },
135
194
  position: {
136
- x: leftX,
137
- y: adjustY(`${y}%`, accentLayout),
138
- width: accentLayout.textWidth,
195
+ x: bodyLeftX,
196
+ y: adjustY(`${itemY}%`, accentLayout),
197
+ width: bodyWidthPx,
139
198
  },
140
- animation: themedEntrance(context, 'text', {
141
- type: 'fadeIn',
142
- duration: 0.8,
143
- delay: 0.3 + 0.3 * i,
144
- }),
199
+ animation,
145
200
  });
201
+
202
+ const itemHeightPx = lineCount * itemFontSize * LIST_ITEM_LINE_HEIGHT;
203
+ itemY += ((itemHeightPx + LIST_ITEM_GAP_PX) / context.viewport.height) * 100;
146
204
  }
147
205
 
148
206
  return layers;
@@ -381,7 +381,8 @@ export type Layer =
381
381
  | MapLayer
382
382
  | VideoLayer
383
383
  | TableLayer
384
- | TreeLayer;
384
+ | TreeLayer
385
+ | MermaidLayer;
385
386
 
386
387
  interface BaseLayer {
387
388
  /** Unique identifier for this layer */
@@ -759,6 +760,26 @@ export interface TreeLayerStyle {
759
760
  fileIcon?: string;
760
761
  }
761
762
 
763
+ /**
764
+ * Mermaid diagram layer — renders an authored `mermaid` code fence through
765
+ * the official Mermaid runtime while keeping the fence source authoritative.
766
+ * Like video/table/tree layers, the browser renderer hosts the diagram in an
767
+ * SVG `<foreignObject>` so it participates in slide layout and frame capture.
768
+ */
769
+ export interface MermaidLayer extends BaseLayer {
770
+ type: 'mermaid';
771
+ content: {
772
+ /** Complete Mermaid source, excluding the Markdown fence delimiters. */
773
+ source: string;
774
+ /** Theme-derived panel color behind the generated SVG. */
775
+ background?: string;
776
+ /** Theme-derived foreground used by loading/error states. */
777
+ foreground?: string;
778
+ /** Inner panel padding in pixels. */
779
+ padding?: number;
780
+ };
781
+ }
782
+
762
783
  /**
763
784
  * Available map tile styles from free/open-source providers.
764
785
  */
@@ -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';