@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
package/dist/cli.js CHANGED
@@ -1,66 +1,63 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- PptxIRV3Schema,
4
3
  VERSION,
5
4
  assembleDeck,
6
5
  buildAssetBrief,
6
+ copyThemePreset,
7
7
  disassembleDeck,
8
8
  extractBrandTheme,
9
+ forkTheme,
9
10
  formatInvalidSpecError,
10
- migrateBannerHeadingToTwoColumn,
11
- migrateBloomToClassroom,
12
- migrateChromeToBranding,
13
- migrateDeckPlanToSpec,
14
- migrateIrV3ToV4,
15
- migrateLogoWallToImageGrid,
16
11
  parseBrandThemeFile,
17
- registerBrandThemeFile,
18
12
  resolveSpecThemeId,
19
13
  slugify,
20
14
  specJsonSchema,
21
15
  validateSpec
22
- } from "./chunk-M35M4QUC.js";
16
+ } from "./chunk-2CA3JRFR.js";
23
17
  import {
24
18
  installNodePlatform,
25
19
  isMissingModuleError,
26
20
  proxyFetch
27
- } from "./chunk-3ZUKISTY.js";
21
+ } from "./chunk-VEZGVYJS.js";
28
22
  import {
29
23
  AUDIENCE_VALUES,
30
24
  CANONICAL_THEME_IDS,
31
25
  CANVAS_H_PX,
32
26
  CANVAS_W_PX,
33
27
  FORMAT_BY_MIME,
28
+ LAYOUT_REGISTRY,
34
29
  MIME_BY_SNIFFED_FORMAT,
35
30
  NARRATIVE_PRESETS,
36
31
  PACING_BUDGETS,
37
32
  PptwiseError,
38
33
  STRATEGY_DEFINITIONS,
39
- StyleOverrideSchema,
40
- assertContrastFloor,
34
+ THEME_ID_CONSTRAINT,
35
+ THEME_ID_PATTERN,
36
+ THEME_OCCASIONS,
37
+ ThemeFileSchema,
41
38
  auditDeck,
42
39
  formatIssues,
43
40
  formatWarnings,
44
41
  generatePptx,
45
- getInstalledThemeIds,
42
+ getThemeDefinition,
43
+ installThemeFile,
46
44
  irJsonSchema,
47
45
  listThemes,
48
46
  renderSlideSvg,
49
47
  resolveNarrative,
50
48
  sniffImageFormat,
51
- styleJsonSchema,
52
49
  validateIr
53
- } from "./chunk-WL5KWYKS.js";
50
+ } from "./chunk-G7WQ7KSP.js";
54
51
  import {
55
52
  getPlatform
56
- } from "./chunk-VUOLBHD7.js";
53
+ } from "./chunk-RQUKZYSH.js";
57
54
 
58
55
  // src/cli.ts
59
56
  import { Command } from "commander";
60
57
 
61
58
  // src/cli/commands.ts
62
- import { mkdir as mkdir4, readdir as readdir3, readFile as readFile6, rm, writeFile as writeFile3 } from "fs/promises";
63
- import { basename as basename4, dirname as dirname3, join as join5, relative as relative2, resolve as resolve6 } from "path";
59
+ import { mkdir as mkdir5, readFile as readFile6, rm as rm2, writeFile as writeFile4 } from "fs/promises";
60
+ import { basename as basename5, dirname as dirname5, join as join7, relative as relative2, resolve as resolve7 } from "path";
64
61
 
65
62
  // src/cli/config.ts
66
63
  import { readFile as readFile2 } from "fs/promises";
@@ -119,8 +116,8 @@ function pptwiseHome(opts = {}) {
119
116
  }
