@liustack/pptwise 0.22.0 → 0.24.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 (42) hide show
  1. package/README.md +72 -41
  2. package/README.zh-CN.md +72 -41
  3. package/dist/{chunk-M35M4QUC.js → chunk-2CA3JRFR.js} +460 -499
  4. package/dist/chunk-2CA3JRFR.js.map +1 -0
  5. package/dist/{chunk-WL5KWYKS.js → chunk-G7WQ7KSP.js} +34800 -30625
  6. package/dist/chunk-G7WQ7KSP.js.map +1 -0
  7. package/dist/{chunk-VUOLBHD7.js → chunk-RQUKZYSH.js} +1 -1
  8. package/dist/{chunk-VUOLBHD7.js.map → chunk-RQUKZYSH.js.map} +1 -1
  9. package/dist/{chunk-3ZUKISTY.js → chunk-VEZGVYJS.js} +2 -2
  10. package/dist/cli.js +911 -462
  11. package/dist/cli.js.map +1 -1
  12. package/dist/index.d.ts +5320 -1534
  13. package/dist/index.js +15 -13
  14. package/dist/node.d.ts +1 -1
  15. package/dist/node.js +2 -2
  16. package/dist/{pixel-audit-H5K6JK3X.js → pixel-audit-M5Q5WCST.js} +4 -4
  17. package/dist/pixel-audit-M5Q5WCST.js.map +1 -0
  18. package/dist/{registry-C0GJH7ZT.d.ts → registry-D8bkC8ff.d.ts} +1 -1
  19. package/dsh/preview-tool.js +20 -17
  20. package/package.json +2 -1
  21. package/skills/pptwise/SKILL.md +133 -70
  22. package/skills/pptwise/SKILL.zh-CN.md +136 -73
  23. package/skills/pptwise/references/branding.md +34 -8
  24. package/skills/pptwise/references/branding.zh-CN.md +36 -8
  25. package/skills/pptwise/references/components.md +63 -33
  26. package/skills/pptwise/references/components.zh-CN.md +63 -33
  27. package/skills/pptwise/references/density.md +23 -9
  28. package/skills/pptwise/references/density.zh-CN.md +23 -9
  29. package/skills/pptwise/references/images.md +26 -13
  30. package/skills/pptwise/references/images.zh-CN.md +26 -13
  31. package/skills/pptwise/references/layouts.md +30 -26
  32. package/skills/pptwise/references/layouts.zh-CN.md +30 -26
  33. package/skills/pptwise/references/spec.md +143 -69
  34. package/skills/pptwise/references/spec.zh-CN.md +143 -69
  35. package/skills/pptwise/references/validate.md +33 -45
  36. package/skills/pptwise/references/validate.zh-CN.md +33 -45
  37. package/skills/pptwise/scripts/run.ps1 +1 -1
  38. package/skills/pptwise/scripts/run.sh +1 -1
  39. package/dist/chunk-M35M4QUC.js.map +0 -1
  40. package/dist/chunk-WL5KWYKS.js.map +0 -1
  41. package/dist/pixel-audit-H5K6JK3X.js.map +0 -1
  42. /package/dist/{chunk-3ZUKISTY.js.map → chunk-VEZGVYJS.js.map} +0 -0
@@ -1,220 +1,124 @@
1
1
  import {
2
- AssetsSchema,
3
- BEAT_VALUES,
4
- BrandConfigSchema,
2
+ BUILTIN_THEME_FILES,
5
3
  BrandSchema,
6
4
  CANONICAL_THEME_IDS,
7
5
  CAPACITY,
8
6
  COMPONENT_TYPES,
9
7
  DeckBrandingSchema,
10
- LAYOUT_REGISTRY,
8
+ HexTokenSchema,
9
+ KIND_VALUES,
11
10
  MetaSchema,
12
11
  NarrativeProfileInputSchema,
13
12
  PptwiseError,
14
13
  PptxIRSchema,
15
- STRATEGY_DEFINITIONS,
16
- SlideSchema,
14
+ THEME_ID_CONSTRAINT,
15
+ THEME_ID_PATTERN,
17
16
  THEME_LABELS,
18
- ThemeSchema,
17
+ THEME_OCCASIONS,
18
+ THEME_REQUIRED_MESSAGE,
19
+ ThemeFileSchema,
20
+ assertContrastFloor,
19
21
  contrastRatio,
20
22
  getInstalledThemeIds,
23
+ getLayout,
21
24
  getThemeDefinition,
25
+ installThemeFile,
22
26
  mixHex,
23
27
  normalizeDeckRootAliases,
24
28
  normalizeNarrativeShape,
29
+ offeredContentKinds,
25
30
  parseTransform,
26
- registerTheme,
27
31
  renderSlideSvg,
28
- resolveEffectiveLayoutId,
32
+ resolveLayoutId,
29
33
  resolveNarrative,
30
- resolveStyle
31
- } from "./chunk-WL5KWYKS.js";
34
+ resolveStyle,
35
+ themeIssueMessage
36
+ } from "./chunk-G7WQ7KSP.js";
32
37
  import {
33
38
  getPlatform
34
- } from "./chunk-VUOLBHD7.js";
39
+ } from "./chunk-RQUKZYSH.js";
35
40
 
36
41
  // src/version.ts
37
- var VERSION = "0.22.0";
42
+ var VERSION = "0.24.0";
38
43
 
39
- // src/ir/legacy-v3.ts
40
- import { z } from "zod";
41
- var PptxIRV3Schema = z.object({
42
- version: z.literal("3").default("3"),
43
- filename: z.string().default("presentation"),
44
- // Pre-rename field name and axis vocabulary (mode/delivery/audience) —
45
- // frozen as of the 0.3.0 release, spec §9.3. Same open-schema/closed-
46
- // semantic split `NarrativeProfileInputSchema` documents: the actual
47
- // mode/delivery/audience enum closure happened at `resolveScenario`
48
- // runtime, not here, even before this rename.
49
- scenario: z.union([z.string(), NarrativeProfileInputSchema]).optional(),
50
- theme: ThemeSchema.default({ id: "consulting" }),
51
- meta: MetaSchema.default({}),
52
- assets: AssetsSchema.default({ images: {} }),
53
- brand: BrandSchema.optional(),
54
- // Optional so a v3 file that already carried the (then-undocumented)
55
- // chrome key still parses. migrateIrV3ToV4 rewrites it to branding.
56
- chrome: DeckBrandingSchema.optional(),
57
- seed: z.number().int().optional(),
58
- slides: z.array(SlideSchema)
59
- }).strict();
60
-
61
- // src/ir/migrate.ts
62
- function migrateChromeToBranding(raw) {
63
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return raw;
64
- const obj = raw;
65
- const hasChrome = Object.hasOwn(obj, "chrome");
66
- const hasBranding = Object.hasOwn(obj, "branding");
67
- if (hasChrome && hasBranding) {
68
- throw new PptwiseError('cannot migrate: both "chrome" and "branding" are present');
69
- }
70
- if (!hasChrome) return raw;
71
- const next = { ...obj, branding: obj.chrome };
72
- delete next.chrome;
73
- return next;
74
- }
75
- function migrateBloomToClassroom(raw) {
76
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return raw;
77
- const obj = raw;
78
- const theme = obj.theme;
79
- if (theme === "bloom") {
80
- return { ...obj, theme: "classroom" };
81
- }
82
- if (typeof theme === "object" && theme !== null && !Array.isArray(theme)) {
83
- const themeObj = theme;
84
- if (themeObj.id === "bloom") {
85
- return { ...obj, theme: { ...themeObj, id: "classroom" } };
86
- }
87
- }
88
- return raw;
89
- }
90
- function isPlainRecord(value) {
91
- return typeof value === "object" && value !== null && !Array.isArray(value);
92
- }
93
- function isLogoWallComponent(value) {
94
- return isPlainRecord(value) && value.type === "logo_wall";
95
- }
96
- function arrayHasLogoWall(components) {
97
- return Array.isArray(components) && components.some(isLogoWallComponent);
98
- }
99
- function rewriteLogoWallItem(item) {
100
- if (!isPlainRecord(item)) return item;
101
- const next = { asset_id: item.asset_id };
102
- if (Object.hasOwn(item, "label") && item.label !== void 0) next.caption = item.label;
103
- return next;
104
- }
105
- function rewriteLogoWallComponent(component) {
106
- const items = component.items;
107
- if (!Array.isArray(items)) {
108
- return { type: "image_grid", items };
44
+ // src/themes/presets.ts
45
+ var THEME_PRESETS = CANONICAL_THEME_IDS.map((id) => ({
46
+ id,
47
+ label: BUILTIN_THEME_FILES[id].label,
48
+ occasions: THEME_OCCASIONS[id].occasions,
49
+ identity: THEME_OCCASIONS[id].identity
50
+ }));
51
+ function isThemePresetId(id) {
52
+ return CANONICAL_THEME_IDS.includes(id);
53
+ }
54
+ function getThemePreset(id) {
55
+ const summary = THEME_PRESETS.find((preset) => preset.id === id);
56
+ if (!summary) {
57
+ throw new PptwiseError(
58
+ `unknown theme preset "${id}". Installed presets: ${CANONICAL_THEME_IDS.join(", ")}`
59
+ );
109
60
  }
110
- return { type: "image_grid", items: items.slice(0, 4).map(rewriteLogoWallItem) };
111
- }
112
- function rewriteComponentsArray(components) {
113
- return components.map((component) => isLogoWallComponent(component) ? rewriteLogoWallComponent(component) : component);
61
+ return summary;
114
62
  }
115
- function migrateLogoWallToImageGrid(raw) {
116
- if (!isPlainRecord(raw)) return raw;
117
- let next;
118
- const take = () => {
119
- if (!next) next = { ...raw };
120
- return next;
63
+ function withDecor(entry, motif) {
64
+ if (entry.decor !== void 0 || motif === void 0) return structuredClone(entry);
65
+ if (getLayout(entry.face)?.suppressMotif === true) return structuredClone(entry);
66
+ return {
67
+ ...structuredClone(entry),
68
+ decor: motif.params ? { kind: "motif", id: motif.id, params: { ...motif.params } } : { kind: "motif", id: motif.id }
121
69
  };
122
- if (arrayHasLogoWall(raw.components)) {
123
- take().components = rewriteComponentsArray(raw.components);
124
- }
125
- if (Array.isArray(raw.slides)) {
126
- let slidesChanged = false;
127
- const slides = raw.slides.map((slide) => {
128
- if (!isPlainRecord(slide) || !arrayHasLogoWall(slide.components)) return slide;
129
- slidesChanged = true;
130
- return { ...slide, components: rewriteComponentsArray(slide.components) };
131
- });
132
- if (slidesChanged) take().slides = slides;
70
+ }
71
+ function copyMenu(menu, motif) {
72
+ const content = {};
73
+ for (const [kind, entry] of Object.entries(menu.content)) {
74
+ content[kind] = withDecor(entry, motif);
133
75
  }
134
- return next ?? raw;
135
- }
136
- function rewriteBannerHeadingField(obj, key) {
137
- if (obj[key] !== "banner-heading") return void 0;
138
- return { ...obj, [key]: "two-column" };
139
- }
140
- function migrateBannerHeadingToTwoColumn(raw) {
141
- if (!isPlainRecord(raw)) return raw;
142
- let next;
143
- const take = () => {
144
- if (!next) next = { ...raw };
145
- return next;
76
+ return {
77
+ cover: withDecor(menu.cover, motif),
78
+ chapter: withDecor(menu.chapter, motif),
79
+ content,
80
+ ending: withDecor(menu.ending, motif)
146
81
  };
147
- const topLayout = rewriteBannerHeadingField(raw, "layout");
148
- const topFocus = rewriteBannerHeadingField(topLayout ?? raw, "focus");
149
- if (topLayout || topFocus) {
150
- const rewritten = topFocus ?? topLayout;
151
- Object.assign(take(), rewritten);
152
- }
153
- if (Array.isArray(raw.slides)) {
154
- let slidesChanged = false;
155
- const slides = raw.slides.map((slide) => {
156
- if (!isPlainRecord(slide)) return slide;
157
- const rewritten = rewriteBannerHeadingField(slide, "layout");
158
- if (!rewritten) return slide;
159
- slidesChanged = true;
160
- return rewritten;
161
- });
162
- if (slidesChanged) take().slides = slides;
163
- }
164
- if (Array.isArray(raw.pages)) {
165
- let pagesChanged = false;
166
- const pages = raw.pages.map((page) => {
167
- if (!isPlainRecord(page)) return page;
168
- const layoutHit = rewriteBannerHeadingField(page, "layout");
169
- const focusHit = rewriteBannerHeadingField(layoutHit ?? page, "focus");
170
- const rewritten = focusHit ?? layoutHit;
171
- if (!rewritten) return page;
172
- pagesChanged = true;
173
- return rewritten;
174
- });
175
- if (pagesChanged) take().pages = pages;
176
- }
177
- return next ?? raw;
178
- }
179
- var STRATEGY_VALUE_MIGRATION = { narrative: "storytelling" };
180
- var PACING_VALUE_MIGRATION = { text: "dense", presentation: "spacious" };
181
- function migrateNarrativeInput(scenario) {
182
- if (scenario === void 0 || typeof scenario === "string") return scenario;
183
- const narrative = {};
184
- for (const [key, value] of Object.entries(scenario)) {
185
- if (key === "mode") {
186
- narrative.strategy = typeof value === "string" ? STRATEGY_VALUE_MIGRATION[value] ?? value : value;
187
- } else if (key === "delivery") {
188
- narrative.pacing = typeof value === "string" ? PACING_VALUE_MIGRATION[value] ?? value : value;
189
- } else if (key === "audience") {
190
- narrative.audience = value;
191
- } else {
192
- narrative[key] = value;
193
- }
82
+ }
83
+ function copyThemePreset(presetId, targetId) {
84
+ getThemePreset(presetId);
85
+ if (!THEME_ID_PATTERN.test(targetId)) {
86
+ throw new PptwiseError(`invalid theme id "${targetId}". ${THEME_ID_CONSTRAINT}`);
194
87
  }
195
- return narrative;
196
- }
197
- function migrateIrV3ToV4(v3) {
198
- const narrative = migrateNarrativeInput(v3.scenario);
199
- const v4 = {
200
- version: "4",
201
- filename: v3.filename,
202
- ...narrative !== void 0 ? { narrative } : {},
203
- theme: v3.theme,
204
- meta: v3.meta,
205
- assets: v3.assets,
206
- ...v3.brand !== void 0 ? { brand: v3.brand } : {},
207
- ...v3.chrome !== void 0 ? { chrome: v3.chrome } : {},
208
- ...v3.seed !== void 0 ? { seed: v3.seed } : {},
209
- slides: v3.slides
88
+ const preset = BUILTIN_THEME_FILES[presetId];
89
+ const record = THEME_OCCASIONS[preset.id];
90
+ return {
91
+ version: 2,
92
+ id: targetId,
93
+ label: preset.label,
94
+ style: { ...structuredClone(preset.style), id: targetId },
95
+ ...preset.brand ? { brand: structuredClone(preset.brand) } : {},
96
+ occasions: [...record.occasions],
97
+ identity: record.identity,
98
+ menu: copyMenu(preset.menu, preset.motif)
210
99
  };
211
- return migrateBannerHeadingToTwoColumn(
212
- migrateLogoWallToImageGrid(migrateBloomToClassroom(migrateChromeToBranding(v4)))
213
- );
214
100
  }
215
101
 
216
- // src/themes/brand-extract.ts
102
+ // src/themes/extract/brand-extract.ts
217
103
  import JSZip from "jszip";
104
+ var EXTRACTED_THEME_MENU = {
105
+ cover: { face: "poster-center" },
106
+ chapter: { face: "masthead-chapter" },
107
+ content: {
108
+ points: { face: "two-column" },
109
+ list: { face: "bento-panel" },
110
+ comparison: { face: "two-column" },
111
+ process: { face: "rail-numbered" },
112
+ data: { face: "bento-panel" },
113
+ photo: { face: "asymmetric-triptych" },
114
+ statement: { face: "statement" },
115
+ quote: { face: "pull-quote" },
116
+ fact: { face: "stat-hero" },
117
+ evidence: { face: "one-evidence" },
118
+ hierarchy: { face: "two-column" }
119
+ },
120
+ ending: { face: "poster-ending" }
121
+ };
218
122
  var ACCENT_SLOTS = ["accent1", "accent2", "accent3", "accent4", "accent5", "accent6"];
219
123
  var THEME_PART_RE = /theme\d*\.xml$/;
220
124
  var THEME_VARIANTS_SEGMENT = "themeVariants";
@@ -252,15 +156,22 @@ function resolveBgText(dk1, lt1) {
252
156
  }
253
157
  var MUTED_STEPS = 20;
254
158
  var MUTED_TARGET_RATIO = 4.5;
255
- function deriveMuted(text, bg, surface) {
159
+ function findMutedCandidate(text, bg, surface) {
256
160
  for (let step = MUTED_STEPS; step >= 0; step--) {
257
161
  const t = step / MUTED_STEPS;
258
162
  const candidate = mixHex(text, bg, t);
259
163
  if (contrastRatio(candidate, bg) >= MUTED_TARGET_RATIO && contrastRatio(candidate, surface) >= MUTED_TARGET_RATIO) {
260
- return candidate;
164
+ return candidate.toUpperCase();
261
165
  }
262
166
  }
263
- return text;
167
+ return void 0;
168
+ }
169
+ function deriveMuted(text, bg, surface) {
170
+ const candidate = findMutedCandidate(text, bg, surface);
171
+ if (candidate !== void 0) return candidate;
172
+ throw new PptwiseError(
173
+ `cannot derive colors.muted with a contrast ratio of ${MUTED_TARGET_RATIO.toFixed(1)}:1 against both colors.bg (${bg}) and colors.surface (${surface}) from colors.text (${text}). Choose compatible background and text anchors`
174
+ );
264
175
  }
265
176
  var SERIF_HINTS = [
266
177
  "times",
@@ -333,124 +244,259 @@ async function extractBrandTheme(bytes, opts = {}) {
333
244
  ending: { kind: "color", value: bg }
334
245
  }
335
246
  };
336
- return { id, label, style, brand: {}, tags: [] };
247
+ return { version: 2, id, label, style, brand: {}, menu: EXTRACTED_THEME_MENU };
337
248
  }
338
249
 
339
250
  // src/themes/brand-theme-file.ts
340
- import { z as z2 } from "zod";
341
- var HexToken = z2.string().regex(/^#[0-9A-Fa-f]{3,8}$/, "expected a hex color like #RRGGBB");
342
- var BackgroundSpecFileSchema = z2.discriminatedUnion("kind", [
343
- z2.object({ kind: z2.literal("color"), value: HexToken }).strict(),
344
- z2.object({
345
- kind: z2.literal("gradient"),
346
- from: HexToken,
347
- to: HexToken,
348
- direction: z2.enum(["tb", "lr", "diagonal"]).optional()
349
- }).strict(),
350
- z2.object({
351
- kind: z2.literal("asset"),
352
- asset_id: z2.string(),
353
- overlay: z2.object({ color: HexToken, opacity: z2.number().min(0).max(1) }).strict().optional(),
354
- fit: z2.enum(["cover", "contain"]).optional()
355
- }).strict()
356
- ]);
357
- var StyleTokensFileSchema = z2.object({
358
- id: z2.string().min(1),
359
- allowCustomBackground: z2.boolean().optional(),
360
- colors: z2.object({
361
- bg: HexToken,
362
- surface: HexToken,
363
- panel: HexToken.optional(),
364
- primary: HexToken,
365
- accent: HexToken,
366
- text: HexToken,
367
- muted: HexToken,
368
- border: HexToken.optional(),
369
- chartPalette: z2.array(HexToken).min(1),
370
- accentPool: z2.array(HexToken).min(1).optional(),
371
- cardStroke: HexToken.optional()
372
- }).strict(),
373
- fonts: z2.object({
374
- heading: z2.array(z2.string()).min(1),
375
- body: z2.array(z2.string()).min(1),
376
- mono: z2.array(z2.string()).min(1).optional()
377
- }).strict(),
378
- shape: z2.object({
379
- radius: z2.number().min(0).max(32).optional(),
380
- gapScale: z2.number().min(0.8).max(1.3).optional(),
381
- typeScale: z2.number().min(0.5).max(2).optional()
382
- }).strict().optional(),
383
- defaultBackgrounds: z2.object({
384
- cover: BackgroundSpecFileSchema,
385
- chapter: BackgroundSpecFileSchema,
386
- content: BackgroundSpecFileSchema,
387
- ending: BackgroundSpecFileSchema
388
- }).strict()
389
- }).strict();
390
- var BrandThemeFileSchema = z2.object({
391
- id: z2.string().min(1),
392
- label: z2.string().optional(),
393
- style: StyleTokensFileSchema,
394
- brand: BrandConfigSchema.optional(),
395
- tags: z2.array(z2.string()).optional()
396
- }).strict();
251
+ var BrandThemeFileSchema = ThemeFileSchema;
397
252
  function parseBrandThemeFile(raw, source) {
398
- const r = BrandThemeFileSchema.safeParse(raw);
399
- if (!r.success) {
400
- const detail = r.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
401
- throw new PptwiseError(`invalid theme file ${source}:
402
- ${detail}`);
253
+ const result = ThemeFileSchema.safeParse(raw);
254
+ if (!result.success) {
255
+ const detail = result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
256
+ throw new PptwiseError(
257
+ `invalid theme file ${source}: current theme format is version 2 and every file is self-contained.
258
+ ${detail}`
259
+ );
403
260
  }
404
- return r.data;
261
+ return result.data;
405
262
  }
406
263
  function registerBrandThemeFile(file) {
407
- if (CANONICAL_THEME_IDS.includes(file.id)) {
408
- throw new PptwiseError(
409
- `theme file id "${file.id}" collides with a built-in pptwise theme \u2014 pick a different id (\`pptwise brand extract --id <id>\`, or edit the theme file's own "id" field)`
410
- );
264
+ installThemeFile(file);
265
+ return file.id;
266
+ }
267
+
268
+ // src/cli/theme-fork.ts
269
+ function clamp(value, min = 0, max = 1) {
270
+ return Math.min(max, Math.max(min, value));
271
+ }
272
+ function hexToRgb(hex) {
273
+ const canonical = HexTokenSchema.parse(hex);
274
+ const n = Number.parseInt(canonical.slice(1), 16);
275
+ return [(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255];
276
+ }
277
+ function hexToHsl(hex) {
278
+ const [r, g, b] = hexToRgb(hex);
279
+ const max = Math.max(r, g, b);
280
+ const min = Math.min(r, g, b);
281
+ const delta = max - min;
282
+ const l = (max + min) / 2;
283
+ if (delta === 0) return { h: 0, s: 0, l };
284
+ const s = delta / (1 - Math.abs(2 * l - 1));
285
+ let h = 0;
286
+ if (max === r) h = 60 * ((g - b) / delta % 6);
287
+ else if (max === g) h = 60 * ((b - r) / delta + 2);
288
+ else h = 60 * ((r - g) / delta + 4);
289
+ return { h: (h + 360) % 360, s, l };
290
+ }
291
+ function hslToHex({ h, s, l }) {
292
+ const hue = (h % 360 + 360) % 360;
293
+ const chroma = (1 - Math.abs(2 * l - 1)) * s;
294
+ const segment = hue / 60;
295
+ const x = chroma * (1 - Math.abs(segment % 2 - 1));
296
+ const [r1, g1, b1] = segment < 1 ? [chroma, x, 0] : segment < 2 ? [x, chroma, 0] : segment < 3 ? [0, chroma, x] : segment < 4 ? [0, x, chroma] : segment < 5 ? [x, 0, chroma] : [chroma, 0, x];
297
+ const m = l - chroma / 2;
298
+ const channel = (value) => Math.round((value + m) * 255).toString(16).padStart(2, "0");
299
+ return `#${channel(r1)}${channel(g1)}${channel(b1)}`.toUpperCase();
300
+ }
301
+ function hueDelta(from, to) {
302
+ return (to - from + 540) % 360 - 180;
303
+ }
304
+ function rebaseRelativeColor(color, sourcePlane, targetPlane, sourceText, targetText) {
305
+ const value = hexToHsl(color);
306
+ const source = hexToHsl(sourcePlane);
307
+ const target = hexToHsl(targetPlane);
308
+ const oldText = hexToHsl(sourceText);
309
+ const newText = hexToHsl(targetText);
310
+ const sourceRange = oldText.l - source.l;
311
+ const progress = Math.abs(sourceRange) < 1e-6 ? 0 : (value.l - source.l) / sourceRange;
312
+ const lightness = clamp(target.l + progress * (newText.l - target.l));
313
+ const offset = source.s > 0.02 && value.s > 0.02 ? hueDelta(source.h, value.h) : 0;
314
+ const hue = target.s > 0.02 ? target.h + offset : value.h;
315
+ const saturation = clamp(target.s + value.s - source.s);
316
+ return hslToHex({ h: hue, s: saturation, l: lightness });
317
+ }
318
+ var ORDINARY_BACKGROUND_ANCHORS = ["bg", "surface", "primary", "accent", "text"];
319
+ var CHAPTER_BACKGROUND_ANCHORS = ["primary", "bg", "surface", "accent", "text"];
320
+ function rgbDistance(a, b) {
321
+ const [ar, ag, ab] = hexToRgb(a);
322
+ const [br, bg, bb] = hexToRgb(b);
323
+ return Math.hypot(ar - br, ag - bg, ab - bb);
324
+ }
325
+ function resolveBackgroundAnchor(color, slideRole, source) {
326
+ const roles = slideRole === "chapter" ? CHAPTER_BACKGROUND_ANCHORS : ORDINARY_BACKGROUND_ANCHORS;
327
+ let best = roles[0];
328
+ let bestDistance = rgbDistance(color, source[best]);
329
+ for (const role of roles.slice(1)) {
330
+ const distance = rgbDistance(color, source[role]);
331
+ if (distance < bestDistance) {
332
+ best = role;
333
+ bestDistance = distance;
334
+ }
411
335
  }
412
- if (!getInstalledThemeIds().includes(file.id)) {
413
- registerTheme({ id: file.id, style: file.style, brand: file.brand ?? {}, tags: file.tags ?? [] });
336
+ return best;
337
+ }
338
+ function deriveBackgroundColor(color, slideRole, source, target) {
339
+ const role = resolveBackgroundAnchor(color, slideRole, source);
340
+ return rebaseRelativeColor(color, source[role], target[role], source.text, target.text);
341
+ }
342
+ function deriveBackground(spec, slideRole, source, target) {
343
+ if (spec.kind === "color") {
344
+ return { ...spec, value: deriveBackgroundColor(spec.value, slideRole, source, target) };
414
345
  }
415
- return file.id;
346
+ if (spec.kind === "gradient") {
347
+ return {
348
+ ...spec,
349
+ from: deriveBackgroundColor(spec.from, slideRole, source, target),
350
+ to: deriveBackgroundColor(spec.to, slideRole, source, target)
351
+ };
352
+ }
353
+ if (spec.overlay !== void 0) {
354
+ return {
355
+ ...spec,
356
+ overlay: {
357
+ ...spec.overlay,
358
+ color: deriveBackgroundColor(spec.overlay.color, slideRole, source, target)
359
+ }
360
+ };
361
+ }
362
+ return spec;
363
+ }
364
+ function rotatePaletteColor(color, sourceAnchor, targetAnchor) {
365
+ const value = hexToHsl(color);
366
+ const source = hexToHsl(sourceAnchor);
367
+ const target = hexToHsl(targetAnchor);
368
+ if (value.s <= 0.02 || source.s <= 0.02 || target.s <= 0.02) {
369
+ return hslToHex(value);
370
+ }
371
+ return hslToHex({ ...value, h: value.h + hueDelta(source.h, target.h) });
372
+ }
373
+ function derivePaletteSequence(sequence, sourceAnchor, targetAnchor) {
374
+ return [
375
+ HexTokenSchema.parse(targetAnchor),
376
+ ...sequence.slice(1).map((color) => rotatePaletteColor(color, sourceAnchor, targetAnchor))
377
+ ];
378
+ }
379
+ var STATUS_CONTRAST_RATIO = 4.5;
380
+ var STATUS_LIGHTNESS_STEPS = 1e3;
381
+ function calibrateStatusColor(role, color, bg, surface) {
382
+ const source = hexToHsl(color);
383
+ const original = hslToHex(source);
384
+ const clearsFloor = (candidate) => contrastRatio(candidate, bg) >= STATUS_CONTRAST_RATIO && contrastRatio(candidate, surface) >= STATUS_CONTRAST_RATIO;
385
+ if (clearsFloor(original)) return original;
386
+ let best;
387
+ for (let step = 0; step <= STATUS_LIGHTNESS_STEPS; step++) {
388
+ const lightness = step / STATUS_LIGHTNESS_STEPS;
389
+ const candidate = hslToHex({ ...source, l: lightness });
390
+ if (!clearsFloor(candidate)) continue;
391
+ const distance = Math.abs(lightness - source.l);
392
+ if (best === void 0 || distance < best.distance) best = { color: candidate, distance };
393
+ }
394
+ if (best !== void 0) return best.color;
395
+ throw new PptwiseError(
396
+ `cannot calibrate colors.${role} to a ${STATUS_CONTRAST_RATIO.toFixed(1)}:1 contrast ratio against both colors.bg (${bg}) and colors.surface (${surface}) while preserving its hue`
397
+ );
398
+ }
399
+ function buildForkedTheme(source, anchors, identity) {
400
+ const bg = HexTokenSchema.parse(anchors.bg ?? source.style.colors.bg);
401
+ const primary = HexTokenSchema.parse(anchors.primary);
402
+ const accent = HexTokenSchema.parse(anchors.accent ?? source.style.colors.accent);
403
+ const text = HexTokenSchema.parse(anchors.text ?? source.style.colors.text);
404
+ const surface = HexTokenSchema.parse(anchors.surface ?? source.style.colors.surface);
405
+ const muted = deriveMuted(text, bg, surface);
406
+ const sourceColors = source.style.colors;
407
+ const sourceAnchors = {
408
+ bg: sourceColors.bg,
409
+ surface: sourceColors.surface,
410
+ text: sourceColors.text,
411
+ primary: sourceColors.primary,
412
+ accent: sourceColors.accent
413
+ };
414
+ const targetAnchors = { bg, surface, text, primary, accent };
415
+ const chartPalette = anchors.chartPalette ?? sourceColors.chartPalette;
416
+ const chartAnchor = anchors.chartPalette === void 0 ? sourceColors.primary : chartPalette[0];
417
+ const colors = {
418
+ ...sourceColors,
419
+ bg,
420
+ primary,
421
+ accent,
422
+ text,
423
+ surface,
424
+ muted,
425
+ chartPalette: derivePaletteSequence(chartPalette, chartAnchor, primary)
426
+ };
427
+ if (sourceColors.accentPool !== void 0) {
428
+ colors.accentPool = derivePaletteSequence(sourceColors.accentPool, sourceColors.accent, accent);
429
+ }
430
+ if (sourceColors.panel !== void 0) {
431
+ colors.panel = rebaseRelativeColor(sourceColors.panel, sourceColors.bg, bg, sourceColors.text, text);
432
+ }
433
+ if (sourceColors.border !== void 0) {
434
+ colors.border = rebaseRelativeColor(sourceColors.border, sourceColors.surface, surface, sourceColors.text, text);
435
+ }
436
+ if (sourceColors.cardStroke !== void 0) {
437
+ colors.cardStroke = rebaseRelativeColor(sourceColors.cardStroke, sourceColors.surface, surface, sourceColors.text, text);
438
+ }
439
+ if (sourceColors.danger !== void 0) {
440
+ colors.danger = calibrateStatusColor("danger", sourceColors.danger, bg, surface);
441
+ }
442
+ if (sourceColors.warning !== void 0) {
443
+ colors.warning = calibrateStatusColor("warning", sourceColors.warning, bg, surface);
444
+ }
445
+ if (sourceColors.success !== void 0) {
446
+ colors.success = calibrateStatusColor("success", sourceColors.success, bg, surface);
447
+ }
448
+ const defaultBackgrounds = {
449
+ cover: deriveBackground(source.style.defaultBackgrounds.cover, "cover", sourceAnchors, targetAnchors),
450
+ chapter: deriveBackground(source.style.defaultBackgrounds.chapter, "chapter", sourceAnchors, targetAnchors),
451
+ content: deriveBackground(source.style.defaultBackgrounds.content, "content", sourceAnchors, targetAnchors),
452
+ ending: deriveBackground(source.style.defaultBackgrounds.ending, "ending", sourceAnchors, targetAnchors)
453
+ };
454
+ const file = {
455
+ version: 2,
456
+ id: identity.id,
457
+ label: identity.label ?? source.label,
458
+ style: {
459
+ ...source.style,
460
+ id: identity.id,
461
+ colors,
462
+ fonts: identity.fonts ?? source.style.fonts,
463
+ defaultBackgrounds
464
+ },
465
+ brand: source.brand,
466
+ occasions: source.occasions,
467
+ identity: source.identity,
468
+ menu: structuredClone(source.menu)
469
+ };
470
+ return ThemeFileSchema.parse(file);
471
+ }
472
+ function forkTheme(source, anchors, identity) {
473
+ const file = buildForkedTheme(source, anchors, identity);
474
+ assertContrastFloor(file.id, file.style);
475
+ return file;
416
476
  }
417
477
 
418
478
  // src/spec/index.ts
419
- import { z as z3 } from "zod";
420
- var PAGE_TYPES = ["cover", "chapter", "content", "ending"];
421
- var PageSpecSchema = z3.object({
422
- id: z3.string(),
423
- type: z3.enum(PAGE_TYPES),
424
- heading: z3.string(),
425
- /** One of the three beat values, or omitted entirely — an omitted
426
- * beat is never a hard-gate violation on its own (see
427
- * {@link checkBeatRotation}'s policy functions below). It gets
428
- * auto-alternated at assemble time (W5 task 3, not this task — still
429
- * unimplemented as of the P1 variety wave's task 1). Renamed
430
- * from `rhythm` (vocabulary-v4 rename, spec §4.3/§6/§8.1) — same
431
- * three values, same semantics, page-level term only, distinct from
432
- * the deck-level `pacing` axis. A *declared* value here is no longer
433
- * spec-only advisory material (P1 variety wave, task 1): `assembleDeck`
434
- * (`./assemble.ts`) now carries it straight into the IR's own
435
- * `Slide.beat` field, where it combines with a soft selection-weight
436
- * onto layout picking (`Math.max`, not multiplication — see
437
- * `SlideSchema.beat`'s own doc comment, `../ir/index.ts`, and
438
- * `BEAT_TENDENCY_WEIGHT`'s in `../svg/layout-selection.ts` for why) —
439
- * the checks below (rotation shape) and that downstream weighting
440
- * (which layouts a given beat favors) are two independent consumers
441
- * of the same declared value, not two views of one mechanism. */
442
- beat: z3.enum(BEAT_VALUES).optional(),
443
- /** Optional authoring hint pointing fill/select at a preferred
444
- * component type or layout id — see {@link checkFocusVocabulary}. */
445
- focus: z3.string().optional(),
446
- /** Free-text content anchor read by the fill step and never validated or
447
- * interpreted here. Assemble also surfaces it as `subheading` on
448
- * boundary pages, which have no page-content field for that line. On a
449
- * filled content page it remains a fill-only authoring hint. */
450
- summary: z3.string().optional()
451
- }).strict();
452
- var DeckSpecSchema = z3.object({
453
- version: z3.literal("1").default("1"),
479
+ import { z } from "zod";
480
+ var CommonPageSpecFields = {
481
+ id: z.string(),
482
+ heading: z.string(),
483
+ /** Optional authoring hint pointing fill at a preferred content kind or component type, not a face. */
484
+ focus: z.string().optional(),
485
+ /** Free-text content anchor read by the fill step. */
486
+ summary: z.string().optional()
487
+ };
488
+ var PageSpecSchema = z.discriminatedUnion("type", [
489
+ z.object({ type: z.literal("cover"), ...CommonPageSpecFields }).strict(),
490
+ z.object({ type: z.literal("chapter"), ...CommonPageSpecFields }).strict(),
491
+ z.object({
492
+ type: z.literal("content"),
493
+ kind: z.enum(KIND_VALUES),
494
+ ...CommonPageSpecFields
495
+ }).strict(),
496
+ z.object({ type: z.literal("ending"), ...CommonPageSpecFields }).strict()
497
+ ]);
498
+ var DeckSpecSchema = z.object({
499
+ version: z.literal("1").default("1"),
454
500
  // Same open-schema/closed-semantic split as PptxIRSchema's `narrative`
455
501
  // field — see `NarrativeProfileInputSchema`'s doc comment in `ir/index.ts`
456
502
  // for the full rationale (reused verbatim here, not redefined, so the
@@ -459,17 +505,18 @@ var DeckSpecSchema = z3.object({
459
505
  // *value* was already in the new strategy/pacing vocabulary as of task 1
460
506
  // (vocabulary-v4 rename) — `resolveNarrative` below is what actually
461
507
  // enforces that.
462
- narrative: z3.union([z3.string(), NarrativeProfileInputSchema]).optional(),
463
- theme: z3.string().optional(),
464
- filename: z3.string().optional(),
465
- seed: z3.number().int().optional(),
508
+ narrative: z.union([z.string(), NarrativeProfileInputSchema]).optional(),
509
+ theme: z.string({
510
+ error: (iss) => iss.input === void 0 ? THEME_REQUIRED_MESSAGE : void 0
511
+ }).regex(THEME_ID_PATTERN, THEME_ID_CONSTRAINT),
512
+ filename: z.string().optional(),
466
513
  meta: MetaSchema.default({}),
467
514
  /** Deck logo placement — reused verbatim from the IR's own `brand` field
468
515
  * (`BrandSchema`, `../ir`) so the deck spec and IR can't drift apart on
469
516
  * shape, same pattern as `meta` just above. Unlike `meta`, no
470
517
  * `.default({})`: IR's own `brand` field is a bare `.optional()` with no
471
518
  * default either (`undefined` means "no brand", not "an empty brand
472
- * object") — consumed by `Branding` (`src/svg/branding.tsx`) for
519
+ * object") — consumed by `Branding` (`src/render/branding.tsx`) for
473
520
  * the deck's logo image and corner position. */
474
521
  brand: BrandSchema.optional(),
475
522
  /**
@@ -479,14 +526,14 @@ var DeckSpecSchema = z3.object({
479
526
  * does not write `"cover-only"` into the IR. The renderer treats that
480
527
  * as `"cover-only"`. Omitted by default. Write `"full"` only when every
481
528
  * content page needs the brand footer. `"full"` also paints confidentiality
482
- * and date on cover and ending meta rows. Layout `branding: "none"` still
483
- * wins at render.
529
+ * and date on cover and ending meta rows. A silent menu entry can suppress
530
+ * branding for its own page.
484
531
  */
485
532
  branding: DeckBrandingSchema.optional(),
486
- pages: z3.array(PageSpecSchema)
533
+ pages: z.array(PageSpecSchema)
487
534
  }).strict();
488
535
  function specJsonSchema() {
489
- return z3.toJSONSchema(DeckSpecSchema);
536
+ return z.toJSONSchema(DeckSpecSchema);
490
537
  }
491
538
  function formatSpecIssues(errors) {
492
539
  return errors.map((e) => e.pageId ? `page "${e.pageId}" \u2014 ${e.path}: ${e.message}` : `${e.path}: ${e.message}`).join("\n");
@@ -496,7 +543,7 @@ function formatInvalidSpecError(errors) {
496
543
  ${formatSpecIssues(errors)}`;
497
544
  }
498
545
  function resolveSpecThemeId(spec) {
499
- return spec.theme ?? "consulting";
546
+ return spec.theme;
500
547
  }
501
548
  function checkPagesNonEmpty(spec) {
502
549
  if (spec.pages.length > 0) return [];
@@ -592,12 +639,11 @@ function checkTheme(spec) {
592
639
  const themeId = resolveSpecThemeId(spec);
593
640
  const installed = getInstalledThemeIds();
594
641
  if (installed.includes(themeId)) return [];
595
- const message = themeId === "bloom" ? 'theme id "bloom" was removed \u2014 run `pptwise migrate <input> -o <output>` to rewrite it to "classroom"' : `unknown theme "${themeId}" \u2014 available: ${installed.join(", ")} (see \`pptwise themes\`)`;
642
+ const message = themeId === "bloom" ? 'theme id "bloom" was removed \u2014 current format uses an installed theme id (see `pptwise themes`)' : `unknown theme "${themeId}" \u2014 available: ${installed.join(", ")} (see \`pptwise themes\`)`;
596
643
  return [{ path: "theme", message }];
597
644
  }
598
- var LAYOUT_IDS = Object.keys(LAYOUT_REGISTRY);
599
- function checkFocusVocabulary(spec, strategy) {
600
- const tendencies = STRATEGY_DEFINITIONS[strategy].tendencies;
645
+ function checkFocusVocabulary(spec) {
646
+ const kinds = KIND_VALUES;
601
647
  const errors = [];
602
648
  spec.pages.forEach((page, i) => {
603
649
  if (page.focus === void 0) return;
@@ -605,7 +651,7 @@ function checkFocusVocabulary(spec, strategy) {
605
651
  errors.push({
606
652
  path: `pages.${i}.focus`,
607
653
  pageId: page.id,
608
- message: 'component type "logo_wall" was removed \u2014 run `pptwise migrate <input> -o <output>` to rewrite it to "image_grid"'
654
+ message: 'component type "logo_wall" is not in the current vocabulary \u2014 use "image_grid"'
609
655
  });
610
656
  return;
611
657
  }
@@ -613,99 +659,65 @@ function checkFocusVocabulary(spec, strategy) {
613
659
  errors.push({
614
660
  path: `pages.${i}.focus`,
615
661
  pageId: page.id,
616
- message: 'layout "banner-heading" was removed \u2014 run `pptwise migrate <input> -o <output>` to rewrite it to "two-column"'
662
+ message: '"banner-heading" is a face id, not authoring vocabulary \u2014 focus accepts a content kind or a component type'
617
663
  });
618
664
  return;
619
665
  }
620
- if (tendencies.includes(page.focus) || COMPONENT_TYPES.includes(page.focus) || LAYOUT_IDS.includes(page.focus)) {
666
+ if (kinds.includes(page.focus) || COMPONENT_TYPES.includes(page.focus)) {
621
667
  return;
622
668
  }
623
669
  errors.push({
624
670
  path: `pages.${i}.focus`,
625
671
  pageId: page.id,
626
- message: `unknown focus "${page.focus}" for strategy "${strategy}" \u2014 expected one of this strategy's tendencies (${tendencies.join(", ")}), a component type (${COMPONENT_TYPES.join(", ")}), or a layout id (${LAYOUT_IDS.join(", ")})`
672
+ message: `unknown focus "${page.focus}" \u2014 expected a content kind (${KIND_VALUES.join(", ")}) or a component type (${COMPONENT_TYPES.join(", ")})`
627
673
  });
628
674
  });
629
675
  return errors;
630
676
  }
631
- function declaredBeatContentPages(spec) {
632
- const result = [];
633
- spec.pages.forEach((page, index) => {
634
- if (page.type === "content" && page.beat !== void 0) {
635
- result.push({ index, id: page.id, beat: page.beat });
636
- }
677
+ function checkThemeMenuKinds(spec) {
678
+ const menu = getThemeDefinition(resolveSpecThemeId(spec)).menu;
679
+ if (menu === void 0) {
680
+ return [
681
+ {
682
+ path: "theme",
683
+ message: `theme "${resolveSpecThemeId(spec)}" has no menu`
684
+ }
685
+ ];
686
+ }
687
+ const offered = offeredContentKinds(menu);
688
+ return spec.pages.flatMap((page, index) => {
689
+ if (page.type !== "content" || resolveLayoutId("content", page.kind, menu) !== null) return [];
690
+ return [
691
+ {
692
+ path: `pages.${index}.kind`,
693
+ pageId: page.id,
694
+ message: `kind "${page.kind}" is not offered by theme "${resolveSpecThemeId(
695
+ spec
696
+ )}". Available content kinds: ${offered.join(", ")}`
697
+ }
698
+ ];
637
699
  });
638
- return result;
639
700
  }
640
- function checkAlternatePolicy(spec, strategy) {
641
- const seq = declaredBeatContentPages(spec);
642
- const errors = [];
643
- let i = 0;
644
- while (i < seq.length) {
645
- let j = i + 1;
646
- while (j < seq.length && seq[j].beat === seq[i].beat) j++;
647
- const runLength = j - i;
648
- if (runLength >= 3) {
649
- const members = seq.slice(i, j);
650
- errors.push({
701
+ function checkKindDistribution(spec) {
702
+ const contentPages = spec.pages.map((page, index) => ({ page, index })).filter(
703
+ (entry) => entry.page.type === "content"
704
+ );
705
+ const warnings = [];
706
+ let start = 0;
707
+ while (start < contentPages.length) {
708
+ let end = start + 1;
709
+ while (end < contentPages.length && contentPages[end].page.kind === contentPages[start].page.kind) end++;
710
+ if (end - start >= 3) {
711
+ const run = contentPages.slice(start, end);
712
+ warnings.push({
651
713
  path: "pages",
652
- pageId: members[0].id,
653
- message: `${runLength} consecutive content pages declare beat "${seq[i].beat}" (${members.map((m) => m.id).join(", ")}) \u2014 strategy "${strategy}" requires beat to alternate, vary at least one of them`
714
+ pageId: run[0].page.id,
715
+ message: `${run.length} consecutive content pages use kind "${run[0].page.kind}" (${run.map((entry) => entry.page.id).join(", ")}). Vary the explanation pattern where the argument allows it`
654
716
  });
655
717
  }
656
- i = j;
657
- }
658
- return errors;
659
- }
660
- function checkAnchorOpenPolicy(spec, strategy) {
661
- const firstContentIndex = spec.pages.findIndex((page) => page.type === "content");
662
- if (firstContentIndex === -1) return [];
663
- const firstContent = spec.pages[firstContentIndex];
664
- if (firstContent.beat === void 0 || firstContent.beat === "anchor") return [];
665
- return [
666
- {
667
- path: `pages.${firstContentIndex}.beat`,
668
- pageId: firstContent.id,
669
- message: `first content page declares beat "${firstContent.beat}" \u2014 strategy "${strategy}" requires the deck to open its first content page on "anchor" beat when a beat is declared`
670
- }
671
- ];
672
- }
673
- function checkAnchorSparsePolicy(spec, strategy) {
674
- const declared = declaredBeatContentPages(spec);
675
- if (declared.length === 0) return [];
676
- const anchorPages = declared.filter((page) => page.beat === "anchor");
677
- if (anchorPages.length / declared.length <= 0.5) return [];
678
- const pct = Math.round(anchorPages.length / declared.length * 100);
679
- return [
680
- {
681
- path: "pages",
682
- // First offending anchor page, same "representative pageId" shape
683
- // checkAlternatePolicy's own issue carries (members[0]!.id there) —
684
- // this gate's violation is deck-wide (a ratio, not one page), but a
685
- // representative id still gives a CLI/agent caller something to jump
686
- // to rather than only a bare "pages" path.
687
- pageId: anchorPages[0].id,
688
- message: `${anchorPages.length} of ${declared.length} content pages with a declared beat are "anchor" (${pct}%: ${anchorPages.map((page) => page.id).join(", ")}) \u2014 strategy "${strategy}" requires "anchor" to stay a minority of declared beats, vary some to "dense" or "breathing"`
689
- }
690
- ];
691
- }
692
- function checkBeatRotation(spec, strategy) {
693
- const policy = STRATEGY_DEFINITIONS[strategy].beatPolicy;
694
- switch (policy) {
695
- case "uniform-dense":
696
- case "repetition-ok":
697
- return [];
698
- case "alternate":
699
- return checkAlternatePolicy(spec, strategy);
700
- case "anchor-open":
701
- return checkAnchorOpenPolicy(spec, strategy);
702
- case "anchor-sparse":
703
- return checkAnchorSparsePolicy(spec, strategy);
704
- default: {
705
- const exhaustive = policy;
706
- throw new Error(`unhandled beat policy: ${String(exhaustive)}`);
707
- }
718
+ start = end;
708
719
  }
720
+ return warnings;
709
721
  }
710
722
  var SPEC_PAGE_COUNT_RANGE = {
711
723
  dense: { min: 8, max: 30 },
@@ -743,7 +755,11 @@ function validateSpec(input) {
743
755
  const errors = r.error.issues.map((issue) => {
744
756
  const path = issue.path.join(".");
745
757
  const m = /^pages\.(\d+)/.exec(path);
746
- return { path, message: issue.message, pageId: m ? pageIdFromRawInput(normalizedInput, Number(m[1])) : void 0 };
758
+ return {
759
+ path,
760
+ message: themeIssueMessage(path, issue.message, issue.input, issue.code),
761
+ pageId: m ? pageIdFromRawInput(normalizedInput, Number(m[1])) : void 0
762
+ };
747
763
  });
748
764
  return withNormalized({ ok: false, errors });
749
765
  }
@@ -758,6 +774,8 @@ function validateSpec(input) {
758
774
  if (headingErrors.length > 0) return withNormalized({ ok: false, errors: headingErrors });
759
775
  const themeErrors = checkTheme(spec);
760
776
  if (themeErrors.length > 0) return withNormalized({ ok: false, errors: themeErrors });
777
+ const menuErrors = checkThemeMenuKinds(spec);
778
+ if (menuErrors.length > 0) return withNormalized({ ok: false, errors: menuErrors });
761
779
  let resolvedAxes;
762
780
  try {
763
781
  resolvedAxes = resolveNarrative(spec.narrative);
@@ -765,30 +783,48 @@ function validateSpec(input) {
765
783
  if (!(err instanceof PptwiseError)) throw err;
766
784
  return withNormalized({ ok: false, errors: [{ path: "narrative", message: err.message }] });
767
785
  }
768
- const beatErrors = checkBeatRotation(spec, resolvedAxes.strategy);
769
- if (beatErrors.length > 0) return withNormalized({ ok: false, errors: beatErrors });
770
- const focusErrors = checkFocusVocabulary(spec, resolvedAxes.strategy);
786
+ const focusErrors = checkFocusVocabulary(spec);
771
787
  if (focusErrors.length > 0) return withNormalized({ ok: false, errors: focusErrors });
772
788
  const pageCountErrors = checkPageCount(spec, resolvedAxes.pacing);
773
789
  if (pageCountErrors.length > 0) return withNormalized({ ok: false, errors: pageCountErrors });
774
- return withNormalized({ ok: true, spec, errors: [] });
790
+ const warnings = checkKindDistribution(spec);
791
+ return withNormalized({
792
+ ok: true,
793
+ spec,
794
+ errors: [],
795
+ ...warnings.length > 0 ? { warnings } : {}
796
+ });
775
797
  }
776
798
 
777
799
  // src/spec/assemble.ts
778
- function stableHash(s) {
779
- let h = 5381;
780
- for (let i = 0; i < s.length; i++) h = (h << 5) + h + s.charCodeAt(i) | 0;
781
- return Math.abs(h);
782
- }
783
- function generateSeed(filename, pageIds) {
784
- return stableHash([filename ?? "", ...pageIds].join("\n"));
800
+ var LOCKED_KEYS = ["type", "kind", "heading"];
801
+ function buildSlide(page, content) {
802
+ const locked = {
803
+ id: page.id,
804
+ type: page.type,
805
+ heading: page.heading,
806
+ ...page.type === "content" ? { kind: page.kind } : {}
807
+ };
808
+ if (content === void 0) {
809
+ return {
810
+ ...locked,
811
+ placeholder: true,
812
+ ...page.summary !== void 0 ? { subheading: page.summary } : {}
813
+ };
814
+ }
815
+ return {
816
+ ...locked,
817
+ ...page.type !== "content" && page.summary !== void 0 ? { subheading: page.summary } : {},
818
+ ...content.components !== void 0 ? { components: content.components } : {},
819
+ ...content.background !== void 0 ? { background: content.background } : {},
820
+ ...content.image_side !== void 0 ? { image_side: content.image_side } : {},
821
+ ...content.footnote !== void 0 ? { footnote: content.footnote } : {},
822
+ ...content.notes !== void 0 ? { notes: content.notes } : {}
823
+ };
785
824
  }
786
- var LOCKED_KEYS = ["type", "heading"];
787
825
  function assembleDeck(spec, pages) {
788
826
  const validated = validateSpec(spec);
789
- if (!validated.ok) {
790
- throw new PptwiseError(formatInvalidSpecError(validated.errors));
791
- }
827
+ if (!validated.ok) throw new PptwiseError(formatInvalidSpecError(validated.errors));
792
828
  const deckSpec = validated.spec;
793
829
  for (const page of deckSpec.pages) {
794
830
  const raw = pages[page.id];
@@ -798,7 +834,7 @@ function assembleDeck(spec, pages) {
798
834
  }
799
835
  for (const key of LOCKED_KEYS) {
800
836
  if (Object.hasOwn(raw, key)) {
801
- throw new PptwiseError(`page "${page.id}": "${key}" is locked by the spec \u2014 remove it from the page file`);
837
+ throw new PptwiseError(`page "${page.id}": "${key}" is locked by the spec. Remove it from the page file`);
802
838
  }
803
839
  }
804
840
  }
@@ -806,22 +842,18 @@ function assembleDeck(spec, pages) {
806
842
  const orphanIds = Object.keys(pages).filter((id) => !specIds.has(id));
807
843
  if (orphanIds.length > 0) {
808
844
  throw new PptwiseError(
809
- `orphan page id${orphanIds.length === 1 ? "" : "s"} ${orphanIds.map((id) => `"${id}"`).join(", ")} \u2014 not in the spec, delete the page file or add the page to the spec`
845
+ `orphan page id${orphanIds.length === 1 ? "" : "s"} ${orphanIds.map((id) => `"${id}"`).join(", ")}. Delete the page file or add the page to the spec`
810
846
  );
811
847
  }
812
- const slides = deckSpec.pages.map((page) => buildSlide(page, pages[page.id]));
813
- const generatedSeed = deckSpec.seed === void 0 ? generateSeed(deckSpec.filename, deckSpec.pages.map((page) => page.id)) : void 0;
814
- const seed = deckSpec.seed ?? generatedSeed;
815
848
  const rawIr = {
816
- version: "4",
849
+ version: "5",
817
850
  ...deckSpec.narrative !== void 0 ? { narrative: deckSpec.narrative } : {},
818
- ...deckSpec.theme !== void 0 ? { theme: { id: deckSpec.theme } } : {},
851
+ theme: { id: deckSpec.theme },
819
852
  ...deckSpec.filename !== void 0 ? { filename: deckSpec.filename } : {},
820
853
  ...deckSpec.brand !== void 0 ? { brand: deckSpec.brand } : {},
821
854
  ...deckSpec.branding !== void 0 ? { branding: deckSpec.branding } : {},
822
855
  meta: deckSpec.meta,
823
- seed,
824
- slides
856
+ slides: deckSpec.pages.map((page) => buildSlide(page, pages[page.id]))
825
857
  };
826
858
  const parsed = PptxIRSchema.safeParse(rawIr);
827
859
  if (!parsed.success) {
@@ -829,49 +861,7 @@ function assembleDeck(spec, pages) {
829
861
  throw new PptwiseError(`assembled deck did not produce valid IR:
830
862
  ${detail}`);
831
863
  }
832
- const { ir, materializedCount } = materializeEffectiveLayouts(parsed.data);
833
- return {
834
- ir,
835
- ...generatedSeed !== void 0 ? { generatedSeed } : {},
836
- ...materializedCount > 0 ? { materializedLayoutCount: materializedCount } : {}
837
- };
838
- }
839
- function materializeEffectiveLayouts(ir) {
840
- let materializedCount = 0;
841
- const slides = ir.slides.map((slide, index) => {
842
- if (slide.layout !== void 0) return slide;
843
- const effectiveLayoutId = resolveEffectiveLayoutId(ir, slide, index);
844
- if (effectiveLayoutId === null) return slide;
845
- materializedCount++;
846
- return { ...slide, layout: effectiveLayoutId };
847
- });
848
- return materializedCount === 0 ? { ir, materializedCount } : { ir: { ...ir, slides }, materializedCount };
849
- }
850
- function buildSlide(page, raw) {
851
- if (raw === void 0) {
852
- return {
853
- id: page.id,
854
- type: page.type,
855
- heading: page.heading,
856
- placeholder: true,
857
- ...page.beat !== void 0 ? { beat: page.beat } : {},
858
- ...page.summary !== void 0 ? { subheading: page.summary } : {}
859
- };
860
- }
861
- return {
862
- id: page.id,
863
- type: page.type,
864
- heading: page.heading,
865
- ...page.beat !== void 0 ? { beat: page.beat } : {},
866
- ...page.type !== "content" && page.summary !== void 0 ? { subheading: page.summary } : {},
867
- ...raw.components !== void 0 ? { components: raw.components } : {},
868
- ...raw.layout !== void 0 ? { layout: raw.layout } : {},
869
- ...raw.arrangement !== void 0 ? { arrangement: raw.arrangement } : {},
870
- ...raw.background !== void 0 ? { background: raw.background } : {},
871
- ...raw.image_side !== void 0 ? { image_side: raw.image_side } : {},
872
- ...raw.footnote !== void 0 ? { footnote: raw.footnote } : {},
873
- ...raw.notes !== void 0 ? { notes: raw.notes } : {}
874
- };
864
+ return { ir: parsed.data };
875
865
  }
876
866
  var UNTITLED_HEADING = "Untitled";
877
867
  function disassembleDeck(ir) {
@@ -883,33 +873,29 @@ function disassembleDeck(ir) {
883
873
  id,
884
874
  type: slide.type,
885
875
  heading,
886
- ...slide.beat !== void 0 ? { beat: slide.beat } : {},
876
+ ...slide.type === "content" ? { kind: slide.kind } : {},
887
877
  ...(slide.placeholder === true || slide.type !== "content") && slide.subheading !== void 0 ? { summary: slide.subheading } : {}
888
878
  };
889
879
  if (slide.placeholder !== true) pages[id] = extractPageContent(slide);
890
880
  return pageSpec;
891
881
  });
892
- const spec = {
893
- version: "1",
894
- // `ir.narrative` (v4 field, vocabulary-v4 rename) carries straight into
895
- // the deck spec's own `narrative` field its value is
896
- // already in the new strategy/pacing vocabulary, no remapping needed.
897
- ...ir.narrative !== void 0 ? { narrative: ir.narrative } : {},
898
- theme: ir.theme.id,
899
- filename: ir.filename,
900
- ...ir.seed !== void 0 ? { seed: ir.seed } : {},
901
- ...ir.brand !== void 0 ? { brand: ir.brand } : {},
902
- ...ir.branding !== void 0 ? { branding: ir.branding } : {},
903
- meta: ir.meta,
904
- pages: pageSpecs
882
+ return {
883
+ spec: {
884
+ version: "1",
885
+ ...ir.narrative !== void 0 ? { narrative: ir.narrative } : {},
886
+ theme: ir.theme.id,
887
+ filename: ir.filename,
888
+ ...ir.brand !== void 0 ? { brand: ir.brand } : {},
889
+ ...ir.branding !== void 0 ? { branding: ir.branding } : {},
890
+ meta: ir.meta,
891
+ pages: pageSpecs
892
+ },
893
+ pages
905
894
  };
906
- return { spec, pages };
907
895
  }
908
896
  function extractPageContent(slide) {
909
897
  const content = {};
910
898
  if (slide.components.length > 0) content.components = slide.components;
911
- if (slide.layout !== void 0) content.layout = slide.layout;
912
- if (slide.arrangement !== void 0) content.arrangement = slide.arrangement;
913
899
  if (slide.background !== void 0) content.background = slide.background;
914
900
  if (slide.image_side !== void 0) content.image_side = slide.image_side;
915
901
  if (slide.footnote !== void 0) content.footnote = slide.footnote;
@@ -917,30 +903,7 @@ function extractPageContent(slide) {
917
903
  return content;
918
904
  }
919
905
 
920
- // src/spec/migrate.ts
921
- function migrateDeckPlanToSpec(raw) {
922
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return raw;
923
- const { scenario, pages, ...rest } = raw;
924
- const result = { ...rest };
925
- if (scenario !== void 0) result.narrative = scenario;
926
- if (Array.isArray(pages)) {
927
- result.pages = pages.map(migratePageRhythmToBeat);
928
- } else if (pages !== void 0) {
929
- result.pages = pages;
930
- }
931
- return migrateBannerHeadingToTwoColumn(
932
- migrateLogoWallToImageGrid(migrateBloomToClassroom(migrateChromeToBranding(result)))
933
- );
934
- }
935
- function migratePageRhythmToBeat(page) {
936
- if (typeof page !== "object" || page === null || Array.isArray(page)) return page;
937
- const { rhythm, ...rest } = page;
938
- const next = { ...rest };
939
- if (rhythm !== void 0) next.beat = rhythm;
940
- return next;
941
- }
942
-
943
- // src/svg/asset-brief.ts
906
+ // src/render/asset-brief.ts
944
907
  var DUMMY_PNG_DATA_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
945
908
  function extractImageFrames(markup) {
946
909
  const Parser = getPlatform().domParser ?? globalThis.DOMParser;
@@ -1039,7 +1002,7 @@ function buildPrompt(mood, palette, frame, fit) {
1039
1002
  }
1040
1003
  function buildAssetBrief(ir) {
1041
1004
  const themeDef = getThemeDefinition(ir.theme.id);
1042
- const tokens = resolveStyle(ir.theme.id, ir.theme.style);
1005
+ const tokens = resolveStyle(ir.theme.id);
1043
1006
  const palette = buildPalette(tokens.colors);
1044
1007
  const mood = buildMood(ir.theme.id, themeDef);
1045
1008
  const occurrences = [];
@@ -1140,17 +1103,16 @@ function buildAssetBrief(ir) {
1140
1103
 
1141
1104
  export {
1142
1105
  VERSION,
1143
- PptxIRV3Schema,
1144
- migrateChromeToBranding,
1145
- migrateBloomToClassroom,
1146
- migrateLogoWallToImageGrid,
1147
- migrateBannerHeadingToTwoColumn,
1148
- migrateIrV3ToV4,
1106
+ THEME_PRESETS,
1107
+ isThemePresetId,
1108
+ getThemePreset,
1109
+ copyThemePreset,
1149
1110
  slugify,
1150
1111
  extractBrandTheme,
1151
1112
  BrandThemeFileSchema,
1152
1113
  parseBrandThemeFile,
1153
1114
  registerBrandThemeFile,
1115
+ forkTheme,
1154
1116
  PageSpecSchema,
1155
1117
  DeckSpecSchema,
1156
1118
  specJsonSchema,
@@ -1161,7 +1123,6 @@ export {
1161
1123
  validateSpec,
1162
1124
  assembleDeck,
1163
1125
  disassembleDeck,
1164
- migrateDeckPlanToSpec,
1165
1126
  buildAssetBrief
1166
1127
  };
1167
- //# sourceMappingURL=chunk-M35M4QUC.js.map
1128
+ //# sourceMappingURL=chunk-2CA3JRFR.js.map