120
117
  function migrateLegacyHomes(home, nextDir) {
121
118
  if (existsSync(nextDir)) return;
122
- for (const dirname5 of LEGACY_HOME_DIRNAMES) {
123
- const legacy = join(home, dirname5);
119
+ for (const dirname8 of LEGACY_HOME_DIRNAMES) {
120
+ const legacy = join(home, dirname8);
124
121
  if (existsSync(legacy)) {
125
122
  copyLegacyHome(legacy, nextDir);
126
123
  return;
@@ -448,14 +445,10 @@ function missingKeysError(kind) {
448
445
 
449
446
  // src/cli/config.ts
450
447
  var ConfigSchema = z2.object({
451
- theme: z2.string().optional(),
452
- style: StyleOverrideSchema.optional(),
453
448
  decksDir: z2.string().optional(),
454
449
  outDir: z2.string().optional()
455
450
  }).strict();
456
451
  var UserConfigSchema = z2.object({
457
- theme: z2.string().optional(),
458
- style: StyleOverrideSchema.optional(),
459
452
  decksDir: z2.string().optional(),
460
453
  images: ImagesConfigSchema.optional()
461
454
  }).strict();
@@ -606,7 +599,6 @@ async function resolveLocalAssets(ir, baseDir, workspaceAssetsDir) {
606
599
  }
607
600
 
608
601
  // src/cli/deck-dir.ts
609
- var PLAN_FILENAME = "deck.plan.json";
610
602
  var SPEC_FILENAME = "deck.spec.json";
611
603
  var PAGES_DIRNAME = "pages";
612
604
  var ASSETS_DIRNAME = "assets";
@@ -657,18 +649,6 @@ function expectedLayoutHint() {
657
649
  }
658
650
  async function readSpecFile(dir) {
659
651
  const specPath = join3(dir, SPEC_FILENAME);
660
- const planPath = join3(dir, PLAN_FILENAME);
661
- const [specExists, planExists] = await Promise.all([pathExists(specPath), pathExists(planPath)]);
662
- if (specExists && planExists) {
663
- throw new PptwiseError(
664
- `both ${SPEC_FILENAME} and ${PLAN_FILENAME} exist in ${dir} \u2014 ambiguous, refusing to guess which one wins. Delete ${PLAN_FILENAME} once you have confirmed ${SPEC_FILENAME} is correct (\`pptwise migrate\` never deletes the source file it read)`
665
- );
666
- }
667
- if (!specExists && planExists) {
668
- throw new PptwiseError(
669
- `${dir} has ${PLAN_FILENAME} but no ${SPEC_FILENAME} \u2014 deck project directories now use ${SPEC_FILENAME}. Run \`pptwise migrate ${dir} -o ${dir}\` to convert it`
670
- );
671
- }
672
652
  let text;
673
653
  try {
674
654
  text = await readFile4(specPath, "utf8");
@@ -729,14 +709,26 @@ async function scanAssets(dir) {
729
709
  }
730
710
  return images2;
731
711
  }
712
+ function specThemeFromRaw(spec2) {
713
+ if (typeof spec2 !== "object" || spec2 === null) return void 0;
714
+ const theme2 = spec2.theme;
715
+ return typeof theme2 === "string" ? theme2 : void 0;
716
+ }
732
717
  async function readDeckDir(dir) {
733
718
  const deckDir = resolve4(dir);
719
+ const specPath = join3(deckDir, SPEC_FILENAME);
734
720
  const spec2 = await readSpecFile(deckDir);
721
+ const specTheme = specThemeFromRaw(spec2);
735
722
  const pages = await readPages(deckDir);
736
- const { ir, generatedSeed, materializedLayoutCount } = assembleDeck(spec2, pages);
723
+ const { ir } = assembleDeck(spec2, pages);
737
724
  const images2 = await scanAssets(deckDir);
738
725
  const merged = { ...ir, assets: { images: { ...ir.assets.images, ...images2 } } };
739
- return { ir: merged, generatedSeed, materializedLayoutCount, deckDir };
726
+ return {
727
+ ir: merged,
728
+ deckDir,
729
+ specPath,
730
+ ...specTheme !== void 0 ? { specTheme } : {}
731
+ };
740
732
  }
741
733
  async function writeDeckAssets(images2, outDir, sourceBaseDir) {
742
734
  const entries = Object.entries(images2);
@@ -778,6 +770,36 @@ async function writeOneAsset(id, src, assetsDir, sourceBaseDir) {
778
770
  }
779
771
  }
780
772
 
773
+ // src/cli/theme-write.ts
774
+ import { link, mkdir as mkdir3, rename, rm, writeFile as writeFile3 } from "fs/promises";
775
+ import { basename as basename3, dirname as dirname2, join as join4 } from "path";
776
+ import { randomBytes } from "crypto";
777
+ async function writeThemeFile(path2, file, force = false, hooks) {
778
+ const dir = dirname2(path2);
779
+ await mkdir3(dir, { recursive: true });
780
+ const tmp = join4(dir, `.${basename3(path2)}.${randomBytes(8).toString("hex")}.tmp`);
781
+ try {
782
+ await writeFile3(tmp, JSON.stringify(file, null, 2) + "\n");
783
+ await hooks?.beforePublish?.(tmp, path2);
784
+ if (force) {
785
+ await rename(tmp, path2);
786
+ return;
787
+ }
788
+ try {
789
+ await link(tmp, path2);
790
+ } catch (error) {
791
+ if (error.code === "EEXIST") {
792
+ throw new PptwiseError(`theme file already exists: ${path2}. Pass --force to overwrite.`);
793
+ }
794
+ throw error;
795
+ }
796
+ await rm(tmp, { force: true });
797
+ } catch (error) {
798
+ await rm(tmp, { force: true });
799
+ throw error;
800
+ }
801
+ }
802
+
781
803
  // src/lib/slide-edge.ts
782
804
  var TAG = /<(\/?)(g|rect)\b([^>]*?)(\/?)>/g;
783
805
  var ATTR_CACHE = /* @__PURE__ */ new Map();
@@ -1195,6 +1217,61 @@ ${findingsDataScript}
1195
1217
  </html>
1196
1218
  `;
1197
1219
  }
1220
+ var CONTACT_SHEET_CSS = `
1221
+ :root { color-scheme: light; }
1222
+ * { box-sizing: border-box; }
1223
+ html, body { margin: 0; padding: 0; }
1224
+ body {
1225
+ font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
1226
+ color: #111;
1227
+ background: #f4f4f1;
1228
+ padding: 24px;
1229
+ }
1230
+ h1 { font-size: 18px; font-weight: 600; margin: 0 0 16px; }
1231
+ table.cs { width: 100%; border-collapse: collapse; table-layout: fixed; }
1232
+ table.cs th, table.cs td { vertical-align: top; padding: 8px; }
1233
+ table.cs thead th { text-align: left; font-size: 13px; font-weight: 600; }
1234
+ table.cs tbody th { text-align: left; font-weight: 500; color: #555; width: 88px; }
1235
+ .cs-cell svg { display: block; width: 100%; height: auto; background: #fff; }
1236
+ `.trim();
1237
+ function buildContactSheetHtml(input) {
1238
+ const { title, themes } = input;
1239
+ const rowLabels = [];
1240
+ for (const col of themes) {
1241
+ for (const slide of col.slides) {
1242
+ const label = slide.label ?? slide.type;
1243
+ if (!rowLabels.includes(label)) rowLabels.push(label);
1244
+ }
1245
+ }
1246
+ const head = themes.map((t) => `<th scope="col">${escapeHtml(t.id)}</th>`).join("");
1247
+ const body = rowLabels.map((label) => {
1248
+ const cells = themes.map((t, colIdx) => {
1249
+ const slide = t.slides.find((s) => (s.label ?? s.type) === label);
1250
+ if (!slide) return "<td></td>";
1251
+ const svg = namespaceSvgIds(slide.svg, `t${colIdx}-${label}-`);
1252
+ return `<td class="cs-cell">${svg}</td>`;
1253
+ }).join("");
1254
+ return `<tr><th scope="row">${escapeHtml(label)}</th>${cells}</tr>`;
1255
+ }).join("");
1256
+ const escapedTitle = escapeHtml(title);
1257
+ return `<!doctype html>
1258
+ <html lang="en">
1259
+ <head>
1260
+ <meta charset="utf-8">
1261
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1262
+ <title>${escapedTitle} \u2014 theme comparison</title>
1263
+ <style>${CONTACT_SHEET_CSS}</style>
1264
+ </head>
1265
+ <body>
1266
+ <h1>${escapedTitle}</h1>
1267
+ <table class="cs">
1268
+ <thead><tr><th></th>${head}</tr></thead>
1269
+ <tbody>${body}</tbody>
1270
+ </table>
1271
+ </body>
1272
+ </html>
1273
+ `;
1274
+ }
1198
1275
 
1199
1276
  // src/cli/preview-manifest.ts
1200
1277
  var PREVIEW_MANIFEST_VERSION = 1;
@@ -1261,8 +1338,8 @@ function buildPreviewManifest(input) {
1261
1338
 
1262
1339
  // src/cli/workspace.ts
1263
1340
  import { existsSync as existsSync2 } from "fs";
1264
- import { appendFile, mkdir as mkdir3, readFile as readFile5, readdir as readdir2, stat as stat2, unlink } from "fs/promises";
1265
- import { basename as basename3, dirname as dirname2, extname as extname3, join as join4, resolve as resolve5 } from "path";
1341
+ import { appendFile, mkdir as mkdir4, readFile as readFile5, readdir as readdir2, stat as stat2, unlink } from "fs/promises";
1342
+ import { basename as basename4, dirname as dirname3, extname as extname3, join as join5, resolve as resolve5 } from "path";
1266
1343
 
1267
1344
  // src/cli/child.ts
1268
1345
  import {
@@ -1288,14 +1365,14 @@ function envValue(env, name) {
1288
1365
  }
1289
1366
  async function findOnPath(bin, env, deps = {}) {
1290
1367
  const platform = deps.platform ?? process.platform;
1291
- const join10 = deps.join ?? defaultJoin;
1368
+ const join12 = deps.join ?? defaultJoin;
1292
1369
  const delimiter = deps.delimiter ?? defaultDelimiter;
1293
1370
  const tryAccess = deps.access ?? ((target, mode) => fsAccess(target, mode));
1294
1371
  const dirs = (envValue(env, "PATH") ?? "").split(delimiter).filter(Boolean);
1295
1372
  const suffixes = platform === "win32" ? [...(envValue(env, "PATHEXT") ?? DEFAULT_PATHEXT).split(";").map((ext) => ext.trim()).filter(Boolean), ""] : [""];
1296
1373
  for (const dir of dirs) {
1297
1374
  for (const suffix of suffixes) {
1298
- const full = join10(dir, `${bin}${suffix}`);
1375
+ const full = join12(dir, `${bin}${suffix}`);
1299
1376
  try {
1300
1377
  await tryAccess(full, constants.X_OK);
1301
1378
  return full;
@@ -1567,11 +1644,11 @@ var ChildTimeoutError = class extends Error {
1567
1644
  async function runChild(command, args = [], options = {}) {
1568
1645
  const { timeoutMs, signal, ...spawnOptions } = options;
1569
1646
  const cwd = typeof spawnOptions.cwd === "string" ? spawnOptions.cwd : void 0;
1570
- const plan2 = await resolveSpawnPlan(command, args, spawnOptions.env ?? process.env, cwd);
1571
- return new Promise((resolve8, reject) => {
1572
- const child = spawnHidden(plan2.command, plan2.args, {
1647
+ const plan = await resolveSpawnPlan(command, args, spawnOptions.env ?? process.env, cwd);
1648
+ return new Promise((resolve9, reject) => {
1649
+ const child = spawnHidden(plan.command, plan.args, {
1573
1650
  ...spawnOptions,
1574
- env: plan2.env ?? spawnOptions.env,
1651
+ env: plan.env ?? spawnOptions.env,
1575
1652
  stdio: ["ignore", "pipe", "pipe"]
1576
1653
  });
1577
1654
  let stdout = "";
@@ -1595,7 +1672,7 @@ async function runChild(command, args = [], options = {}) {
1595
1672
  reject(new ChildTimeoutError(timeoutMs ?? 0));
1596
1673
  return;
1597
1674
  }
1598
- resolve8({ code: code ?? 0, stdout, stderr });
1675
+ resolve9({ code: code ?? 0, stdout, stderr });
1599
1676
  };
1600
1677
  const restartDrain = () => {
1601
1678
  if (!exited || settled) return;
@@ -1644,33 +1721,33 @@ var WORKSPACE_DIRNAME = ".pptwise";
1644
1721
  var LEGACY_WORKSPACE_DIRNAMES = [".pptpress", ".pptfast"];
1645
1722
  var WORKSPACE_IGNORE_ENTRY = `${WORKSPACE_DIRNAME}/`;
1646
1723
  function defaultWorkspaceDirname(anchor) {
1647
- if (existsSync2(join4(anchor, WORKSPACE_DIRNAME))) return WORKSPACE_DIRNAME;
1724
+ if (existsSync2(join5(anchor, WORKSPACE_DIRNAME))) return WORKSPACE_DIRNAME;
1648
1725
  for (const name of LEGACY_WORKSPACE_DIRNAMES) {
1649
- if (existsSync2(join4(anchor, name))) return name;
1726
+ if (existsSync2(join5(anchor, name))) return name;
1650
1727
  }
1651
1728
  return WORKSPACE_DIRNAME;
1652
1729
  }
1653
- function ignoreEntryFor(dirname5) {
1654
- return `${dirname5}/`;
1730
+ function ignoreEntryFor(dirname8) {
1731
+ return `${dirname8}/`;
1655
1732
  }
1656
1733
  var RENDERED_SVG_PATTERN = /^\d{3}-[a-z-]+\.svg$/;
1657
1734
  function deckSlug(target, isDir) {
1658
- const base = basename3(target);
1735
+ const base = basename4(target);
1659
1736
  const name = isDir ? base : base.slice(0, base.length - extname3(base).length);
1660
1737
  const slug = slugify(name, "deck");
1661
1738
  assertSafeFileSegment(slug, "deck slug");
1662
1739
  return slug;
1663
1740
  }
1664
1741
  function resolveWorkspaceRoot(opts) {
1665
- const anchor = opts.projectConfigPath ? dirname2(resolve5(opts.projectConfigPath)) : resolve5(opts.cwd);
1742
+ const anchor = opts.projectConfigPath ? dirname3(resolve5(opts.projectConfigPath)) : resolve5(opts.cwd);
1666
1743
  const configured = opts.outDir !== void 0;
1667
- const root = opts.outDir !== void 0 ? resolve5(anchor, opts.outDir) : join4(anchor, defaultWorkspaceDirname(anchor));
1744
+ const root = opts.outDir !== void 0 ? resolve5(anchor, opts.outDir) : join5(anchor, defaultWorkspaceDirname(anchor));
1668
1745
  return { anchor, root, configured };
1669
1746
  }
1670
1747
  function resolveWorkspaceLocation(opts) {
1671
1748
  const { anchor, root, configured } = resolveWorkspaceRoot(opts);
1672
1749
  const slug = deckSlug(opts.target, opts.isDir);
1673
- return { anchor, root, dir: join4(root, slug), slug, configured };
1750
+ return { anchor, root, dir: join5(root, slug), slug, configured };
1674
1751
  }
1675
1752
  var runGitDefault = async (args, cwd) => {
1676
1753
  try {
@@ -1692,7 +1769,7 @@ async function gitIgnoreStatus(dir, entry, runGit = runGitDefault) {
1692
1769
  async function inspectWorkspace(opts) {
1693
1770
  const { anchor, root, configured } = resolveWorkspaceRoot(opts);
1694
1771
  if (configured) return { anchor, root, configured, ignore: "skipped" };
1695
- const ignore = await gitIgnoreStatus(anchor, ignoreEntryFor(basename3(root)), opts.runGit);
1772
+ const ignore = await gitIgnoreStatus(anchor, ignoreEntryFor(basename4(root)), opts.runGit);
1696
1773
  return { anchor, root, configured, ignore };
1697
1774
  }
1698
1775
  async function ensureGitIgnored(dir, entry, runGit = runGitDefault) {
@@ -1701,9 +1778,9 @@ async function ensureGitIgnored(dir, entry, runGit = runGitDefault) {
1701
1778
  if (status === "not-a-repo") return { kind: "no-repo" };
1702
1779
  const common = await runGit(["rev-parse", "--git-common-dir"], dir);
1703
1780
  if (common === null || common.code !== 0 || common.stdout.trim() === "") return { kind: "no-repo" };
1704
- const excludePath = join4(resolve5(dir, common.stdout.trim()), "info", "exclude");
1781
+ const excludePath = join5(resolve5(dir, common.stdout.trim()), "info", "exclude");
1705
1782
  try {
1706
- await mkdir3(dirname2(excludePath), { recursive: true });
1783
+ await mkdir4(dirname3(excludePath), { recursive: true });
1707
1784
  let existing = "";
1708
1785
  try {
1709
1786
  existing = await readFile5(excludePath, "utf8");
@@ -1729,7 +1806,7 @@ async function exists(path2) {
1729
1806
  async function prepareWorkspaceDir(location, opts = {}) {
1730
1807
  const rootExisted = await exists(location.root);
1731
1808
  try {
1732
- await mkdir3(location.dir, { recursive: true });
1809
+ await mkdir4(location.dir, { recursive: true });
1733
1810
  } catch (e) {
1734
1811
  throw new PptwiseError(
1735
1812
  `cannot create the output directory ${location.dir}: ${e.message}
@@ -1737,7 +1814,7 @@ async function prepareWorkspaceDir(location, opts = {}) {
1737
1814
  );
1738
1815
  }
1739
1816
  if (rootExisted || location.configured || opts.gitIgnore === false) return [];
1740
- const ignoreEntry = ignoreEntryFor(basename3(location.root));
1817
+ const ignoreEntry = ignoreEntryFor(basename4(location.root));
1741
1818
  const outcome = await ensureGitIgnored(location.anchor, ignoreEntry, opts.runGit);
1742
1819
  if (outcome.kind === "appended") {
1743
1820
  return [
@@ -1759,7 +1836,7 @@ async function pruneRenderedSvgs(dir) {
1759
1836
  return 0;
1760
1837
  }
1761
1838
  const stale = entries.filter((name) => RENDERED_SVG_PATTERN.test(name));
1762
- await Promise.all(stale.map((name) => unlink(join4(dir, name))));
1839
+ await Promise.all(stale.map((name) => unlink(join5(dir, name))));
1763
1840
  return stale.length;
1764
1841
  }
1765
1842
  var WORKSPACE_IMAGE_EXTS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
@@ -1776,7 +1853,7 @@ async function scanWorkspaceAssets(assetsDir) {
1776
1853
  for (const name of names) {
1777
1854
  const ext = extname3(name).toLowerCase();
1778
1855
  if (ext === ".json" || !WORKSPACE_IMAGE_EXTS.has(ext)) continue;
1779
- const id = basename3(name, extname3(name));
1856
+ const id = basename4(name, extname3(name));
1780
1857
  const previous = sourceFile.get(id);
1781
1858
  if (previous !== void 0) {
1782
1859
  throw new PptwiseError(
@@ -1784,63 +1861,336 @@ async function scanWorkspaceAssets(assetsDir) {
1784
1861
  );
1785
1862
  }
1786
1863
  sourceFile.set(id, name);
1787
- images2[id] = { src: join4(assetsDir, name) };
1864
+ images2[id] = { src: join5(assetsDir, name) };
1788
1865
  }
1789
1866
  return images2;
1790
1867
  }
1791
1868
 
1792
- // src/cli/commands.ts
1793
- async function loadStyleFile(path2) {
1794
- const raw = await loadIrFile(path2);
1795
- const r = StyleOverrideSchema.safeParse(raw);
1796
- if (!r.success) {
1797
- const detail = r.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
1798
- throw new PptwiseError(`invalid style file ${path2}:
1799
- ${detail}`);
1869
+ // src/cli/theme-resolve.ts
1870
+ import { dirname as dirname4, join as join6, resolve as resolve6 } from "path";
1871
+ var WORKSPACE_THEMES_DIRNAME = "themes";
1872
+ function sortKeysDeep(value) {
1873
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
1874
+ if (value !== null && typeof value === "object") {
1875
+ const sorted = {};
1876
+ for (const key of Object.keys(value).sort()) {
1877
+ sorted[key] = sortKeysDeep(value[key]);
1878
+ }
1879
+ return sorted;
1800
1880
  }
1801
- return r.data;
1881
+ return value;
1802
1882
  }
1803
- async function loadThemeFile(path2) {
1883
+ function menusEqual(a, b) {
1884
+ return JSON.stringify(sortKeysDeep(a)) === JSON.stringify(sortKeysDeep(b));
1885
+ }
1886
+ function assertThemeId(id) {
1887
+ if (!THEME_ID_PATTERN.test(id)) {
1888
+ throw new PptwiseError(`invalid theme id "${id}". ${THEME_ID_CONSTRAINT}`);
1889
+ }
1890
+ }
1891
+ async function readThemeFile(path2) {
1804
1892
  const raw = await loadIrFile(path2, "theme");
1805
- return registerBrandThemeFile(parseBrandThemeFile(raw, path2));
1893
+ return parseBrandThemeFile(raw, path2);
1894
+ }
1895
+ function isCanonicalThemeId(name) {
1896
+ return CANONICAL_THEME_IDS.includes(name);
1897
+ }
1898
+ function menuForThemeId(id) {
1899
+ const def = getThemeDefinition(id);
1900
+ return def.menu;
1806
1901
  }
1807
- async function registerDeckThemeFile(deckDir) {
1808
- const themePath = join5(deckDir, THEME_FILENAME);
1809
- if (await pathExists(themePath)) await loadThemeFile(themePath);
1902
+ function publicStyle(style, id) {
1903
+ const colors = { ...style.colors };
1904
+ const fonts = {
1905
+ heading: [...style.fonts.heading],
1906
+ body: [...style.fonts.body]
1907
+ };
1908
+ if (style.fonts.mono !== void 0) fonts.mono = [...style.fonts.mono];
1909
+ const out = {
1910
+ id,
1911
+ colors,
1912
+ fonts,
1913
+ defaultBackgrounds: structuredClone(style.defaultBackgrounds)
1914
+ };
1915
+ if (style.allowCustomBackground !== void 0) out.allowCustomBackground = style.allowCustomBackground;
1916
+ if (style.shape !== void 0) {
1917
+ const shape = {};
1918
+ if (style.shape.radius !== void 0) shape.radius = style.shape.radius;
1919
+ if (style.shape.gapScale !== void 0) shape.gapScale = style.shape.gapScale;
1920
+ if (style.shape.typeScale !== void 0) shape.typeScale = style.shape.typeScale;
1921
+ if (Object.keys(shape).length > 0) out.shape = shape;
1922
+ }
1923
+ return out;
1924
+ }
1925
+ function themeFileFromPreset(presetId, identity) {
1926
+ const copy = copyThemePreset(presetId, identity.id);
1927
+ const file = {
1928
+ version: 2,
1929
+ id: identity.id,
1930
+ label: identity.label ?? copy.label,
1931
+ style: publicStyle(copy.style, identity.id),
1932
+ brand: copy.brand,
1933
+ occasions: copy.occasions !== void 0 ? [...copy.occasions] : void 0,
1934
+ identity: copy.identity,
1935
+ menu: copy.menu
1936
+ };
1937
+ return ThemeFileSchema.parse(file);
1938
+ }
1939
+ async function tryParseThemeFile(path2) {
1940
+ const raw = await loadIrFile(path2, "theme");
1941
+ const parsed = ThemeFileSchema.safeParse(raw);
1942
+ return parsed.success ? parsed.data : void 0;
1943
+ }
1944
+ async function acceptThemeFile(path2, file) {
1945
+ installThemeFile(file);
1946
+ return { kind: "file", id: file.id, path: path2, file };
1947
+ }
1948
+ async function acceptIfNameMatches(path2, name, file) {
1949
+ if (file.id !== name) return void 0;
1950
+ return acceptThemeFile(path2, file);
1951
+ }
1952
+ async function resolveDeckThemeFile(deckDir, name) {
1953
+ const boundPath = join6(deckDir, THEME_FILENAME);
1954
+ if (await pathExists(boundPath)) {
1955
+ const file = await readThemeFile(boundPath);
1956
+ const hit = await acceptIfNameMatches(boundPath, name, file);
1957
+ if (hit !== void 0) return hit;
1958
+ }
1959
+ const namedTheme = join6(deckDir, `${name}.theme.json`);
1960
+ if (await pathExists(namedTheme)) {
1961
+ const file = await readThemeFile(namedTheme);
1962
+ const hit = await acceptIfNameMatches(namedTheme, name, file);
1963
+ if (hit !== void 0) return hit;
1964
+ }
1965
+ const namedJson = join6(deckDir, `${name}.json`);
1966
+ if (await pathExists(namedJson)) {
1967
+ const file = await tryParseThemeFile(namedJson);
1968
+ if (file !== void 0) {
1969
+ const hit = await acceptIfNameMatches(namedJson, name, file);
1970
+ if (hit !== void 0) return hit;
1971
+ }
1972
+ }
1973
+ return void 0;
1974
+ }
1975
+ async function resolveWorkspaceThemeFile(startDir, name) {
1976
+ let dir = resolve6(startDir);
1977
+ for (; ; ) {
1978
+ const candidates = [
1979
+ join6(dir, WORKSPACE_THEMES_DIRNAME, `${name}.theme.json`),
1980
+ join6(dir, WORKSPACE_THEMES_DIRNAME, `${name}.json`),
1981
+ join6(dir, WORKSPACE_THEMES_DIRNAME, name, THEME_FILENAME)
1982
+ ];
1983
+ for (const candidate of candidates) {
1984
+ if (!await pathExists(candidate)) continue;
1985
+ const isLooseJson = candidate.endsWith(`${name}.json`) && !candidate.endsWith(`${name}.theme.json`);
1986
+ if (isLooseJson) {
1987
+ const file2 = await tryParseThemeFile(candidate);
1988
+ if (file2 !== void 0) {
1989
+ const hit2 = await acceptIfNameMatches(candidate, name, file2);
1990
+ if (hit2 !== void 0) return hit2;
1991
+ }
1992
+ continue;
1993
+ }
1994
+ const file = await readThemeFile(candidate);
1995
+ const hit = await acceptIfNameMatches(candidate, name, file);
1996
+ if (hit !== void 0) return hit;
1997
+ }
1998
+ const parent = dirname4(dir);
1999
+ if (parent === dir) return void 0;
2000
+ dir = parent;
2001
+ }
1810
2002
  }
1811
- function describeThemeSource(opts, projectHit, userHit) {
1812
- if (opts.theme !== void 0) return "--theme";
1813
- if (projectHit?.config.theme !== void 0) return projectHit.path;
1814
- if (userHit?.config.theme !== void 0) return userHit.path;
1815
- return "the deck's own theme";
2003
+ async function resolveThemeByName(name, opts) {
2004
+ assertThemeId(name);
2005
+ if (opts.deckDir !== void 0) {
2006
+ const deckHit = await resolveDeckThemeFile(opts.deckDir, name);
2007
+ if (deckHit !== void 0) return deckHit;
2008
+ }
2009
+ const workspaceHit = await resolveWorkspaceThemeFile(opts.startDir, name);
2010
+ if (workspaceHit !== void 0) return workspaceHit;
2011
+ if (isCanonicalThemeId(name)) return { kind: "builtin", id: name };
2012
+ const places = [
2013
+ opts.deckDir !== void 0 ? `deck directory ${opts.deckDir}` : void 0,
2014
+ `workspace ${WORKSPACE_THEMES_DIRNAME}/ walking up from ${resolve6(opts.startDir)}`,
2015
+ "built-in presets"
2016
+ ].filter((place) => place !== void 0);
2017
+ throw new PptwiseError(`unknown theme "${name}". Searched ${places.join(", ")}.`);
2018
+ }
2019
+ var REBIND_SUFFIX = "A same-menu color fork is allowed. A different menu is a new theme. Start over from the theme layer (keep intent, narrative, and harvested materials, rewrite the spec).";
2020
+ async function assertThemeRebind(deckDir, resolved) {
2021
+ if (deckDir === void 0) return;
2022
+ const boundPath = resolve6(join6(deckDir, THEME_FILENAME));
2023
+ if (!await pathExists(boundPath)) return;
2024
+ if (resolved.kind === "file" && resolve6(resolved.path) === boundPath) return;
2025
+ const bound = parseBrandThemeFile(await loadIrFile(boundPath, "theme"), boundPath);
2026
+ const nextId = resolved.id;
2027
+ const nextMenu = resolved.kind === "file" ? resolved.file.menu : menuForThemeId(resolved.id);
2028
+ if (menusEqual(bound.menu, nextMenu)) return;
2029
+ throw new PptwiseError(`cannot rebind theme "${bound.id}" to "${nextId}": menus differ. ${REBIND_SUFFIX}`);
2030
+ }
2031
+ async function registerThemeSelection(name, opts) {
2032
+ if (name === void 0 || name.length === 0) return void 0;
2033
+ return resolveThemeByName(name, opts);
2034
+ }
2035
+ function themeNameFromUnknown(raw) {
2036
+ if (typeof raw !== "object" || raw === null) return void 0;
2037
+ const theme2 = raw.theme;
2038
+ return typeof theme2 === "string" ? theme2 : void 0;
2039
+ }
2040
+
2041
+ // src/cli/fixtures/theme-try-sample.ts
2042
+ var THEME_TRY_SAMPLE_IR = {
2043
+ version: "5",
2044
+ filename: "northstar-review",
2045
+ theme: { id: "consulting" },
2046
+ slides: [
2047
+ {
2048
+ type: "cover",
2049
+ heading: "Northstar Review",
2050
+ subheading: "Q3 operating report for the board"
2051
+ },
2052
+ {
2053
+ type: "chapter",
2054
+ heading: "The Quarter"
2055
+ },
2056
+ {
2057
+ type: "content",
2058
+ kind: "points",
2059
+ heading: "What we decided",
2060
+ components: [
2061
+ {
2062
+ type: "bullets",
2063
+ items: [
2064
+ "Hold hiring at 42",
2065
+ "Cut the analytics rebuild",
2066
+ "Share the support rotation"
2067
+ ]
2068
+ }
2069
+ ]
2070
+ },
2071
+ {
2072
+ type: "content",
2073
+ kind: "list",
2074
+ heading: "Open workstreams",
2075
+ components: [
2076
+ {
2077
+ type: "bullets",
2078
+ items: [
2079
+ "EU residency addendum",
2080
+ "Partner invite flow",
2081
+ "Invoice localization"
2082
+ ]
2083
+ }
2084
+ ]
2085
+ },
2086
+ {
2087
+ type: "content",
2088
+ kind: "comparison",
2089
+ heading: "Build vs buy",
2090
+ components: [
2091
+ {
2092
+ type: "comparison",
2093
+ columns: ["Build", "Buy"],
2094
+ rows: [
2095
+ { label: "Time to first report", cells: ["14 weeks", "3 weeks"] },
2096
+ { label: "Year-one cost", cells: ["$420k", "$180k"] },
2097
+ { label: "Lock-in", cells: ["None", "Vendor roadmap"] }
2098
+ ]
2099
+ }
2100
+ ]
2101
+ },
2102
+ {
2103
+ type: "content",
2104
+ kind: "process",
2105
+ heading: "How we ship",
2106
+ components: [
2107
+ {
2108
+ type: "steps",
2109
+ items: [
2110
+ { title: "Scope", text: "One page, one owner" },
2111
+ { title: "Build", text: "Two-week slices" },
2112
+ { title: "Prove", text: "A number in production" }
2113
+ ]
2114
+ }
2115
+ ]
2116
+ },
2117
+ {
2118
+ type: "content",
2119
+ kind: "data",
2120
+ heading: "Pipeline by stage",
2121
+ components: [
2122
+ {
2123
+ type: "chart",
2124
+ chart_type: "bar",
2125
+ series: [
2126
+ {
2127
+ name: "Qualified",
2128
+ data: [
2129
+ { x: "Discover", y: 42 },
2130
+ { x: "Propose", y: 19 },
2131
+ { x: "Close", y: 11 }
2132
+ ]
2133
+ }
2134
+ ]
2135
+ }
2136
+ ]
2137
+ },
2138
+ {
2139
+ type: "content",
2140
+ kind: "statement",
2141
+ heading: "We will not add a fourth product line this year",
2142
+ components: [
2143
+ {
2144
+ type: "blockquote",
2145
+ text: "Three products is the house."
2146
+ }
2147
+ ]
2148
+ },
2149
+ {
2150
+ type: "content",
2151
+ kind: "fact",
2152
+ heading: "NPS",
2153
+ components: [
2154
+ {
2155
+ type: "kpi_cards",
2156
+ items: [{ value: "61", label: "trailing 90-day NPS", delta: "up" }]
2157
+ }
2158
+ ]
2159
+ },
2160
+ {
2161
+ type: "ending",
2162
+ heading: "See you Thursday"
2163
+ }
2164
+ ]
2165
+ };
2166
+
2167
+ // src/cli/commands.ts
2168
+ async function registerThemesFromSpecSource(specPath, opts) {
2169
+ if (!await pathExists(specPath)) return;
2170
+ const specRaw = await loadIrFile(specPath, "spec");
2171
+ await registerThemeSelection(themeNameFromUnknown(specRaw), opts);
1816
2172
  }
1817
2173
  function resolveDecksDirSource(projectHit, userHit) {
1818
2174
  if (projectHit?.config.decksDir !== void 0) {
1819
- return { decksDir: resolve6(dirname3(projectHit.path), projectHit.config.decksDir) };
2175
+ return { decksDir: resolve7(dirname5(projectHit.path), projectHit.config.decksDir) };
1820
2176
  }
1821
2177
  return userHit?.config;
1822
2178
  }
2179
+ function deckLookupDir(resolvedTarget, isDir) {
2180
+ return isDir ? resolvedTarget : dirname5(resolvedTarget);
2181
+ }
1823
2182
  async function applyDeckConfig(raw, opts) {
1824
2183
  if (typeof raw !== "object" || raw === null) return;
1825
2184
  const deck = raw;
1826
2185
  const irTheme = typeof deck.theme === "object" && deck.theme !== null ? deck.theme : {};
1827
- const themeFileId = opts.themeFilePath !== void 0 ? await loadThemeFile(opts.themeFilePath) : void 0;
1828
- const [projectHit, userHit] = await Promise.all([
1829
- opts.projectHit !== void 0 ? Promise.resolve(opts.projectHit) : findConfig(opts.cwd),
1830
- opts.userHit !== void 0 ? Promise.resolve(opts.userHit) : findUserConfig()
1831
- ]);
1832
- const theme = opts.theme ?? themeFileId ?? projectHit?.config.theme ?? userHit?.config.theme ?? irTheme.id;
1833
- const style = opts.stylePath ? await loadStyleFile(opts.stylePath) : projectHit?.config.style ?? userHit?.config.style ?? irTheme.style;
1834
- if (theme !== void 0) {
1835
- const installedThemeIds = getInstalledThemeIds();
1836
- if (!installedThemeIds.includes(theme)) {
1837
- throw new PptwiseError(
1838
- `unknown theme "${theme}" (from ${describeThemeSource(opts, projectHit, userHit)}) \u2014 available: ${installedThemeIds.join(", ")} (see \`pptwise themes\`)`
1839
- );
1840
- }
1841
- }
1842
- if (theme === void 0 && style === void 0) return;
1843
- deck.theme = { ...irTheme, id: theme, ...style !== void 0 ? { style } : {} };
2186
+ const authoredName = opts.specTheme ?? (opts.fromDeckDir ? void 0 : typeof irTheme.id === "string" ? irTheme.id : void 0);
2187
+ if (authoredName === void 0) return;
2188
+ const resolved = await resolveThemeByName(authoredName, { startDir: opts.cwd, deckDir: opts.deckDir });
2189
+ await assertThemeRebind(opts.deckDir, resolved);
2190
+ deck.theme = {
2191
+ ...irTheme,
2192
+ id: resolved.id
2193
+ };
1844
2194
  }
1845
2195
  function mergeWorkspaceImages(raw, extras) {
1846
2196
  if (typeof raw !== "object" || raw === null) return raw;
@@ -1856,30 +2206,32 @@ async function loadWorkspaceStock(cwd, projectHit, resolvedTarget, isDir) {
1856
2206
  target: resolvedTarget,
1857
2207
  isDir
1858
2208
  });
1859
- const workspaceAssetsDir = join5(location.dir, ASSETS_DIRNAME);
2209
+ const workspaceAssetsDir = join7(location.dir, ASSETS_DIRNAME);
1860
2210
  const images2 = await scanWorkspaceAssets(workspaceAssetsDir);
1861
2211
  return { workspaceAssetsDir, images: images2 };
1862
2212
  }
1863
2213
  async function loadDeckTarget(arg, cwd, projectHit, userHit) {
1864
2214
  const target = await resolveDeckTarget(arg, resolveDecksDirSource(projectHit, userHit), cwd);
1865
2215
  if (await isDeckDirectory(target)) {
1866
- await registerDeckThemeFile(target);
1867
- const { ir, deckDir } = await readDeckDir(target);
2216
+ await registerThemesFromSpecSource(join7(target, SPEC_FILENAME), { startDir: cwd, deckDir: target });
2217
+ const { ir, deckDir, specTheme, specPath } = await readDeckDir(target);
1868
2218
  const stock2 = await loadWorkspaceStock(cwd, projectHit, deckDir, true);
1869
2219
  return {
1870
2220
  raw: mergeWorkspaceImages(ir, stock2.images),
1871
2221
  baseDir: deckDir,
1872
2222
  isDir: true,
1873
2223
  resolvedTarget: deckDir,
1874
- workspaceAssetsDir: stock2.workspaceAssetsDir
2224
+ workspaceAssetsDir: stock2.workspaceAssetsDir,
2225
+ specTheme,
2226
+ specPath
1875
2227
  };
1876
2228
  }
1877
2229
  const raw = await loadIrFile(target);
1878
- const resolvedFile = resolve6(target);
2230
+ const resolvedFile = resolve7(target);
1879
2231
  const stock = await loadWorkspaceStock(cwd, projectHit, resolvedFile, false);
1880
2232
  return {
1881
2233
  raw: mergeWorkspaceImages(raw, stock.images),
1882
- baseDir: dirname3(resolvedFile),
2234
+ baseDir: dirname5(resolvedFile),
1883
2235
  isDir: false,
1884
2236
  resolvedTarget: resolvedFile,
1885
2237
  workspaceAssetsDir: stock.workspaceAssetsDir
@@ -1887,8 +2239,16 @@ async function loadDeckTarget(arg, cwd, projectHit, userHit) {
1887
2239
  }
1888
2240
  async function loadValidatedDeckIr(target, cwd) {
1889
2241
  const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
1890
- const { raw, baseDir, workspaceAssetsDir } = await loadDeckTarget(target, cwd, projectHit, userHit);
1891
- await applyDeckConfig(raw, { cwd, projectHit, userHit });
2242
+ const { raw, baseDir, workspaceAssetsDir, isDir, resolvedTarget, specTheme, specPath } = await loadDeckTarget(target, cwd, projectHit, userHit);
2243
+ await applyDeckConfig(raw, {
2244
+ cwd,
2245
+ projectHit,
2246
+ userHit,
2247
+ specTheme,
2248
+ specPath,
2249
+ fromDeckDir: isDir,
2250
+ deckDir: deckLookupDir(resolvedTarget, isDir)
2251
+ });
1892
2252
  const v = validateIr(raw);
1893
2253
  if (!v.ok) {
1894
2254
  throw new PptwiseError(
@@ -1902,11 +2262,12 @@ ${formatIssues(v.errors)}`
1902
2262
  async function runRender(irPath, opts) {
1903
2263
  const cwd = opts.cwd ?? process.cwd();
1904
2264
  const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
1905
- const { raw, baseDir, isDir, resolvedTarget, workspaceAssetsDir } = await loadDeckTarget(irPath, cwd, projectHit, userHit);
2265
+ const { raw, baseDir, isDir, resolvedTarget, workspaceAssetsDir, specTheme, specPath } = await loadDeckTarget(irPath, cwd, projectHit, userHit);
1906
2266
  await applyDeckConfig(raw, {
1907
- theme: opts.theme,
1908
- themeFilePath: opts.themeFilePath,
1909
- stylePath: opts.stylePath,
2267
+ specTheme,
2268
+ specPath,
2269
+ fromDeckDir: isDir,
2270
+ deckDir: deckLookupDir(resolvedTarget, isDir),
1910
2271
  cwd,
1911
2272
  projectHit,
1912
2273
  userHit
@@ -1922,8 +2283,8 @@ ${formatIssues(v.errors)}`);
1922
2283
  const extraNotes = [];
1923
2284
  let output;
1924
2285
  if (opts.output !== void 0) {
1925
- output = resolve6(cwd, opts.output);
1926
- await mkdir4(dirname3(output), { recursive: true });
2286
+ output = resolve7(cwd, opts.output);
2287
+ await mkdir5(dirname5(output), { recursive: true });
1927
2288
  } else {
1928
2289
  const location = resolveWorkspaceLocation({
1929
2290
  cwd,
@@ -1933,9 +2294,9 @@ ${formatIssues(v.errors)}`);
1933
2294
  isDir
1934
2295
  });
1935
2296
  extraNotes.push(...await prepareWorkspaceDir(location, { gitIgnore: opts.gitIgnore, runGit: opts.runGit }));
1936
- output = join5(location.dir, `${location.slug}.pptx`);
2297
+ output = join7(location.dir, `${location.slug}.pptx`);
1937
2298
  }
1938
- await writeFile3(output, bytes);
2299
+ await writeFile4(output, bytes);
1939
2300
  const ok = `wrote ${output} (${v.ir.slides.length} slides, ${bytes.length} bytes)`;
1940
2301
  const notes = [...extraNotes, warningsNote(v.warnings), normalizedNote(v.normalized)].filter(
1941
2302
  (n) => n !== void 0
@@ -1959,10 +2320,18 @@ function placeholderNote(ir) {
1959
2320
  const refs = placeholders.map(({ slide, page }) => slide.id ? `${slide.id} (page ${page})` : `page ${page}`).join(", ");
1960
2321
  return `note: ${placeholders.length} unfilled placeholder page${placeholders.length === 1 ? "" : "s"}: ${refs}`;
1961
2322
  }
1962
- async function runValidate(irPath, cwd = process.cwd(), opts = {}) {
2323
+ async function runValidate(irPath, cwd = process.cwd()) {
1963
2324
  const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
1964
- const { raw, baseDir, isDir, workspaceAssetsDir } = await loadDeckTarget(irPath, cwd, projectHit, userHit);
1965
- await applyDeckConfig(raw, { themeFilePath: opts.themeFilePath, cwd, projectHit, userHit });
2325
+ const { raw, baseDir, isDir, resolvedTarget, workspaceAssetsDir, specTheme, specPath } = await loadDeckTarget(irPath, cwd, projectHit, userHit);
2326
+ await applyDeckConfig(raw, {
2327
+ specTheme,
2328
+ specPath,
2329
+ fromDeckDir: isDir,
2330
+ deckDir: deckLookupDir(resolvedTarget, isDir),
2331
+ cwd,
2332
+ projectHit,
2333
+ userHit
2334
+ });
1966
2335
  const v = validateIr(raw);
1967
2336
  if (!v.ok)
1968
2337
  throw new PptwiseError(
@@ -2002,8 +2371,16 @@ function formatAuditReport(report, ir) {
2002
2371
  async function runAudit(target, opts = {}) {
2003
2372
  const cwd = opts.cwd ?? process.cwd();
2004
2373
  const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
2005
- const { raw, baseDir, workspaceAssetsDir } = await loadDeckTarget(target, cwd, projectHit, userHit);
2006
- await applyDeckConfig(raw, { themeFilePath: opts.themeFilePath, cwd, projectHit, userHit });
2374
+ const { raw, baseDir, workspaceAssetsDir, isDir, resolvedTarget, specTheme, specPath } = await loadDeckTarget(target, cwd, projectHit, userHit);
2375
+ await applyDeckConfig(raw, {
2376
+ specTheme,
2377
+ specPath,
2378
+ fromDeckDir: isDir,
2379
+ deckDir: deckLookupDir(resolvedTarget, isDir),
2380
+ cwd,
2381
+ projectHit,
2382
+ userHit
2383
+ });
2007
2384
  const v = validateIr(raw);
2008
2385
  if (!v.ok) {
2009
2386
  throw new PptwiseError(
@@ -2047,8 +2424,16 @@ function formatAssetBriefReport(brief) {
2047
2424
  async function runAssetBrief(target, opts = {}) {
2048
2425
  const cwd = opts.cwd ?? process.cwd();
2049
2426
  const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
2050
- const { raw, baseDir, workspaceAssetsDir } = await loadDeckTarget(target, cwd, projectHit, userHit);
2051
- await applyDeckConfig(raw, { cwd, projectHit, userHit });
2427
+ const { raw, baseDir, workspaceAssetsDir, isDir, resolvedTarget, specTheme, specPath } = await loadDeckTarget(target, cwd, projectHit, userHit);
2428
+ await applyDeckConfig(raw, {
2429
+ specTheme,
2430
+ specPath,
2431
+ fromDeckDir: isDir,
2432
+ deckDir: deckLookupDir(resolvedTarget, isDir),
2433
+ cwd,
2434
+ projectHit,
2435
+ userHit
2436
+ });
2052
2437
  const v = validateIr(raw);
2053
2438
  if (!v.ok) {
2054
2439
  throw new PptwiseError(
@@ -2062,7 +2447,8 @@ ${formatIssues(v.errors)}`
2062
2447
  }
2063
2448
  async function runSpecValidate(specPath) {
2064
2449
  const raw = await loadIrFile(specPath, "spec");
2065
- await registerDeckThemeFile(dirname3(resolve6(specPath)));
2450
+ const specDir = dirname5(resolve7(specPath));
2451
+ await registerThemeSelection(themeNameFromUnknown(raw), { startDir: specDir, deckDir: specDir });
2066
2452
  const v = validateSpec(raw);
2067
2453
  if (!v.ok) {
2068
2454
  throw new PptwiseError(formatInvalidSpecError(v.errors));
@@ -2070,21 +2456,121 @@ async function runSpecValidate(specPath) {
2070
2456
  const spec2 = v.spec;
2071
2457
  const axes = resolveNarrative(spec2.narrative);
2072
2458
  const ok = `OK \u2014 ${spec2.pages.length} pages, narrative ${axes.strategy}/${axes.pacing}/${axes.audience}, theme "${resolveSpecThemeId(spec2)}"`;
2459
+ const lines = [ok];
2460
+ for (const w of v.warnings ?? []) {
2461
+ lines.push(`warning: ${w.path ? `${w.path}: ` : ""}${w.message}`);
2462
+ }
2073
2463
  const aliasNote = normalizedNote(v.normalized);
2074
- return aliasNote ? `${ok}
2075
- ${aliasNote}` : ok;
2464
+ if (aliasNote) lines.push(aliasNote);
2465
+ return lines.join("\n");
2076
2466
  }
2077
2467
  function runSchema(mode) {
2078
- const schema = mode === "style" ? styleJsonSchema() : mode === "spec" ? specJsonSchema() : irJsonSchema();
2468
+ const schema = mode === "spec" ? specJsonSchema() : irJsonSchema();
2079
2469
  return JSON.stringify(schema, null, 2);
2080
2470
  }
2081
2471
  function runThemes(asJson) {
2082
2472
  const themes = listThemes();
2083
- if (asJson) return JSON.stringify(themes, null, 2);
2473
+ if (asJson) {
2474
+ return JSON.stringify(
2475
+ themes.map((t) => {
2476
+ const rec = Object.hasOwn(THEME_OCCASIONS, t.id) ? THEME_OCCASIONS[t.id] : void 0;
2477
+ return {
2478
+ id: t.id,
2479
+ label: t.label,
2480
+ colors: t.colors,
2481
+ occasions: rec?.occasions ?? [],
2482
+ identity: rec?.identity ?? null
2483
+ };
2484
+ }),
2485
+ null,
2486
+ 2
2487
+ );
2488
+ }
2084
2489
  return themes.map((t) => `${t.id.padEnd(12)} ${t.label}`).join("\n");
2085
2490
  }
2491
+ function layoutCapacity(slots) {
2492
+ let sum = 0;
2493
+ let any = false;
2494
+ for (const slot of slots) {
2495
+ if (slot.capacity !== void 0) {
2496
+ sum += slot.capacity;
2497
+ any = true;
2498
+ }
2499
+ }
2500
+ return any ? sum : void 0;
2501
+ }
2502
+ function listLayouts() {
2503
+ return Object.values(LAYOUT_REGISTRY).map((layout) => {
2504
+ const capacity = layoutCapacity(layout.slots);
2505
+ const row = {
2506
+ id: layout.id,
2507
+ slideTypes: layout.slideTypes,
2508
+ slots: layout.slots.map((slot) => {
2509
+ const compact = { name: slot.name, accepts: slot.accepts };
2510
+ if (slot.capacity !== void 0) compact.capacity = slot.capacity;
2511
+ return compact;
2512
+ })
2513
+ };
2514
+ if (capacity !== void 0) row.capacity = capacity;
2515
+ return row;
2516
+ });
2517
+ }
2518
+ function runLayouts(asJson) {
2519
+ const layouts = listLayouts();
2520
+ if (asJson) return JSON.stringify(layouts, null, 2);
2521
+ const rows = layouts.map((l) => ({
2522
+ id: l.id,
2523
+ types: l.slideTypes.join(","),
2524
+ cap: l.capacity !== void 0 ? String(l.capacity) : "-",
2525
+ slots: l.slots.map((s) => s.name).join(", ")
2526
+ }));
2527
+ const idWidth = Math.max(...rows.map((r) => r.id.length));
2528
+ const typesWidth = Math.max(...rows.map((r) => r.types.length));
2529
+ const capWidth = Math.max(...rows.map((r) => r.cap.length));
2530
+ return rows.map(
2531
+ (r) => `${r.id.padEnd(idWidth + 2)}${r.types.padEnd(typesWidth + 2)}${r.cap.padEnd(capWidth + 2)}${r.slots}`
2532
+ ).join("\n");
2533
+ }
2086
2534
  function defaultThemeIdFor(output) {
2087
- return slugify(basename4(output).replace(/\.theme\.json$|\.json$/i, ""));
2535
+ return slugify(basename5(output).replace(/\.theme\.json$|\.json$/i, ""));
2536
+ }
2537
+ async function defaultThemeOutputPath(id, cwd) {
2538
+ assertThemeId(id);
2539
+ const project = await findConfig(cwd);
2540
+ const root = project !== null ? dirname5(project.path) : resolve7(cwd);
2541
+ return join7(root, WORKSPACE_THEMES_DIRNAME, `${id}.theme.json`);
2542
+ }
2543
+ async function resolveThemeWriteTarget(opts) {
2544
+ if (opts.output !== void 0) {
2545
+ const output = resolve7(opts.cwd, opts.output);
2546
+ const id = opts.id ?? defaultThemeIdFor(output);
2547
+ assertThemeId(id);
2548
+ return { id, output };
2549
+ }
2550
+ if (opts.id !== void 0) {
2551
+ assertThemeId(opts.id);
2552
+ return { id: opts.id, output: await defaultThemeOutputPath(opts.id, opts.cwd) };
2553
+ }
2554
+ throw new PptwiseError("pass --id or -o so the theme file has a name");
2555
+ }
2556
+ async function themeFileFromResolved(resolved, identity) {
2557
+ if (resolved.kind === "builtin") {
2558
+ return themeFileFromPreset(resolved.id, identity);
2559
+ }
2560
+ const copy = structuredClone(resolved.file);
2561
+ copy.id = identity.id;
2562
+ copy.style = { ...copy.style, id: identity.id };
2563
+ if (identity.label !== void 0) copy.label = identity.label;
2564
+ return ThemeFileSchema.parse(copy);
2565
+ }
2566
+ function rethrowBrandColorError(error, id) {
2567
+ const message = error instanceof Error ? error.message : String(error);
2568
+ if (/colors\.|contrast ratio|calibrate/i.test(message)) {
2569
+ throw new PptwiseError(
2570
+ `${message}. Run \`pptwise theme new --from <preset> --id ${id}\`, adjust the copied theme colors by hand, then bind that theme`
2571
+ );
2572
+ }
2573
+ throw error;
2088
2574
  }
2089
2575
  async function runBrandExtract(file, opts) {
2090
2576
  let bytes;
@@ -2094,31 +2580,123 @@ async function runBrandExtract(file, opts) {
2094
2580
  throw new PptwiseError(`cannot read template file: ${file}`);
2095
2581
  }
2096
2582
  const id = opts.id ?? defaultThemeIdFor(opts.output);
2097
- if (CANONICAL_THEME_IDS.includes(id)) {
2098
- throw new PptwiseError(
2099
- `theme id "${id}" collides with a built-in pptwise theme \u2014 pick a different id with --id (or a different output filename)`
2583
+ assertThemeId(id);
2584
+ let extracted;
2585
+ try {
2586
+ extracted = await extractBrandTheme(bytes, { id, label: opts.label });
2587
+ } catch (error) {
2588
+ rethrowBrandColorError(error, id);
2589
+ }
2590
+ const from = opts.from ?? "consulting";
2591
+ const cwd = dirname5(resolve7(opts.output));
2592
+ const donorResolved = await resolveThemeByName(from, { startDir: cwd });
2593
+ const donor = await themeFileFromResolved(donorResolved, { id: extracted.id, label: extracted.label });
2594
+ let theme2;
2595
+ try {
2596
+ theme2 = forkTheme(
2597
+ donor,
2598
+ {
2599
+ primary: extracted.style.colors.primary,
2600
+ accent: extracted.style.colors.accent,
2601
+ bg: extracted.style.colors.bg,
2602
+ text: extracted.style.colors.text,
2603
+ surface: extracted.style.colors.surface,
2604
+ chartPalette: extracted.style.colors.chartPalette
2605
+ },
2606
+ { id: extracted.id, label: extracted.label, fonts: extracted.style.fonts }
2100
2607
  );
2608
+ } catch (error) {
2609
+ rethrowBrandColorError(error, id);
2101
2610
  }
2102
- const theme = await extractBrandTheme(bytes, { id, label: opts.label });
2103
- const outPath = resolve6(opts.output);
2104
- await mkdir4(dirname3(outPath), { recursive: true });
2105
- await writeFile3(outPath, JSON.stringify(theme, null, 2) + "\n");
2106
- const c = theme.style.colors;
2611
+ const outPath = resolve7(opts.output);
2612
+ await writeThemeFile(outPath, theme2, opts.force === true);
2613
+ const c = theme2.style.colors;
2107
2614
  const lines = [
2108
- `wrote ${opts.output} (theme "${theme.id}", label "${theme.label}")`,
2615
+ `wrote ${opts.output} (theme "${theme2.id}", label "${theme2.label}")`,
2109
2616
  ` colors: bg ${c.bg}, text ${c.text}, primary ${c.primary}, accent ${c.accent}, muted ${c.muted} (derived), ${c.chartPalette.length} chart colors`,
2110
- ` fonts: heading "${theme.style.fonts.heading[0]}", body "${theme.style.fonts.body[0]}"`,
2111
- `use it: pptwise render <deck> --theme-file ${opts.output} \u2014 or drop it into a deck project directory as ${THEME_FILENAME} and reference "${theme.id}" as the deck's theme`
2617
+ ` fonts: heading "${theme2.style.fonts.heading[0]}", body "${theme2.style.fonts.body[0]}"`,
2618
+ `Drop the file in a deck ${THEME_FILENAME} or workspace themes/ and set spec.theme to "${theme2.id}".`
2112
2619
  ];
2113
- try {
2114
- assertContrastFloor(theme.id, theme.style);
2115
- } catch (e) {
2116
- lines.push(
2117
- `warning: this theme will be refused at load time \u2014 ${e instanceof Error ? e.message : String(e)}. Edit the written file's colors (darker text, or a lighter bg) before using it`
2118
- );
2119
- }
2120
2620
  return lines.join("\n");
2121
2621
  }
2622
+ async function runThemeNew(opts) {
2623
+ const cwd = opts.cwd ?? process.cwd();
2624
+ const { id, output } = await resolveThemeWriteTarget({ id: opts.id, output: opts.output, cwd });
2625
+ const resolved = await resolveThemeByName(opts.from, { startDir: cwd });
2626
+ const file = await themeFileFromResolved(resolved, { id, label: opts.label });
2627
+ await writeThemeFile(output, file, opts.force === true);
2628
+ return `wrote ${output} (theme "${file.id}"). Set spec.theme to "${file.id}".`;
2629
+ }
2630
+ async function runThemeFork(name, opts) {
2631
+ const cwd = opts.cwd ?? process.cwd();
2632
+ const { id, output } = await resolveThemeWriteTarget({ id: opts.id, output: opts.output, cwd });
2633
+ const resolved = await resolveThemeByName(name, { startDir: cwd });
2634
+ const source = await themeFileFromResolved(resolved, { id, label: opts.label });
2635
+ const file = forkTheme(
2636
+ source,
2637
+ { primary: opts.primary, bg: opts.bg, accent: opts.accent, text: opts.text, surface: opts.surface },
2638
+ { id, label: opts.label ?? source.label }
2639
+ );
2640
+ await writeThemeFile(output, file, opts.force === true);
2641
+ return `wrote ${output} (theme "${file.id}"). Set spec.theme to "${file.id}".`;
2642
+ }
2643
+ function parseThemeTryIds(raw) {
2644
+ const ids = raw.split(",").map((part) => part.trim()).filter((part) => part.length > 0);
2645
+ if (ids.length < 2 || ids.length > 4) {
2646
+ throw new PptwiseError(`pptwise theme try expects 2-4 theme ids, got ${ids.length}`);
2647
+ }
2648
+ const seen = /* @__PURE__ */ new Set();
2649
+ for (const id of ids) {
2650
+ if (seen.has(id)) throw new PptwiseError(`pptwise theme try has duplicate theme id "${id}"`);
2651
+ seen.add(id);
2652
+ }
2653
+ return ids;
2654
+ }
2655
+ function contactSheetSlidesFromIr(ir, svgs) {
2656
+ return ir.slides.map((slide, index) => {
2657
+ const type = slide.type ?? "content";
2658
+ const label = type === "content" && typeof slide.kind === "string" ? slide.kind : type;
2659
+ return { type, label, svg: svgs[index] };
2660
+ });
2661
+ }
2662
+ async function runThemeTry(idsRaw, opts = {}) {
2663
+ const cwd = opts.cwd ?? process.cwd();
2664
+ const ids = parseThemeTryIds(idsRaw);
2665
+ const columns = [];
2666
+ for (const name of ids) {
2667
+ const resolved = await resolveThemeByName(name, { startDir: cwd });
2668
+ const raw = structuredClone(THEME_TRY_SAMPLE_IR);
2669
+ raw.theme = { id: resolved.id };
2670
+ const v = validateIr(raw);
2671
+ if (!v.ok) throw new PptwiseError(`invalid IR:
2672
+ ${formatIssues(v.errors)}`);
2673
+ const ir = v.ir;
2674
+ const svgs = ir.slides.map((_, index) => renderSlideSvg(ir, index));
2675
+ columns.push({ id: resolved.id, slides: contactSheetSlidesFromIr(ir, svgs) });
2676
+ }
2677
+ let resolvedOut;
2678
+ const extraNotes = [];
2679
+ if (opts.output !== void 0) {
2680
+ resolvedOut = resolve7(cwd, opts.output);
2681
+ await mkdir5(resolvedOut, { recursive: true });
2682
+ } else {
2683
+ const projectHit = await findConfig(cwd);
2684
+ const location = resolveWorkspaceLocation({
2685
+ cwd,
2686
+ projectConfigPath: projectHit?.path,
2687
+ outDir: projectHit?.config.outDir,
2688
+ target: "theme-try",
2689
+ isDir: true
2690
+ });
2691
+ extraNotes.push(...await prepareWorkspaceDir(location, { gitIgnore: opts.gitIgnore, runGit: opts.runGit }));
2692
+ resolvedOut = location.dir;
2693
+ }
2694
+ const htmlPath = join7(resolvedOut, "contact-sheet.html");
2695
+ await writeFile4(htmlPath, buildContactSheetHtml({ title: "theme try", themes: columns }));
2696
+ const ok = `wrote contact sheet to ${htmlPath}`;
2697
+ return extraNotes.length > 0 ? `${ok}
2698
+ ${extraNotes.join("\n")}` : ok;
2699
+ }
2122
2700
  function runNarratives(asJson) {
2123
2701
  if (asJson) {
2124
2702
  return JSON.stringify(
@@ -2141,29 +2719,32 @@ function runNarratives(asJson) {
2141
2719
  const axesWidth = Math.max(...rows.map((r) => r.axes.length));
2142
2720
  return rows.map((r) => `${r.id.padEnd(idWidth + 2)}${r.axes.padEnd(axesWidth + 2)}${r.themes}`).join("\n");
2143
2721
  }
2144
- var CONFIG_TEMPLATE = {
2145
- theme: "consulting",
2146
- style: {
2147
- colors: { primary: "#0B5FFF", accent: "#FF6A00" }
2148
- }
2149
- };
2722
+ var CONFIG_TEMPLATE = {};
2150
2723
  async function runInit(cwd = process.cwd()) {
2151
- const target = join5(cwd, CONFIG_FILENAME);
2724
+ const target = join7(cwd, CONFIG_FILENAME);
2152
2725
  try {
2153
- await writeFile3(target, JSON.stringify(CONFIG_TEMPLATE, null, 2) + "\n", { flag: "wx" });
2726
+ await writeFile4(target, JSON.stringify(CONFIG_TEMPLATE, null, 2) + "\n", { flag: "wx" });
2154
2727
  } catch (e) {
2155
2728
  if (e.code === "EEXIST") {
2156
2729
  throw new PptwiseError(`${target} already exists \u2014 edit it instead`);
2157
2730
  }
2158
2731
  throw e;
2159
2732
  }
2160
- return `wrote ${target} \u2014 themes: \`pptwise themes\`, style schema: \`pptwise schema --style\``;
2733
+ return `wrote ${target} \u2014 themes: \`pptwise themes\``;
2161
2734
  }
2162
2735
  async function renderDeckSlides(target, opts = {}) {
2163
2736
  const cwd = opts.cwd ?? process.cwd();
2164
2737
  const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
2165
- const { raw, baseDir, isDir, resolvedTarget, workspaceAssetsDir } = await loadDeckTarget(target, cwd, projectHit, userHit);
2166
- await applyDeckConfig(raw, { themeFilePath: opts.themeFilePath, cwd, projectHit, userHit });
2738
+ const { raw, baseDir, isDir, resolvedTarget, workspaceAssetsDir, specTheme, specPath } = await loadDeckTarget(target, cwd, projectHit, userHit);
2739
+ await applyDeckConfig(raw, {
2740
+ specTheme,
2741
+ specPath,
2742
+ fromDeckDir: isDir,
2743
+ deckDir: deckLookupDir(resolvedTarget, isDir),
2744
+ cwd,
2745
+ projectHit,
2746
+ userHit
2747
+ });
2167
2748
  const v = validateIr(raw);
2168
2749
  if (!v.ok) throw new PptwiseError(`invalid IR:
2169
2750
  ${formatIssues(v.errors)}`);
@@ -2196,35 +2777,35 @@ async function buildDeckPreview(target, opts = {}) {
2196
2777
  const { html, findings, checks } = buildDeckAuditAndHtml(rendered.ir, rendered.svgs);
2197
2778
  return { ...rendered, html, findings, checks };
2198
2779
  }
2780
+ async function resolvePreviewOutDir(cwd, outDir, resolvedTarget, isDir, opts) {
2781
+ if (outDir !== void 0) {
2782
+ const resolvedOut = resolve7(cwd, outDir);
2783
+ await mkdir5(resolvedOut, { recursive: true });
2784
+ return { resolvedOut, extraNotes: [] };
2785
+ }
2786
+ const projectHit = await findConfig(cwd);
2787
+ const location = resolveWorkspaceLocation({
2788
+ cwd,
2789
+ projectConfigPath: projectHit?.path,
2790
+ outDir: projectHit?.config.outDir,
2791
+ target: resolvedTarget,
2792
+ isDir
2793
+ });
2794
+ const extraNotes = await prepareWorkspaceDir(location, { gitIgnore: opts.gitIgnore, runGit: opts.runGit });
2795
+ await pruneRenderedSvgs(location.dir);
2796
+ return { resolvedOut: location.dir, extraNotes };
2797
+ }
2199
2798
  async function runPreview(irPath, outDir, opts = {}) {
2200
2799
  const cwd = opts.cwd ?? process.cwd();
2201
2800
  const { ir, svgs, normalized, isDir, resolvedTarget } = await renderDeckSlides(irPath, {
2202
- cwd,
2203
- themeFilePath: opts.themeFilePath
2801
+ cwd
2204
2802
  });
2205
- const extraNotes = [];
2206
- let resolvedOut;
2207
- if (outDir !== void 0) {
2208
- resolvedOut = resolve6(cwd, outDir);
2209
- await mkdir4(resolvedOut, { recursive: true });
2210
- } else {
2211
- const projectHit = await findConfig(cwd);
2212
- const location = resolveWorkspaceLocation({
2213
- cwd,
2214
- projectConfigPath: projectHit?.path,
2215
- outDir: projectHit?.config.outDir,
2216
- target: resolvedTarget,
2217
- isDir
2218
- });
2219
- extraNotes.push(...await prepareWorkspaceDir(location, { gitIgnore: opts.gitIgnore, runGit: opts.runGit }));
2220
- await pruneRenderedSvgs(location.dir);
2221
- resolvedOut = location.dir;
2222
- }
2803
+ const { resolvedOut, extraNotes } = await resolvePreviewOutDir(cwd, outDir, resolvedTarget, isDir, opts);
2223
2804
  const svgNames = [];
2224
2805
  for (let i = 0; i < ir.slides.length; i++) {
2225
2806
  const name = `${String(i + 1).padStart(3, "0")}-${ir.slides[i].type}.svg`;
2226
2807
  svgNames.push(name);
2227
- await writeFile3(join5(resolvedOut, name), svgs[i]);
2808
+ await writeFile4(join7(resolvedOut, name), svgs[i]);
2228
2809
  }
2229
2810
  const ok = `wrote ${ir.slides.length} SVG files to ${resolvedOut}`;
2230
2811
  const notes = [...extraNotes];
@@ -2232,8 +2813,8 @@ async function runPreview(irPath, outDir, opts = {}) {
2232
2813
  if (aliasNote) notes.push(aliasNote);
2233
2814
  if (opts.htmlOut) {
2234
2815
  const { html, findings, checks } = buildDeckAuditAndHtml(ir, svgs);
2235
- const htmlPath = join5(resolvedOut, "preview.html");
2236
- await writeFile3(htmlPath, html);
2816
+ const htmlPath = join7(resolvedOut, "preview.html");
2817
+ await writeFile4(htmlPath, html);
2237
2818
  const hasPlaceholder = ir.slides.some((s) => s.placeholder);
2238
2819
  const manifest = buildPreviewManifest({
2239
2820
  title: ir.filename,
@@ -2251,8 +2832,8 @@ async function runPreview(irPath, outDir, opts = {}) {
2251
2832
  checks,
2252
2833
  auditNote: hasPlaceholder ? "audit skipped \u2014 deck has unfilled placeholder pages" : void 0
2253
2834
  });
2254
- const manifestPath = join5(resolvedOut, "manifest.json");
2255
- await writeFile3(manifestPath, `${JSON.stringify(manifest, null, 2)}
2835
+ const manifestPath = join7(resolvedOut, "manifest.json");
2836
+ await writeFile4(manifestPath, `${JSON.stringify(manifest, null, 2)}
2256
2837
  `);
2257
2838
  notes.push(`note: wrote self-contained preview to ${htmlPath}`);
2258
2839
  notes.push(`note: wrote machine-readable page manifest to ${manifestPath}`);
@@ -2267,7 +2848,7 @@ function withRewrittenAssetPaths(ir, deckDir, outDir) {
2267
2848
  const images2 = Object.fromEntries(
2268
2849
  Object.entries(ir.assets.images).map(([id, asset]) => {
2269
2850
  if (asset.src.startsWith("data:") || /^https?:\/\//.test(asset.src)) return [id, asset];
2270
- return [id, { ...asset, src: relative2(outDir, join5(deckDir, asset.src)) }];
2851
+ return [id, { ...asset, src: relative2(outDir, join7(deckDir, asset.src)) }];
2271
2852
  })
2272
2853
  );
2273
2854
  return { ...ir, assets: { images: images2 } };
@@ -2279,25 +2860,18 @@ async function runAssemble(target, opts = {}) {
2279
2860
  if (await pathExists(dir) && !await isDeckDirectory(dir)) {
2280
2861
  throw new PptwiseError(`expected a deck project directory: ${dir}`);
2281
2862
  }
2282
- if (await isDeckDirectory(dir)) await registerDeckThemeFile(dir);
2283
- const { ir, generatedSeed, materializedLayoutCount, deckDir } = await readDeckDir(dir);
2284
- const outPath = opts.output ? resolve6(cwd, opts.output) : join5(deckDir, "deck.json");
2285
- const outDir = dirname3(outPath);
2863
+ if (await isDeckDirectory(dir)) {
2864
+ await registerThemesFromSpecSource(join7(dir, SPEC_FILENAME), { startDir: cwd, deckDir: dir });
2865
+ }
2866
+ const { ir, deckDir } = await readDeckDir(dir);
2867
+ const outPath = opts.output ? resolve7(cwd, opts.output) : join7(deckDir, "deck.json");
2868
+ const outDir = dirname5(outPath);
2286
2869
  const outIr = outDir === deckDir ? ir : withRewrittenAssetPaths(ir, deckDir, outDir);
2287
- await mkdir4(outDir, { recursive: true });
2288
- await writeFile3(outPath, JSON.stringify(outIr, null, 2) + "\n");
2870
+ await mkdir5(outDir, { recursive: true });
2871
+ await writeFile4(outPath, JSON.stringify(outIr, null, 2) + "\n");
2289
2872
  const placeholderCount = outIr.slides.filter((s) => s.placeholder).length;
2290
2873
  const summary = `wrote ${outPath} (${outIr.slides.length} slides, ${placeholderCount} placeholder${placeholderCount === 1 ? "" : "s"})`;
2291
- const notes = [];
2292
- if (generatedSeed !== void 0) {
2293
- notes.push(`note: generated seed ${generatedSeed} \u2014 add "seed": ${generatedSeed} to deck.spec.json for revision stability`);
2294
- }
2295
- if (materializedLayoutCount !== void 0) {
2296
- notes.push(
2297
- `note: ${materializedLayoutCount} layout${materializedLayoutCount === 1 ? "" : "s"} auto-selected into deck.json \u2014 pin "layout" in a page file to lock one`
2298
- );
2299
- }
2300
- return [summary, ...notes].join("\n");
2874
+ return summary;
2301
2875
  }
2302
2876
  async function runDisassemble(irPath, outDir) {
2303
2877
  const raw = await loadIrFile(irPath);
@@ -2307,203 +2881,41 @@ ${formatIssues(v.errors)}`);
2307
2881
  const { spec: spec2, pages } = disassembleDeck(v.ir);
2308
2882
  const ids = Object.keys(pages);
2309
2883
  for (const id of ids) assertSafeFileSegment(id, "slide id");
2310
- const specPath = join5(outDir, "deck.spec.json");
2311
- await mkdir4(outDir, { recursive: true });
2884
+ const specPath = join7(outDir, "deck.spec.json");
2885
+ await mkdir5(outDir, { recursive: true });
2312
2886
  try {
2313
- await writeFile3(specPath, JSON.stringify(spec2, null, 2) + "\n", { flag: "wx" });
2887
+ await writeFile4(specPath, JSON.stringify(spec2, null, 2) + "\n", { flag: "wx" });
2314
2888
  } catch (e) {
2315
2889
  if (e.code === "EEXIST") {
2316
2890
  throw new PptwiseError(`${specPath} already exists \u2014 refusing to overwrite an existing deck project`);
2317
2891
  }
2318
2892
  throw e;
2319
2893
  }
2320
- const pagesDir = join5(outDir, "pages");
2894
+ const pagesDir = join7(outDir, "pages");
2321
2895
  try {
2322
2896
  if (ids.length > 0) {
2323
- await mkdir4(pagesDir, { recursive: true });
2897
+ await mkdir5(pagesDir, { recursive: true });
2324
2898
  await Promise.all(
2325
2899
  ids.map((id) => {
2326
2900
  const content = pages[id];
2327
- return writeFile3(join5(pagesDir, `${id}.json`), JSON.stringify(content, null, 2) + "\n");
2901
+ return writeFile4(join7(pagesDir, `${id}.json`), JSON.stringify(content, null, 2) + "\n");
2328
2902
  })
2329
2903
  );
2330
2904
  }
2331
2905
  const { count: assetCount, assetsDir } = await writeDeckAssets(
2332
2906
  v.ir.assets.images,
2333
2907
  outDir,
2334
- dirname3(resolve6(irPath))
2908
+ dirname5(resolve7(irPath))
2335
2909
  );
2336
2910
  const pagesNote = ids.length > 0 ? `${ids.length} page file${ids.length === 1 ? "" : "s"} to ${pagesDir}` : "no pages (every slide was a placeholder)";
2337
2911
  const assetsNote = assetCount > 0 ? `, and ${assetCount} asset file${assetCount === 1 ? "" : "s"} to ${assetsDir}` : "";
2338
2912
  return `wrote ${specPath}, ${pagesNote}${assetsNote}`;
2339
2913
  } catch (e) {
2340
- await rm(specPath, { force: true }).catch(() => {
2914
+ await rm2(specPath, { force: true }).catch(() => {
2341
2915
  });
2342
2916
  throw e;
2343
2917
  }
2344
2918
  }
2345
- async function runMigrate(input, output, cwd = process.cwd()) {
2346
- const resolvedInput = resolve6(cwd, input);
2347
- if (await isDeckDirectory(resolvedInput)) {
2348
- return runMigrateDeckDir(resolvedInput, output, cwd);
2349
- }
2350
- return runMigrateIrFile(resolvedInput, output, cwd);
2351
- }
2352
- function isPlainRecord(value) {
2353
- return typeof value === "object" && value !== null && !Array.isArray(value);
2354
- }
2355
- function needsChromeRewrite(raw) {
2356
- return Object.hasOwn(raw, "chrome");
2357
- }
2358
- function needsBloomRewrite(raw) {
2359
- const theme = raw.theme;
2360
- if (theme === "bloom") return true;
2361
- return isPlainRecord(theme) && theme.id === "bloom";
2362
- }
2363
- function componentListHasLogoWall(components) {
2364
- return Array.isArray(components) && components.some((component) => isPlainRecord(component) && component.type === "logo_wall");
2365
- }
2366
- function needsLogoWallRewrite(raw) {
2367
- if (componentListHasLogoWall(raw.components)) return true;
2368
- if (!Array.isArray(raw.slides)) return false;
2369
- return raw.slides.some((slide) => isPlainRecord(slide) && componentListHasLogoWall(slide.components));
2370
- }
2371
- function recordHasBannerHeadingPin(obj) {
2372
- return obj.layout === "banner-heading" || obj.focus === "banner-heading";
2373
- }
2374
- function needsBannerHeadingRewrite(raw) {
2375
- if (recordHasBannerHeadingPin(raw)) return true;
2376
- if (Array.isArray(raw.slides) && raw.slides.some((slide) => isPlainRecord(slide) && recordHasBannerHeadingPin(slide))) {
2377
- return true;
2378
- }
2379
- if (Array.isArray(raw.pages) && raw.pages.some((page) => isPlainRecord(page) && recordHasBannerHeadingPin(page))) {
2380
- return true;
2381
- }
2382
- return false;
2383
- }
2384
- function migrateRewriteNote(chrome, bloom, logoWall = false, bannerHeading = false) {
2385
- const parts = [];
2386
- if (chrome) parts.push("renamed chrome \u2192 branding");
2387
- if (bloom) parts.push("relocated bloom \u2192 classroom");
2388
- if (logoWall) parts.push("rewrote logo_wall \u2192 image_grid");
2389
- if (bannerHeading) parts.push("rewrote banner-heading \u2192 two-column");
2390
- return parts.join(", ");
2391
- }
2392
- function applyV4LeftoverRewrites(raw) {
2393
- return migrateBannerHeadingToTwoColumn(
2394
- migrateLogoWallToImageGrid(migrateBloomToClassroom(migrateChromeToBranding(raw)))
2395
- );
2396
- }
2397
- async function listPageJsonNames(dir) {
2398
- const pagesDir = join5(dir, PAGES_DIRNAME);
2399
- try {
2400
- const entries = await readdir3(pagesDir);
2401
- return entries.filter((name) => name.endsWith(".json")).sort();
2402
- } catch (e) {
2403
- if (e.code === "ENOENT") return [];
2404
- throw e;
2405
- }
2406
- }
2407
- async function rewriteLeftoverPages(dir, outDir) {
2408
- const names = await listPageJsonNames(dir);
2409
- const written = [];
2410
- let logoWall = false;
2411
- let bannerHeading = false;
2412
- for (const name of names) {
2413
- const src = join5(dir, PAGES_DIRNAME, name);
2414
- const raw = await loadIrFile(src, "page");
2415
- if (!isPlainRecord(raw)) continue;
2416
- const hasLogo = needsLogoWallRewrite(raw);
2417
- const hasBanner = needsBannerHeadingRewrite(raw);
2418
- if (!hasLogo && !hasBanner) continue;
2419
- if (hasLogo) logoWall = true;
2420
- if (hasBanner) bannerHeading = true;
2421
- const dest = join5(outDir, PAGES_DIRNAME, name);
2422
- await writeMigratedJson(dest, migrateBannerHeadingToTwoColumn(migrateLogoWallToImageGrid(raw)));
2423
- written.push(dest);
2424
- }
2425
- return { paths: written, logoWall, bannerHeading };
2426
- }
2427
- async function writeMigratedJson(outPath, data) {
2428
- await mkdir4(dirname3(outPath), { recursive: true });
2429
- try {
2430
- await writeFile3(outPath, JSON.stringify(data, null, 2) + "\n", { flag: "wx" });
2431
- } catch (e) {
2432
- if (e.code === "EEXIST") {
2433
- throw new PptwiseError(`${outPath} already exists \u2014 refusing to overwrite, delete it first or choose a different -o`);
2434
- }
2435
- throw e;
2436
- }
2437
- }
2438
- async function runMigrateDeckDir(dir, output, cwd) {
2439
- const planPath = join5(dir, PLAN_FILENAME);
2440
- const sourceSpecPath = join5(dir, SPEC_FILENAME);
2441
- const outDir = resolve6(cwd, output);
2442
- const specPath = join5(outDir, SPEC_FILENAME);
2443
- if (!await pathExists(planPath) && await pathExists(sourceSpecPath)) {
2444
- const raw2 = await loadIrFile(sourceSpecPath, "spec");
2445
- const specNeeds = isPlainRecord(raw2) && (needsChromeRewrite(raw2) || needsBloomRewrite(raw2) || needsBannerHeadingRewrite(raw2));
2446
- const pages2 = await rewriteLeftoverPages(dir, outDir);
2447
- if (specNeeds && isPlainRecord(raw2)) {
2448
- const chrome = needsChromeRewrite(raw2);
2449
- const bloom = needsBloomRewrite(raw2);
2450
- const bannerHeading = needsBannerHeadingRewrite(raw2);
2451
- const migrated2 = applyV4LeftoverRewrites(raw2);
2452
- await writeMigratedJson(specPath, migrated2);
2453
- const specNote2 = `wrote ${specPath} (${migrateRewriteNote(chrome, bloom, false, bannerHeading)})`;
2454
- if (pages2.paths.length === 0) return specNote2;
2455
- return `${specNote2}, wrote ${pages2.paths.length === 1 ? pages2.paths[0] : join5(outDir, PAGES_DIRNAME)} (${migrateRewriteNote(false, false, pages2.logoWall, pages2.bannerHeading)})`;
2456
- }
2457
- if (pages2.paths.length > 0) {
2458
- const target = pages2.paths.length === 1 ? pages2.paths[0] : join5(outDir, PAGES_DIRNAME);
2459
- return `wrote ${target} (${migrateRewriteNote(false, false, pages2.logoWall, pages2.bannerHeading)})`;
2460
- }
2461
- throw new PptwiseError(
2462
- `${dir} has ${SPEC_FILENAME} but no ${PLAN_FILENAME} \u2014 this deck project is already migrated, nothing to do`
2463
- );
2464
- }
2465
- const raw = await loadIrFile(planPath, "plan");
2466
- const migrated = migrateDeckPlanToSpec(raw);
2467
- await writeMigratedJson(specPath, migrated);
2468
- const pages = await rewriteLeftoverPages(dir, outDir);
2469
- const specNote = `wrote ${specPath} \u2014 run \`pptwise spec validate ${specPath}\` to confirm it, then delete ${planPath} (a directory with both files present is rejected)`;
2470
- if (pages.paths.length === 0) return specNote;
2471
- return `${specNote}, wrote ${pages.paths.length === 1 ? pages.paths[0] : join5(outDir, PAGES_DIRNAME)} (${migrateRewriteNote(false, false, pages.logoWall, pages.bannerHeading)})`;
2472
- }
2473
- async function runMigrateIrFile(filePath, output, cwd) {
2474
- const raw = await loadIrFile(filePath);
2475
- const version = typeof raw === "object" && raw !== null ? raw.version : void 0;
2476
- if (version === "2") {
2477
- throw new PptwiseError(
2478
- "pptwise migrate does not support IR v2 (spec \xA715.3: v2 has no real users) \u2014 run `pptwise validate` on the v2 file to see the full v2\u2192v4 combined field mapping and rewrite it by hand"
2479
- );
2480
- }
2481
- const outPath = resolve6(cwd, output);
2482
- if (version === "3") {
2483
- const pre = isPlainRecord(raw) ? migrateLogoWallToImageGrid(raw) : raw;
2484
- const parsed = PptxIRV3Schema.safeParse(pre);
2485
- if (!parsed.success) {
2486
- const detail = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
2487
- throw new PptwiseError(`invalid IR v3 file ${filePath}:
2488
- ${detail}`);
2489
- }
2490
- const migrated = migrateIrV3ToV4(parsed.data);
2491
- await writeMigratedJson(outPath, migrated);
2492
- return `wrote ${outPath} (migrated IR v3 \u2192 v4)`;
2493
- }
2494
- if (isPlainRecord(raw) && (needsChromeRewrite(raw) || needsBloomRewrite(raw) || needsLogoWallRewrite(raw) || needsBannerHeadingRewrite(raw))) {
2495
- const chrome = needsChromeRewrite(raw);
2496
- const bloom = needsBloomRewrite(raw);
2497
- const logoWall = needsLogoWallRewrite(raw);
2498
- const bannerHeading = needsBannerHeadingRewrite(raw);
2499
- const migrated = applyV4LeftoverRewrites(raw);
2500
- await writeMigratedJson(outPath, migrated);
2501
- return `wrote ${outPath} (${migrateRewriteNote(chrome, bloom, logoWall, bannerHeading)})`;
2502
- }
2503
- throw new PptwiseError(
2504
- `pptwise migrate converts an IR v3 file (version: "3"), a v4 IR or deck spec still carrying the old chrome field (renamed to branding), the removed bloom theme id (relocated to classroom), a leftover logo_wall component (rewritten to image_grid), or a leftover banner-heading layout pin (rewritten to two-column), or a deck project directory containing ${PLAN_FILENAME} \u2014 got version ${JSON.stringify(version)} in ${filePath} with nothing to migrate`
2505
- );
2506
- }
2507
2919
 
2508
2920
  // src/cli/secret-input.ts
2509
2921
  import readline from "readline";
@@ -2518,7 +2930,7 @@ async function readSecret(prompt, io = { stdin: process.stdin, stderr: process.s
2518
2930
  return await readHiddenTty(prompt, stdin, stderr);
2519
2931
  }
2520
2932
  function readFirstLine(stdin) {
2521
- return new Promise((resolve8, reject) => {
2933
+ return new Promise((resolve9, reject) => {
2522
2934
  const encoding = stdin.readableEncoding ?? "utf8";
2523
2935
  let buf = "";
2524
2936
  const onData = (chunk) => {
@@ -2526,11 +2938,11 @@ function readFirstLine(stdin) {
2526
2938
  const nl = buf.indexOf("\n");
2527
2939
  if (nl === -1) return;
2528
2940
  cleanup();
2529
- resolve8(buf.slice(0, nl).replace(/\r$/, "").trim());
2941
+ resolve9(buf.slice(0, nl).replace(/\r$/, "").trim());
2530
2942
  };
2531
2943
  const onEnd = () => {
2532
2944
  cleanup();
2533
- resolve8(buf.replace(/\r$/, "").trim());
2945
+ resolve9(buf.replace(/\r$/, "").trim());
2534
2946
  };
2535
2947
  const onError = (e) => {
2536
2948
  cleanup();
@@ -2547,7 +2959,7 @@ function readFirstLine(stdin) {
2547
2959
  });
2548
2960
  }
2549
2961
  function readHiddenTty(prompt, stdin, stderr) {
2550
- return new Promise((resolve8, reject) => {
2962
+ return new Promise((resolve9, reject) => {
2551
2963
  const rl = readline.createInterface({ input: stdin, output: stderr, terminal: true });
2552
2964
  const mutable = rl;
2553
2965
  mutable._writeToOutput = () => {
@@ -2565,7 +2977,7 @@ function readHiddenTty(prompt, stdin, stderr) {
2565
2977
  stderr.write("\n");
2566
2978
  const value = answer.trim();
2567
2979
  if (value === "") reject(new PptwiseError("API key cannot be empty"));
2568
- else resolve8(value);
2980
+ else resolve9(value);
2569
2981
  });
2570
2982
  });
2571
2983
  rl.on("SIGINT", () => {
@@ -2634,13 +3046,13 @@ async function runConfigShow(opts = {}) {
2634
3046
 
2635
3047
  // src/cli/doctor.ts
2636
3048
  import { lstatSync as lstatSync2 } from "fs";
2637
- import { access, readFile as readFile7, readdir as readdir5 } from "fs/promises";
3049
+ import { access, readFile as readFile7, readdir as readdir4 } from "fs/promises";
2638
3050
  import { homedir } from "os";
2639
- import { basename as basename5, join as join7 } from "path";
3051
+ import { basename as basename6, join as join9 } from "path";
2640
3052
 
2641
3053
  // src/cli/image-generators.ts
2642
- import { copyFile as copyFile2, readdir as readdir4, stat as stat3 } from "fs/promises";
2643
- import { join as join6 } from "path";
3054
+ import { copyFile as copyFile2, readdir as readdir3, stat as stat3 } from "fs/promises";
3055
+ import { join as join8 } from "path";
2644
3056
  var GENERATOR_PROBE_TIMEOUT_MS = 2e3;
2645
3057
  var BIN_NAMES = {
2646
3058
  grok: ["grok"],
@@ -2713,12 +3125,12 @@ async function isSniffedImage(path2) {
2713
3125
  async function collectFiles(dir, acc) {
2714
3126
  let entries;
2715
3127
  try {
2716
- entries = await readdir4(dir, { withFileTypes: true });
3128
+ entries = await readdir3(dir, { withFileTypes: true });
2717
3129
  } catch {
2718
3130
  return;
2719
3131
  }
2720
3132
  for (const entry of entries) {
2721
- const full = join6(dir, entry.name);
3133
+ const full = join8(dir, entry.name);
2722
3134
  if (entry.isDirectory()) {
2723
3135
  await collectFiles(full, acc);
2724
3136
  } else if (entry.isFile()) {
@@ -2902,18 +3314,19 @@ var SKILL_COPY_FILES = [
2902
3314
  "references/validate.zh-CN.md"
2903
3315
  ];
2904
3316
  var SKILL_ROOTS = [
2905
- { harness: "Claude Code", relative: join7(".claude", "skills") },
2906
- { harness: "Codex", relative: join7(".codex", "skills") },
2907
- { harness: "Pi, OpenCode", relative: join7(".agents", "skills") }
3317
+ { harness: "Claude Code", relative: join9(".claude", "skills") },
3318
+ { harness: "Codex", relative: join9(".codex", "skills") },
3319
+ { harness: "Pi, OpenCode", relative: join9(".agents", "skills") }
2908
3320
  ];
2909
3321
  var SELF_TEST_DECK = {
2910
- version: "4",
3322
+ version: "5",
2911
3323
  filename: "pptwise-doctor-self-test.pptx",
2912
3324
  theme: { id: "consulting" },
2913
3325
  slides: [
2914
3326
  { type: "cover", heading: "pptwise doctor", subheading: "self-test render" },
2915
3327
  {
2916
3328
  type: "content",
3329
+ kind: "points",
2917
3330
  heading: "Core chain",
2918
3331
  components: [{ type: "bullets", items: ["validate the IR", "render a slide to SVG", "generate the .pptx bytes"] }]
2919
3332
  }
@@ -2941,15 +3354,15 @@ async function scanSkillCopies(home, currentVersion) {
2941
3354
  const scanned = [];
2942
3355
  const copies = [];
2943
3356
  for (const { harness, relative: relative3 } of SKILL_ROOTS) {
2944
- const root = join7(home, relative3);
3357
+ const root = join9(home, relative3);
2945
3358
  scanned.push(root);
2946
3359
  for (const { dirName, legacy } of [
2947
3360
  { dirName: SKILL_DIR_NAME, legacy: false },
2948
3361
  ...LEGACY_SKILL_DIR_NAMES.map((dirName2) => ({ dirName: dirName2, legacy: true }))
2949
3362
  ]) {
2950
- const copyDir = join7(root, dirName);
3363
+ const copyDir = join9(root, dirName);
2951
3364
  if (!await pathExists2(copyDir)) continue;
2952
- const launcherPath = join7(copyDir, "scripts", "run.sh");
3365
+ const launcherPath = join9(copyDir, "scripts", "run.sh");
2953
3366
  let pinned = null;
2954
3367
  let launcher = null;
2955
3368
  try {
@@ -2959,7 +3372,7 @@ async function scanSkillCopies(home, currentVersion) {
2959
3372
  }
2960
3373
  const missing = [];
2961
3374
  for (const rel of SKILL_COPY_FILES) {
2962
- if (!await pathExists2(join7(copyDir, rel))) missing.push(rel);
3375
+ if (!await pathExists2(join9(copyDir, rel))) missing.push(rel);
2963
3376
  }
2964
3377
  copies.push({
2965
3378
  harness,
@@ -2976,7 +3389,7 @@ async function scanSkillCopies(home, currentVersion) {
2976
3389
  return { scanned, copies };
2977
3390
  }
2978
3391
  async function readInstalledPluginVersion(profileDir) {
2979
- const pkgPath = join7(profileDir, "node_modules", ...PACKAGE_NAME.split("/"), "package.json");
3392
+ const pkgPath = join9(profileDir, "node_modules", ...PACKAGE_NAME.split("/"), "package.json");
2980
3393
  try {
2981
3394
  const pkg = JSON.parse(await readFile7(pkgPath, "utf8"));
2982
3395
  return typeof pkg.version === "string" ? pkg.version : null;
@@ -2985,22 +3398,22 @@ async function readInstalledPluginVersion(profileDir) {
2985
3398
  }
2986
3399
  }
2987
3400
  async function inspectDsh(home, currentVersion) {
2988
- const dshHome = join7(home, ".dsh");
3401
+ const dshHome = join9(home, ".dsh");
2989
3402
  if (!await pathExists2(dshHome)) return { applicable: false, home: dshHome, profiles: [] };
2990
- const profilesDir = join7(dshHome, "profiles");
3403
+ const profilesDir = join9(dshHome, "profiles");
2991
3404
  let entries;
2992
3405
  try {
2993
- const dirents = await readdir5(profilesDir, { withFileTypes: true });
3406
+ const dirents = await readdir4(profilesDir, { withFileTypes: true });
2994
3407
  entries = dirents.filter((d) => d.isDirectory() && d.name !== "node_modules").map((d) => d.name);
2995
3408
  } catch {
2996
3409
  return { applicable: true, home: dshHome, profiles: [] };
2997
3410
  }
2998
3411
  const profiles = [];
2999
3412
  for (const name of entries.sort()) {
3000
- const profileDir = join7(profilesDir, name);
3413
+ const profileDir = join9(profilesDir, name);
3001
3414
  let declared;
3002
3415
  try {
3003
- const pkg = JSON.parse(await readFile7(join7(profileDir, "package.json"), "utf8"));
3416
+ const pkg = JSON.parse(await readFile7(join9(profileDir, "package.json"), "utf8"));
3004
3417
  declared = pkg.dependencies?.[PACKAGE_NAME];
3005
3418
  } catch {
3006
3419
  continue;
@@ -3171,8 +3584,8 @@ async function buildDoctorReport(input = {}) {
3171
3584
  if (copy.legacy) {
3172
3585
  warnings.push({
3173
3586
  check: "skill copy",
3174
- message: `${copy.path} is a leftover ${basename5(copy.path)} skill copy`,
3175
- fix: `remove it and install the pptwise skill in ${join7(copy.root, SKILL_DIR_NAME)}`
3587
+ message: `${copy.path} is a leftover ${basename6(copy.path)} skill copy`,
3588
+ fix: `remove it and install the pptwise skill in ${join9(copy.root, SKILL_DIR_NAME)}`
3176
3589
  });
3177
3590
  } else if (copy.stale) {
3178
3591
  warnings.push({
@@ -3234,10 +3647,10 @@ function renderDoctorReport(report) {
3234
3647
  } else {
3235
3648
  for (const copy of report.skills.copies) {
3236
3649
  const state = copy.legacy || copy.stale || copy.pinned === null || copy.missing.length > 0 ? "warn" : "ok";
3237
- const pin = copy.legacy ? `leftover ${basename5(copy.path)} copy` : copy.pinned === null ? "version unknown" : `pins ${copy.pinned}`;
3650
+ const pin = copy.legacy ? `leftover ${basename6(copy.path)} copy` : copy.pinned === null ? "version unknown" : `pins ${copy.pinned}`;
3238
3651
  lines.push(` ${mark(state)} ${copy.harness}: ${copy.path} \u2014 ${pin}${copy.stale && !copy.legacy ? " (stale)" : ""}`);
3239
3652
  if (copy.legacy) {
3240
- lines.push(` fix: remove it and install the pptwise skill in ${join7(copy.root, SKILL_DIR_NAME)}`);
3653
+ lines.push(` fix: remove it and install the pptwise skill in ${join9(copy.root, SKILL_DIR_NAME)}`);
3241
3654
  } else if (copy.stale) {
3242
3655
  lines.push(` fix: ${skillRefreshCommand(copy.path)}`);
3243
3656
  } else if (copy.pinned === null) {
@@ -3353,9 +3766,9 @@ async function runDoctor(opts = {}) {
3353
3766
  }
3354
3767
 
3355
3768
  // src/cli/images.ts
3356
- import { mkdir as mkdir5, mkdtemp, readFile as readFile8, readdir as readdir6, rename, rm as rm2, unlink as unlink2, writeFile as writeFile4 } from "fs/promises";
3769
+ import { mkdir as mkdir6, mkdtemp, readFile as readFile8, readdir as readdir5, rename as rename2, rm as rm3, unlink as unlink2, writeFile as writeFile5 } from "fs/promises";
3357
3770
  import { tmpdir } from "os";
3358
- import { dirname as dirname4, join as join8, resolve as resolve7 } from "path";
3771
+ import { dirname as dirname6, join as join10, resolve as resolve8 } from "path";
3359
3772
 
3360
3773
  // src/cli/redact.ts
3361
3774
  var KEY_QUERY = /([?&](?:api_)?key=)[^&\s"'<>]*/gi;
@@ -3376,7 +3789,7 @@ var OPENVERSE_SEARCH_URL = "https://api.openverse.org/v1/images/";
3376
3789
  var OPENVERSE_TOKEN_URL = "https://api.openverse.org/v1/auth_tokens/token/";
3377
3790
  var OPENVERSE_PAGE_SIZE = 8;
3378
3791
  var TOKEN_SKEW_MS = 3e4;
3379
- var defaultSleep = (ms) => new Promise((resolve8) => setTimeout(resolve8, ms));
3792
+ var defaultSleep = (ms) => new Promise((resolve9) => setTimeout(resolve9, ms));
3380
3793
  var tokenCache = /* @__PURE__ */ new Map();
3381
3794
  function userAgent() {
3382
3795
  return `pptwise/${VERSION} (+https://pptwise.com)`;
@@ -3933,7 +4346,7 @@ function parsePhotoRef(ref) {
3933
4346
  }
3934
4347
  async function resolveDeckWorkspace(deckArg, cwd) {
3935
4348
  const [projectHit, userHit] = await Promise.all([findConfig(cwd), findUserConfig()]);
3936
- const decksDirSource = projectHit?.config.decksDir !== void 0 ? { decksDir: resolve7(dirname4(projectHit.path), projectHit.config.decksDir) } : userHit?.config;
4349
+ const decksDirSource = projectHit?.config.decksDir !== void 0 ? { decksDir: resolve8(dirname6(projectHit.path), projectHit.config.decksDir) } : userHit?.config;
3937
4350
  const target = await resolveDeckTarget(deckArg, decksDirSource, cwd);
3938
4351
  const isDir = await isDeckDirectory(target);
3939
4352
  const location = resolveWorkspaceLocation({
@@ -3943,7 +4356,7 @@ async function resolveDeckWorkspace(deckArg, cwd) {
3943
4356
  target,
3944
4357
  isDir
3945
4358
  });
3946
- return { location, assetsDir: join8(location.dir, ASSETS_DIRNAME) };
4359
+ return { location, assetsDir: join10(location.dir, ASSETS_DIRNAME) };
3947
4360
  }
3948
4361
  function assertSafeDownloadUrl(url) {
3949
4362
  let parsed;
@@ -4099,8 +4512,8 @@ async function runImagesFetch(ref, opts) {
4099
4512
  if (!apiKey) throw missingKeysError(provider);
4100
4513
  }
4101
4514
  const { assetsDir } = await resolveDeckWorkspace(opts.deck, cwd);
4102
- const jpgPath = join8(assetsDir, `${opts.as}.jpg`);
4103
- const jsonPath = join8(assetsDir, `${opts.as}.json`);
4515
+ const jpgPath = join10(assetsDir, `${opts.as}.jpg`);
4516
+ const jsonPath = join10(assetsDir, `${opts.as}.json`);
4104
4517
  if (await pathExists(jpgPath) && await pathExists(jsonPath)) {
4105
4518
  const existing = await readSidecar(jsonPath);
4106
4519
  if (existing && existing.photo_id === photoId && existing.provider === provider) {
@@ -4150,8 +4563,8 @@ async function runImagesFetch(ref, opts) {
4150
4563
  const apiLong = meta.width !== void 0 && meta.height !== void 0 ? Math.max(meta.width, meta.height) : void 0;
4151
4564
  const resize = opts.resizeToJpeg ?? defaultResizeToJpeg;
4152
4565
  const jpeg = await toJpeg(bytes, apiLong, resize);
4153
- await mkdir5(assetsDir, { recursive: true });
4154
- await writeFile4(jpgPath, jpeg);
4566
+ await mkdir6(assetsDir, { recursive: true });
4567
+ await writeFile5(jpgPath, jpeg);
4155
4568
  const sidecar = {
4156
4569
  provider,
4157
4570
  photo_id: photoId,
@@ -4167,7 +4580,7 @@ async function runImagesFetch(ref, opts) {
4167
4580
  if (/"apiKey"\s*:/.test(json) || /"key"\s*:/.test(json) || /"clientSecret"\s*:/.test(json)) {
4168
4581
  throw new PptwiseError("internal error: sidecar would have contained a key field");
4169
4582
  }
4170
- await writeFile4(jsonPath, json);
4583
+ await writeFile5(jsonPath, json);
4171
4584
  return `pinned ${provider}:${photoId} as ${opts.as} \u2192 ${jpgPath}`;
4172
4585
  }
4173
4586
  async function runImagesList(opts) {
@@ -4175,14 +4588,14 @@ async function runImagesList(opts) {
4175
4588
  const { assetsDir } = await resolveDeckWorkspace(opts.deck, cwd);
4176
4589
  let names;
4177
4590
  try {
4178
- names = (await readdir6(assetsDir)).filter((n) => n.endsWith(".json") && !n.startsWith(".")).sort();
4591
+ names = (await readdir5(assetsDir)).filter((n) => n.endsWith(".json") && !n.startsWith(".")).sort();
4179
4592
  } catch (e) {
4180
4593
  if (e.code === "ENOENT") return "No pinned stock photos.";
4181
4594
  throw e;
4182
4595
  }
4183
4596
  const lines = [];
4184
4597
  for (const name of names) {
4185
- const sidecar = await readSidecar(join8(assetsDir, name));
4598
+ const sidecar = await readSidecar(join10(assetsDir, name));
4186
4599
  if (!sidecar) continue;
4187
4600
  const assetId = name.slice(0, -".json".length);
4188
4601
  const id = sidecar.photo_id ? `${sidecar.provider}:${sidecar.photo_id}` : sidecar.provider;
@@ -4192,20 +4605,20 @@ async function runImagesList(opts) {
4192
4605
  return lines.length === 0 ? "No pinned stock photos." : lines.join("\n");
4193
4606
  }
4194
4607
  async function writePinnedAsset(assetsDir, assetId, jpeg, sidecar) {
4195
- await mkdir5(assetsDir, { recursive: true });
4196
- const jpgPath = join8(assetsDir, `${assetId}.jpg`);
4197
- const jsonPath = join8(assetsDir, `${assetId}.json`);
4198
- const tmpJpg = join8(assetsDir, `.${assetId}.jpg.tmp`);
4199
- const tmpJson = join8(assetsDir, `.${assetId}.json.tmp`);
4608
+ await mkdir6(assetsDir, { recursive: true });
4609
+ const jpgPath = join10(assetsDir, `${assetId}.jpg`);
4610
+ const jsonPath = join10(assetsDir, `${assetId}.json`);
4611
+ const tmpJpg = join10(assetsDir, `.${assetId}.jpg.tmp`);
4612
+ const tmpJson = join10(assetsDir, `.${assetId}.json.tmp`);
4200
4613
  const json = JSON.stringify(sidecar, null, 2) + "\n";
4201
4614
  if (/"apiKey"\s*:/.test(json) || /"key"\s*:/.test(json) || /"clientSecret"\s*:/.test(json)) {
4202
4615
  throw new PptwiseError("internal error: sidecar would have contained a key field");
4203
4616
  }
4204
4617
  try {
4205
- await writeFile4(tmpJpg, jpeg);
4206
- await writeFile4(tmpJson, json);
4207
- await rename(tmpJpg, jpgPath);
4208
- await rename(tmpJson, jsonPath);
4618
+ await writeFile5(tmpJpg, jpeg);
4619
+ await writeFile5(tmpJson, json);
4620
+ await rename2(tmpJpg, jpgPath);
4621
+ await rename2(tmpJson, jsonPath);
4209
4622
  } catch (e) {
4210
4623
  await unlink2(tmpJpg).catch(() => void 0);
4211
4624
  await unlink2(tmpJson).catch(() => void 0);
@@ -4247,8 +4660,8 @@ async function runImagesGenerate(opts) {
4247
4660
  throw new PptwiseError(`No image generator is enabled. Found but disabled: ${listed}`);
4248
4661
  }
4249
4662
  const { assetsDir } = await resolveDeckWorkspace(opts.deck, cwd);
4250
- const workdir = await mkdtemp(join8(tmpdir(), "pptwise-gen-"));
4251
- const dest = join8(workdir, "generated.jpg");
4663
+ const workdir = await mkdtemp(join10(tmpdir(), "pptwise-gen-"));
4664
+ const dest = join10(workdir, "generated.jpg");
4252
4665
  const run = opts.run ?? defaultProcessRunner;
4253
4666
  const attempts = [];
4254
4667
  try {
@@ -4290,7 +4703,7 @@ async function runImagesGenerate(opts) {
4290
4703
  }
4291
4704
  throw new PptwiseError(`All image generators failed: ${attempts.join("; ")}`);
4292
4705
  } finally {
4293
- await rm2(workdir, { recursive: true, force: true });
4706
+ await rm3(workdir, { recursive: true, force: true });
4294
4707
  }
4295
4708
  }
4296
4709
 
@@ -4298,12 +4711,17 @@ async function runImagesGenerate(opts) {
4298
4711
  import { watch } from "fs";
4299
4712
  import { createServer } from "http";
4300
4713
  import { platform as osPlatform } from "os";
4301
- import { join as join9 } from "path";
4714
+ import { dirname as dirname7, join as join11 } from "path";
4302
4715
  var DEFAULT_PORT = 4400;
4303
4716
  var DEBOUNCE_MS = 200;
4304
4717
  var HEARTBEAT_MS = 3e4;
4305
4718
  function watchRoots(resolvedTarget, isDir, extra = []) {
4306
- const roots = isDir ? [join9(resolvedTarget, SPEC_FILENAME), join9(resolvedTarget, PAGES_DIRNAME), join9(resolvedTarget, ASSETS_DIRNAME)] : [resolvedTarget];
4719
+ const roots = isDir ? [
4720
+ join11(resolvedTarget, SPEC_FILENAME),
4721
+ join11(resolvedTarget, PAGES_DIRNAME),
4722
+ join11(resolvedTarget, ASSETS_DIRNAME),
4723
+ join11(resolvedTarget, THEME_FILENAME)
4724
+ ] : [resolvedTarget];
4307
4725
  return [...roots, ...extra];
4308
4726
  }
4309
4727
  var SERVE_CLIENT_SCRIPT_ID = "pptwise-serve-client";
@@ -4366,7 +4784,7 @@ async function createServeServer(options) {
4366
4784
  if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) {
4367
4785
  throw new PptwiseError(`invalid port ${requestedPort} \u2014 expected an integer between 0 and 65535`);
4368
4786
  }
4369
- const initial = await buildDeckPreview(options.target, { cwd, themeFilePath: options.themeFilePath });
4787
+ const initial = await buildDeckPreview(options.target, { cwd });
4370
4788
  let cachedHtml = injectServeClient(initial.html);
4371
4789
  const sseClients = /* @__PURE__ */ new Set();
4372
4790
  function writeToAll(chunk) {
@@ -4385,7 +4803,7 @@ data: ${JSON.stringify(data)}
4385
4803
  }
4386
4804
  async function rebuild() {
4387
4805
  try {
4388
- const result = await buildDeckPreview(options.target, { cwd, themeFilePath: options.themeFilePath });
4806
+ const result = await buildDeckPreview(options.target, { cwd });
4389
4807
  cachedHtml = injectServeClient(result.html);
4390
4808
  broadcast("reload", {});
4391
4809
  } catch (e) {
@@ -4424,7 +4842,7 @@ data: ${JSON.stringify(data)}
4424
4842
  }, DEBOUNCE_MS);
4425
4843
  }
4426
4844
  const projectHit = await findConfig(cwd);
4427
- const workspaceAssets = join9(
4845
+ const workspaceAssets = join11(
4428
4846
  resolveWorkspaceLocation({
4429
4847
  cwd,
4430
4848
  projectConfigPath: projectHit?.path,
@@ -4434,8 +4852,17 @@ data: ${JSON.stringify(data)}
4434
4852
  }).dir,
4435
4853
  ASSETS_DIRNAME
4436
4854
  );
4855
+ const extraWatch = [workspaceAssets];
4856
+ try {
4857
+ const resolvedTheme = await resolveThemeByName(initial.ir.theme.id, {
4858
+ startDir: cwd,
4859
+ deckDir: initial.isDir ? initial.resolvedTarget : dirname7(initial.resolvedTarget)
4860
+ });
4861
+ if (resolvedTheme.kind === "file") extraWatch.push(resolvedTheme.path);
4862
+ } catch {
4863
+ }
4437
4864
  const watchers = [];
4438
- for (const path2 of watchRoots(initial.resolvedTarget, initial.isDir, [workspaceAssets])) {
4865
+ for (const path2 of watchRoots(initial.resolvedTarget, initial.isDir, extraWatch)) {
4439
4866
  try {
4440
4867
  watchers.push(watch(path2, () => scheduleRebuild()));
4441
4868
  } catch (e) {
@@ -4496,7 +4923,7 @@ function openBrowser(url) {
4496
4923
  }
4497
4924
  }
4498
4925
  async function runServe(target, opts = {}) {
4499
- const handle = await createServeServer({ target, port: opts.port, cwd: opts.cwd, themeFilePath: opts.themeFilePath });
4926
+ const handle = await createServeServer({ target, port: opts.port, cwd: opts.cwd });
4500
4927
  console.log(`pptwise serve: ${handle.url} (Ctrl+C to stop)`);
4501
4928
  if (opts.open !== false) openBrowser(handle.url);
4502
4929
  process.on("SIGINT", () => {
@@ -4515,7 +4942,7 @@ function fail(e) {
4515
4942
  console.error(e instanceof Error ? e.message : String(e));
4516
4943
  process.exit(1);
4517
4944
  }
4518
- program.command("render").description("Render an IR JSON file, deck project directory, or bare deck name to a .pptx").argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptwise/decks").option("-o, --output <file>", "output .pptx path (default: .pptwise/<deck>/<deck>.pptx under the project root)").option("--theme <id>", "override the deck theme (see `pptwise themes`)").option("--theme-file <path>", "load a custom theme file (see `pptwise brand extract`) and render with it").option("--style <path>", "style overrides JSON re-coloring the theme (see `pptwise schema --style`)").option("--draft", "allow unfilled placeholder pages (skip the draft gate)").option(
4945
+ program.command("render").description("Render an IR JSON file, deck project directory, or bare deck name to a .pptx").argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptwise/decks").option("-o, --output <file>", "output .pptx path (default: .pptwise/<deck>/<deck>.pptx under the project root)").option("--draft", "allow unfilled placeholder pages (skip the draft gate)").option(
4519
4946
  "--allow-dropped-content",
4520
4947
  "export anyway when a page holds more than fits and the layout drops blocks (skip the content-drop gate)"
4521
4948
  ).option("--no-git-ignore", "do not add .pptwise/ to this repository's local exclude file").action(
@@ -4524,9 +4951,6 @@ program.command("render").description("Render an IR JSON file, deck project dire
4524
4951
  console.log(
4525
4952
  await runRender(target, {
4526
4953
  output: opts.output,
4527
- theme: opts.theme,
4528
- themeFilePath: opts.themeFile,
4529
- stylePath: opts.style,
4530
4954
  draft: opts.draft,
4531
4955
  allowDroppedContent: opts.allowDroppedContent,
4532
4956
  gitIgnore: opts.gitIgnore,
@@ -4538,21 +4962,20 @@ program.command("render").description("Render an IR JSON file, deck project dire
4538
4962
  }
4539
4963
  }
4540
4964
  );
4541
- program.command("validate").description("Validate an IR JSON file, deck project directory, or bare deck name against the schema").argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptwise/decks").option("--theme-file <path>", "load a custom theme file (see `pptwise brand extract`) before validating").action(async (target, opts) => {
4965
+ program.command("validate").description("Validate an IR JSON file, deck project directory, or bare deck name against the schema").argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptwise/decks").action(async (target) => {
4542
4966
  try {
4543
- console.log(await runValidate(target, process.cwd(), { themeFilePath: opts.themeFile }));
4967
+ console.log(await runValidate(target, process.cwd()));
4544
4968
  } catch (e) {
4545
4969
  fail(e);
4546
4970
  }
4547
4971
  });
4548
4972
  program.command("audit").description(
4549
- "Deterministic geometry audit (overflow, out-of-bounds, low-contrast, overlap, content-truncated, content-dropped), plus an optional --pixels contrast pass \u2014 exits 1 when it finds anything"
4550
- ).argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptwise/decks").option("--json", "machine-readable output (the full AuditReport)").option("--pixels", "also run the optional pixel-contrast pass over image-backed text (requires sharp)").option("--theme-file <path>", "load a custom theme file (see `pptwise brand extract`) and audit with it").action(async (target, opts) => {
4973
+ "Deterministic geometry audit (overflow, out-of-bounds, low-contrast, overlap, content-truncated, content-dropped, monotony), plus an optional --pixels contrast pass \u2014 exits 1 when it finds anything"
4974
+ ).argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptwise/decks").option("--json", "machine-readable output (the full AuditReport)").option("--pixels", "also run the optional pixel-contrast pass over image-backed text (requires sharp)").action(async (target, opts) => {
4551
4975
  try {
4552
4976
  const { output, hasFindings } = await runAudit(target, {
4553
4977
  json: opts.json,
4554
- pixels: opts.pixels,
4555
- themeFilePath: opts.themeFile
4978
+ pixels: opts.pixels
4556
4979
  });
4557
4980
  console.log(output);
4558
4981
  if (hasFindings) process.exit(1);
@@ -4569,15 +4992,8 @@ program.command("asset-brief").description(
4569
4992
  fail(e);
4570
4993
  }
4571
4994
  });
4572
- program.command("schema").description("Print the IR JSON Schema (feed this to a model before it writes IR)").option("--style", "print the style-override schema instead").option("--spec", "print the deck spec schema instead").option("--plan", "removed \u2014 use --spec instead").action((opts) => {
4573
- if (opts.plan) {
4574
- fail(new Error("`pptwise schema --plan` has been renamed to `pptwise schema --spec` \u2014 run `pptwise schema --spec` instead"));
4575
- }
4576
- console.log(runSchema(opts.spec ? "spec" : opts.style ? "style" : void 0));
4577
- });
4578
- var plan = program.command("plan").description("Removed \u2014 use `pptwise spec` instead");
4579
- plan.command("validate").description("Removed \u2014 use `pptwise spec validate` instead").argument("<file>").action(() => {
4580
- fail(new Error("`pptwise plan validate` has been renamed to `pptwise spec validate` \u2014 run `pptwise spec validate <file>` instead"));
4995
+ program.command("schema").description("Print the IR JSON Schema (feed this to a model before it writes IR)").option("--spec", "print the deck spec schema instead").action((opts) => {
4996
+ console.log(runSchema(opts.spec ? "spec" : void 0));
4581
4997
  });
4582
4998
  var spec = program.command("spec").description("Deck spec commands (spec \xA76)");
4583
4999
  spec.command("validate").description("Validate a deck spec JSON file against the schema and strategy-aware hard gates").argument("<spec.json>").action(async (specPath) => {
@@ -4601,28 +5017,62 @@ program.command("disassemble").description("Split an IR JSON file into a deck pr
4601
5017
  fail(e);
4602
5018
  }
4603
5019
  });
4604
- program.command("migrate").description("Convert a v3 IR file to v4, rewrite chrome \u2192 branding, bloom \u2192 classroom, logo_wall \u2192 image_grid, or banner-heading \u2192 two-column on a v4 IR or deck spec, or convert a deck.plan.json project directory to deck.spec.json \u2014 deterministic, no model").argument("<input>", "IR v3 JSON file, a v4 IR or deck spec still carrying chrome, bloom, logo_wall, or banner-heading, or a deck project directory containing deck.plan.json").requiredOption("-o, --output <output>", "output path \u2014 an IR JSON file for a file input, a directory for a deck-project-directory input").action(async (input, opts) => {
5020
+ program.command("themes").description("List built-in themes").option("--json", "machine-readable output").action((opts) => console.log(runThemes(Boolean(opts.json))));
5021
+ var theme = program.command("theme").description("Copy, fork, and compare themes");
5022
+ theme.command("new").description("Copy a preset or named theme into a self-contained v2 theme file").requiredOption("--from <preset>", "preset or theme name to copy").option("-o, --output <path>", "output theme JSON path").option("--id <id>", "theme id (default: slug of the output filename)").option("--label <label>", "human-readable theme label").option("--force", "overwrite an existing theme file").addHelpText("after", "\nExample:\n $ pptwise theme new --from consulting -o themes/acme.theme.json").action(async (opts) => {
4605
5023
  try {
4606
- console.log(await runMigrate(input, opts.output));
5024
+ console.log(await runThemeNew({ from: opts.from, output: opts.output, id: opts.id, label: opts.label, force: opts.force, cwd: process.cwd() }));
4607
5025
  } catch (e) {
4608
5026
  fail(e);
4609
5027
  }
4610
5028
  });
4611
- program.command("themes").description("List built-in themes").option("--json", "machine-readable output").action((opts) => console.log(runThemes(Boolean(opts.json))));
5029
+ theme.command("fork").description("Copy a theme and rederive tokens around new anchor colors").argument("<name>", "preset or theme name to fork").requiredOption("--primary <hex>", "new primary color").option("--bg <hex>", "new background color").option("--accent <hex>", "new accent color").option("--text <hex>", "new text color").option("--surface <hex>", "new surface color").option("-o, --output <path>", "output theme JSON path").option("--id <id>", "theme id (default: slug of the output filename)").option("--label <label>", "human-readable theme label").option("--force", "overwrite an existing theme file").addHelpText("after", "\nExample:\n $ pptwise theme fork acme --primary #0B5FFF -o themes/acme-blue.theme.json").action(
5030
+ async (name, opts) => {
5031
+ try {
5032
+ console.log(
5033
+ await runThemeFork(name, {
5034
+ primary: opts.primary,
5035
+ bg: opts.bg,
5036
+ accent: opts.accent,
5037
+ text: opts.text,
5038
+ surface: opts.surface,
5039
+ output: opts.output,
5040
+ id: opts.id,
5041
+ label: opts.label,
5042
+ force: opts.force,
5043
+ cwd: process.cwd()
5044
+ })
5045
+ );
5046
+ } catch (e) {
5047
+ fail(e);
5048
+ }
5049
+ }
5050
+ );
5051
+ theme.command("try").description("Render the fitting-room sample across 2-4 themes into a contact sheet").argument("<ids>", "comma-separated theme ids (2-4)").option("-o, --output <dir>", "output directory (default: .pptwise/theme-try/)").addHelpText("after", "\nExample:\n $ pptwise theme try consulting,swiss,memo").action(async (ids, opts) => {
5052
+ try {
5053
+ console.log(await runThemeTry(ids, { output: opts.output, cwd: process.cwd() }));
5054
+ } catch (e) {
5055
+ fail(e);
5056
+ }
5057
+ });
5058
+ program.command("layouts").description("List registered layouts").option("--json", "machine-readable output").action((opts) => console.log(runLayouts(Boolean(opts.json))));
4612
5059
  var brand = program.command("brand").description("Brand asset commands \u2014 extract your company's colors/fonts from an Office template");
4613
5060
  brand.command("extract").description(
4614
5061
  "Extract brand colors and fonts from a .thmx/.potx/.pptx file into a pptwise theme file \u2014 runs entirely locally, the file never leaves your machine"
4615
- ).argument("<file>", "a .thmx theme, .potx template, or .pptx presentation").requiredOption("-o, --output <file>", "output theme JSON path (e.g. my-brand.theme.json)").option("--id <id>", "theme id to register under (default: slug of the output filename)").option("--label <label>", "human-readable theme label (default: the source theme's color-scheme name)").action(async (file, opts) => {
5062
+ ).argument("<file>", "a .thmx theme, .potx template, or .pptx presentation").requiredOption("-o, --output <file>", "output theme JSON path (e.g. my-brand.theme.json)").option("--id <id>", "theme id to register under (default: slug of the output filename)").option("--label <label>", "human-readable theme label (default: the source theme's color-scheme name)").option("--from <preset>", "donor preset whose menu is copied (default: consulting)").option("--force", "overwrite an existing theme file").addHelpText("after", "\nExample:\n $ pptwise brand extract corp.pptx -o themes/acme.theme.json --from consulting").action(async (file, opts) => {
4616
5063
  try {
4617
- console.log(await runBrandExtract(file, { output: opts.output, id: opts.id, label: opts.label }));
5064
+ console.log(await runBrandExtract(file, {
5065
+ output: opts.output,
5066
+ id: opts.id,
5067
+ label: opts.label,
5068
+ from: opts.from,
5069
+ force: opts.force
5070
+ }));
4618
5071
  } catch (e) {
4619
5072
  fail(e);
4620
5073
  }
4621
5074
  });
4622
5075
  program.command("narratives").description("List named narrative presets (strategy/pacing/audience axes + theme recommendations)").option("--json", "machine-readable output").action((opts) => console.log(runNarratives(Boolean(opts.json))));
4623
- program.command("scenarios").description("Removed \u2014 use `pptwise narratives` instead").action(() => {
4624
- fail(new Error("`pptwise scenarios` has been renamed to `pptwise narratives` \u2014 run `pptwise narratives` instead"));
4625
- });
4626
5076
  var config = program.command("config").description("User-level settings (API keys for optional stock-photo search)");
4627
5077
  config.command("set <key> [value]").description("Set a user config value. Omit the value for an apiKey or clientSecret to enter it at a hidden prompt").action(async (key, value) => {
4628
5078
  try {
@@ -4693,12 +5143,11 @@ program.command("init").description("Scaffold a pptwise.config.json in the curre
4693
5143
  fail(e);
4694
5144
  }
4695
5145
  });
4696
- program.command("preview").description("Render each slide to an SVG file for visual self-check").argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptwise/decks").option("-o, --output <dir>", "output directory (default: .pptwise/<deck>/ under the project root)").option("--html", "also write a self-contained preview.html (all slides inlined \u2014 thumbnail strip, keyboard navigation) for human review").option("--theme-file <path>", "load a custom theme file (see `pptwise brand extract`) and preview with it").option("--no-git-ignore", "do not add .pptwise/ to this repository's local exclude file").action(async (target, opts) => {
5146
+ program.command("preview").description("Render each slide to an SVG file for visual self-check").argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptwise/decks").option("-o, --output <dir>", "output directory (default: .pptwise/<deck>/ under the project root)").option("--html", "also write a self-contained preview.html (all slides inlined \u2014 thumbnail strip, keyboard navigation) for human review").option("--no-git-ignore", "do not add .pptwise/ to this repository's local exclude file").action(async (target, opts) => {
4697
5147
  try {
4698
5148
  console.log(
4699
5149
  await runPreview(target, opts.output, {
4700
5150
  htmlOut: opts.html,
4701
- themeFilePath: opts.themeFile,
4702
5151
  gitIgnore: opts.gitIgnore,
4703
5152
  cwd: process.cwd()
4704
5153
  })
@@ -4707,7 +5156,7 @@ program.command("preview").description("Render each slide to an SVG file for vis
4707
5156
  fail(e);
4708
5157
  }
4709
5158
  });
4710
- program.command("serve").description("Serve a live-reloading HTML preview of an IR JSON file, deck project directory, or bare deck name over HTTP").argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptwise/decks").option("--port <number>", `port to listen on (default ${DEFAULT_PORT})`).option("--no-open", "do not open the URL in a browser after starting").option("--theme-file <path>", "load a custom theme file (see `pptwise brand extract`) and serve with it").action(async (target, opts) => {
5159
+ program.command("serve").description("Serve a live-reloading HTML preview of an IR JSON file, deck project directory, or bare deck name over HTTP").argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptwise/decks").option("--port <number>", `port to listen on (default ${DEFAULT_PORT})`).option("--no-open", "do not open the URL in a browser after starting").action(async (target, opts) => {
4711
5160
  try {
4712
5161
  let port;
4713
5162
  if (opts.port !== void 0) {
@@ -4716,7 +5165,7 @@ program.command("serve").description("Serve a live-reloading HTML preview of an
4716
5165
  fail(new Error(`invalid --port value "${opts.port}" \u2014 expected an integer`));
4717
5166
  }
4718
5167
  }
4719
- await runServe(target, { port, open: opts.open, themeFilePath: opts.themeFile });
5168
+ await runServe(target, { port, open: opts.open });
4720
5169
  } catch (e) {
4721
5170
  fail(e);
4722
5171
  }