@liustack/pptfast 0.3.0 → 0.4.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.
package/dist/cli.js CHANGED
@@ -1,35 +1,40 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ PptxIRV3Schema,
4
+ VERSION,
5
+ assembleDeck,
6
+ disassembleDeck,
7
+ formatInvalidSpecError,
8
+ migrateDeckPlanToSpec,
9
+ migrateIrV3ToV4,
10
+ resolveSpecThemeId,
11
+ specJsonSchema,
12
+ validateSpec
13
+ } from "./chunk-4W2YZPJJ.js";
14
+ import {
15
+ installNodePlatform
16
+ } from "./chunk-HFRNKYZ6.js";
2
17
  import {
3
18
  AUDIENCE_VALUES,
4
- DELIVERY_BUDGETS,
5
- MODE_DEFINITIONS,
19
+ NARRATIVE_PRESETS,
20
+ PACING_BUDGETS,
6
21
  PptfastError,
7
- SCENARIO_PRESETS,
22
+ STRATEGY_DEFINITIONS,
8
23
  StyleOverrideSchema,
9
- VERSION,
10
- assembleDeck,
11
24
  auditDeck,
12
- disassembleDeck,
13
- formatInvalidPlanError,
14
25
  formatIssues,
15
26
  generatePptx,
16
27
  getInstalledThemeIds,
17
28
  irJsonSchema,
18
29
  listThemes,
19
- planJsonSchema,
20
30
  renderSlideSvg,
21
- resolvePlanThemeId,
22
- resolveScenario,
31
+ resolveNarrative,
23
32
  styleJsonSchema,
24
- validateIr,
25
- validatePlan
26
- } from "./chunk-ZXWCOWJI.js";
27
- import {
28
- installNodePlatform
29
- } from "./chunk-SROEW6BH.js";
33
+ validateIr
34
+ } from "./chunk-7N4HGSMW.js";
30
35
  import {
31
36
  getPlatform
32
- } from "./chunk-G76EVNVT.js";
37
+ } from "./chunk-L524YK63.js";
33
38
 
34
39
  // src/cli.ts
35
40
  import { Command } from "commander";
@@ -163,6 +168,7 @@ async function resolveLocalAssets(ir, baseDir) {
163
168
 
164
169
  // src/cli/deck-dir.ts
165
170
  var PLAN_FILENAME = "deck.plan.json";
171
+ var SPEC_FILENAME = "deck.spec.json";
166
172
  var PAGES_DIRNAME = "pages";
167
173
  var ASSETS_DIRNAME = "assets";
168
174
  function assertSafeFileSegment(id, context) {
@@ -202,31 +208,43 @@ async function resolveDeckTarget(arg, config, cwd = process.cwd()) {
202
208
  }
203
209
  function expectedLayoutHint() {
204
210
  const rows = [
205
- [PLAN_FILENAME, "the locked plan (see `pptfast plan validate`)"],
211
+ [SPEC_FILENAME, "the locked spec (see `pptfast spec validate`)"],
206
212
  [`${PAGES_DIRNAME}/<page-id>.json`, "one file per filled page (missing pages become placeholders)"],
207
213
  [`${ASSETS_DIRNAME}/`, "optional local images"]
208
214
  ];
209
215
  const width = Math.max(...rows.map(([name]) => name.length)) + 2;
210
216
  return rows.map(([name, desc]) => ` ${name.padEnd(width)}${desc}`).join("\n");
211
217
  }
212
- async function readPlanFile(dir) {
218
+ async function readSpecFile(dir) {
219
+ const specPath = join3(dir, SPEC_FILENAME);
213
220
  const planPath = join3(dir, PLAN_FILENAME);
221
+ const [specExists, planExists] = await Promise.all([pathExists(specPath), pathExists(planPath)]);
222
+ if (specExists && planExists) {
223
+ throw new PptfastError(
224
+ `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 (\`pptfast migrate\` never deletes the source file it read)`
225
+ );
226
+ }
227
+ if (!specExists && planExists) {
228
+ throw new PptfastError(
229
+ `${dir} has ${PLAN_FILENAME} but no ${SPEC_FILENAME} \u2014 deck project directories now use ${SPEC_FILENAME}. Run \`pptfast migrate ${dir} -o ${dir}\` to convert it`
230
+ );
231
+ }
214
232
  let text;
215
233
  try {
216
- text = await readFile3(planPath, "utf8");
234
+ text = await readFile3(specPath, "utf8");
217
235
  } catch (e) {
218
236
  if (e.code === "ENOENT") {
219
237
  throw new PptfastError(
220
- `no ${PLAN_FILENAME} in ${dir} \u2014 expected a deck project directory:
238
+ `no ${SPEC_FILENAME} in ${dir} \u2014 expected a deck project directory:
221
239
  ${expectedLayoutHint()}`
222
240
  );
223
241
  }
224
- throw new PptfastError(`cannot read plan file: ${planPath}`);
242
+ throw new PptfastError(`cannot read spec file: ${specPath}`);
225
243
  }
226
244
  try {
227
245
  return JSON.parse(text);
228
246
  } catch (e) {
229
- throw new PptfastError(`plan file ${planPath} is not valid JSON: ${e.message}`);
247
+ throw new PptfastError(`spec file ${specPath} is not valid JSON: ${e.message}`);
230
248
  }
231
249
  }
232
250
  async function readPages(dir) {
@@ -273,9 +291,9 @@ async function scanAssets(dir) {
273
291
  }
274
292
  async function readDeckDir(dir) {
275
293
  const deckDir = resolve4(dir);
276
- const plan2 = await readPlanFile(deckDir);
294
+ const spec2 = await readSpecFile(deckDir);
277
295
  const pages = await readPages(deckDir);
278
- const { ir, generatedSeed, materializedLayoutCount } = assembleDeck(plan2, pages);
296
+ const { ir, generatedSeed, materializedLayoutCount } = assembleDeck(spec2, pages);
279
297
  const images = await scanAssets(deckDir);
280
298
  const merged = { ...ir, assets: { images: { ...ir.assets.images, ...images } } };
281
299
  return { ir: merged, generatedSeed, materializedLayoutCount, deckDir };
@@ -324,10 +342,18 @@ async function writeOneAsset(id, src, assetsDir, sourceBaseDir) {
324
342
  function escapeHtml(s) {
325
343
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
326
344
  }
327
- function slideNode(slide) {
345
+ function embedJson(value) {
346
+ return JSON.stringify(value).replace(/</g, "\\u003c");
347
+ }
348
+ function findingBadge(count, className) {
349
+ if (count === 0) return "";
350
+ return `<div class="${className}" aria-hidden="true">${count}</div>`;
351
+ }
352
+ function slideNode(slide, findingCount) {
328
353
  const idAttr = slide.id !== void 0 ? ` data-id="${escapeHtml(slide.id)}"` : "";
329
354
  const badge = slide.placeholder ? `<div class="pf-badge" aria-hidden="true">unfilled</div>` : "";
330
- return `<div class="pf-slide" id="pf-slide-${slide.index}" data-index="${slide.index}"${idAttr}>${badge}${slide.svg}</div>`;
355
+ const fBadge = findingBadge(findingCount, "pf-finding-badge");
356
+ return `<div class="pf-slide" id="pf-slide-${slide.index}" data-index="${slide.index}"${idAttr}>${badge}${fBadge}${slide.svg}</div>`;
331
357
  }
332
358
  function thumbDescription(slide) {
333
359
  const parts = [`slide ${slide.index + 1}`, slide.type];
@@ -343,10 +369,15 @@ function counterText(slide, total) {
343
369
  const idPart = slide.id !== void 0 ? ` \xB7 ${slide.id}` : "";
344
370
  return escapeHtml(`${slide.index + 1} / ${total}${idPart}`);
345
371
  }
346
- function thumbButton(slide, isActive, slotContent) {
372
+ function thumbButton(slide, isActive, slotContent, findingCount) {
347
373
  const description = thumbDescription(slide);
348
374
  const badge = slide.placeholder ? `<span class="pf-thumb-badge" aria-hidden="true">unfilled</span>` : "";
349
- return `<button type="button" class="pf-thumb${isActive ? " pf-thumb-active" : ""}" id="pf-thumb-${slide.index}" data-index="${slide.index}" title="${description}" aria-label="${description}"><span class="pf-thumb-slot" id="pf-slot-${slide.index}">${slotContent}</span><span class="pf-thumb-label">${positionLabel(slide)}</span>${badge}</button>`;
375
+ const fBadge = findingBadge(findingCount, "pf-thumb-finding-badge");
376
+ return `<button type="button" class="pf-thumb${isActive ? " pf-thumb-active" : ""}" id="pf-thumb-${slide.index}" data-index="${slide.index}" title="${description}" aria-label="${description}"><span class="pf-thumb-slot" id="pf-slot-${slide.index}">${slotContent}</span><span class="pf-thumb-label">${positionLabel(slide)}</span>${badge}${fBadge}</button>`;
377
+ }
378
+ function findingPanelEntry(f) {
379
+ const idPart = f.slideId !== void 0 ? ` \xB7 ${escapeHtml(f.slideId)}` : "";
380
+ return `<button type="button" class="pf-finding" data-page-index="${f.page - 1}"><span class="pf-finding-loc">page ${f.page}${idPart}</span><span class="pf-finding-code">[${escapeHtml(f.code)}]</span> <span class="pf-finding-msg">${escapeHtml(f.message)}</span></button>`;
350
381
  }
351
382
  var CSS = `
352
383
  :root{color-scheme:light}
@@ -356,11 +387,31 @@ body{display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystem
356
387
  header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:#fff;border-bottom:1px solid #ddd;font-size:14px;flex:0 0 auto}
357
388
  #pf-title{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
358
389
  #pf-counter{font-variant-numeric:tabular-nums;color:#555;white-space:nowrap}
359
- #pf-stage-wrap{flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;padding:16px}
390
+ #pf-audit-note{color:#b45309;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
391
+ #pf-export-btn{font:inherit;font-size:13px;padding:6px 10px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;cursor:pointer;white-space:nowrap;flex:0 0 auto}
392
+ #pf-export-btn:hover{background:#1d4ed8}
393
+ #pf-stage-wrap{flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;gap:16px;padding:16px}
360
394
  #pf-stage{position:relative;background:#000;box-shadow:0 2px 16px rgba(0,0,0,.15);aspect-ratio:16/9;width:min(100%,calc((100vh - 190px) * 16 / 9));max-height:100%}
361
395
  #pf-stage,.pf-thumb-slot{position:relative}
362
396
  .pf-slide{position:absolute;inset:0}
363
397
  .pf-slide svg{display:block;width:100%;height:100%}
398
+ #pf-side{flex:0 0 260px;align-self:stretch;overflow-y:auto;background:#fff;border:1px solid #ddd;border-radius:6px;padding:12px;font-size:13px}
399
+ #pf-side h2{margin:0 0 8px;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:#666}
400
+ #pf-side section+section{margin-top:16px;padding-top:16px;border-top:1px solid #eee}
401
+ #pf-audit-checks{margin:0 0 12px;font-size:12px;color:#555}
402
+ .pf-finding{display:block;width:100%;text-align:left;background:#fff;border:1px solid #eee;border-radius:4px;padding:6px 8px;margin-bottom:6px;cursor:pointer;font:inherit}
403
+ .pf-finding:hover{border-color:#93c5fd}
404
+ .pf-finding-loc{display:block;font-size:11px;color:#888}
405
+ .pf-finding-code{display:inline-block;font-size:11px;font-weight:700;color:#b91c1c}
406
+ .pf-finding-msg{font-size:12px;color:#333}
407
+ #pf-annotate-current-label{font-size:11px;color:#888;margin-bottom:6px}
408
+ #pf-annotate-list{list-style:none;margin:0 0 8px;padding:0}
409
+ .pf-annotate-item{display:flex;justify-content:space-between;gap:6px;align-items:flex-start;padding:4px 0;border-bottom:1px solid #f0f0f0;font-size:12px}
410
+ .pf-annotate-remove{border:none;background:none;color:#999;cursor:pointer;font-size:14px;line-height:1;padding:0 2px}
411
+ .pf-annotate-remove:hover{color:#dc2626}
412
+ #pf-annotate-input{width:100%;box-sizing:border-box;font:inherit;font-size:12px;padding:6px;border:1px solid #ddd;border-radius:4px;resize:vertical}
413
+ #pf-annotate-add{font:inherit;font-size:12px;margin-top:6px;width:100%;padding:6px 10px;border:1px solid #2563eb;background:#2563eb;color:#fff;border-radius:4px;cursor:pointer}
414
+ #pf-annotate-add:hover{background:#1d4ed8}
364
415
  #pf-filmstrip{display:flex;gap:8px;padding:10px 16px;overflow-x:auto;background:#fff;border-top:1px solid #ddd;flex:0 0 auto}
365
416
  .pf-thumb{flex:0 0 auto;width:160px;padding:0;margin:0;border:2px solid transparent;background:#eee;cursor:pointer;border-radius:6px;overflow:hidden;position:relative;font:inherit;text-align:left}
366
417
  .pf-thumb:hover{border-color:#93c5fd}
@@ -368,6 +419,7 @@ header{display:flex;align-items:center;justify-content:space-between;gap:12px;pa
368
419
  .pf-thumb-slot{display:block;width:100%;aspect-ratio:16/9;background:#ddd}
369
420
  .pf-thumb-label{display:block;font-size:11px;line-height:1.4;padding:3px 6px;color:#444;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
370
421
  .pf-badge,.pf-thumb-badge{position:absolute;top:4px;right:4px;background:#d97706;color:#fff;font-size:10px;font-weight:700;letter-spacing:.03em;padding:2px 6px;border-radius:3px;text-transform:uppercase;z-index:2;pointer-events:none}
422
+ .pf-finding-badge,.pf-thumb-finding-badge{position:absolute;top:4px;left:4px;background:#dc2626;color:#fff;font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;z-index:2;pointer-events:none}
371
423
  `.trim();
372
424
  var JS = `
373
425
  (function () {
@@ -411,6 +463,7 @@ var JS = `
411
463
  nextThumb.classList.add('pf-thumb-active')
412
464
  current = i
413
465
  updateCounter(i)
466
+ renderAnnotations()
414
467
  }
415
468
 
416
469
  thumbs.forEach(function (t) {
@@ -427,15 +480,129 @@ var JS = `
427
480
  activate(parseInt(thumbs[pos - 1].getAttribute('data-index'), 10))
428
481
  }
429
482
  })
483
+
484
+ // ---- audit findings panel: click a finding, jump to its page (same
485
+ // activate() every thumbnail click already uses) ----
486
+ Array.prototype.slice.call(document.querySelectorAll('.pf-finding')).forEach(function (b) {
487
+ b.addEventListener('click', function () {
488
+ activate(parseInt(b.getAttribute('data-page-index'), 10))
489
+ })
490
+ })
491
+
492
+ // ---- annotations: in-memory only, keyed by the slide's 0-based array
493
+ // index (current) \u2014 never by pageId, since an object key coerces every
494
+ // key to a string and would silently collide a numeric page-fallback
495
+ // pageId with a same-looking slide id (or lose the numeric/string type
496
+ // distinction the exported JSON needs); pageIdFor() below resolves the
497
+ // real pageId fresh from the DOM only at render/export time. ----
498
+ var annotations = {}
499
+ var annotateList = document.getElementById('pf-annotate-list')
500
+ var annotateInput = document.getElementById('pf-annotate-input')
501
+ var annotateLabel = document.getElementById('pf-annotate-current-label')
502
+ var annotateAdd = document.getElementById('pf-annotate-add')
503
+ var exportBtn = document.getElementById('pf-export-btn')
504
+
505
+ // Slide id when the active slide has one, else its 1-based page number \u2014
506
+ // mirrors AuditFinding.page's own "1-based page number when there is no
507
+ // slide id" convention (../svg/audit/deck-audit.ts) rather than this
508
+ // file's internal 0-based data-index, so a revision-request.json's
509
+ // pageId lines up with what pptfast audit/validate already print.
510
+ function pageIdFor(i) {
511
+ var el = slideEl(i)
512
+ var id = el ? el.getAttribute('data-id') : null
513
+ return id !== null ? id : thumbPos(i) + 1
514
+ }
515
+
516
+ function renderAnnotations() {
517
+ var pid = pageIdFor(current)
518
+ annotateLabel.textContent = 'page ' + (thumbPos(current) + 1) + (typeof pid === 'string' ? ' \xB7 ' + pid : '')
519
+ var list = annotations[current] || []
520
+ annotateList.innerHTML = ''
521
+ list.forEach(function (text, idx) {
522
+ var li = document.createElement('li')
523
+ li.className = 'pf-annotate-item'
524
+ var span = document.createElement('span')
525
+ span.textContent = text
526
+ var rm = document.createElement('button')
527
+ rm.type = 'button'
528
+ rm.className = 'pf-annotate-remove'
529
+ rm.setAttribute('aria-label', 'remove annotation')
530
+ rm.textContent = '\\u00d7'
531
+ rm.addEventListener('click', function () {
532
+ list.splice(idx, 1)
533
+ renderAnnotations()
534
+ })
535
+ li.appendChild(span)
536
+ li.appendChild(rm)
537
+ annotateList.appendChild(li)
538
+ })
539
+ }
540
+
541
+ annotateAdd.addEventListener('click', function () {
542
+ var text = annotateInput.value.trim()
543
+ if (!text) return
544
+ if (!annotations[current]) annotations[current] = []
545
+ annotations[current].push(text)
546
+ annotateInput.value = ''
547
+ renderAnnotations()
548
+ })
549
+
550
+ // "Export revision requests": preview.html stays read-only end to end \u2014
551
+ // this never writes back into the deck itself, only produces a JSON file
552
+ // of requests for an agent/human to route through pages/*.json (see
553
+ // skills/pptfast/SKILL.md's phase-6 revision-request handling). Zero
554
+ // network/storage \u2014 an in-memory Blob + a synthetic <a download> click,
555
+ // the standard browser-only download pattern.
556
+ exportBtn.addEventListener('click', function () {
557
+ var requests = []
558
+ Object.keys(annotations).forEach(function (key) {
559
+ var i = parseInt(key, 10)
560
+ var pid = pageIdFor(i)
561
+ annotations[i].forEach(function (text) {
562
+ requests.push({ pageId: pid, annotation: text, createdAt: new Date().toISOString() })
563
+ })
564
+ })
565
+ var deckTitle = document.getElementById('pf-title').textContent
566
+ var payload = { version: '1', deck: deckTitle, requests: requests }
567
+ var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
568
+ var url = URL.createObjectURL(blob)
569
+ var a = document.createElement('a')
570
+ a.href = url
571
+ a.download = 'revision-request.json'
572
+ document.body.appendChild(a)
573
+ a.click()
574
+ document.body.removeChild(a)
575
+ URL.revokeObjectURL(url)
576
+ })
577
+
578
+ renderAnnotations()
430
579
  })()
431
580
  `.trim();
581
+ var ANNOTATE_PANEL = `<section id="pf-annotate-panel">
582
+ <h2>Annotations</h2>
583
+ <div id="pf-annotate-current-label"></div>
584
+ <ul id="pf-annotate-list"></ul>
585
+ <textarea id="pf-annotate-input" rows="3" placeholder="Add a note for this page\u2026"></textarea>
586
+ <button type="button" id="pf-annotate-add">Add annotation</button>
587
+ </section>`;
432
588
  function buildPreviewHtml(input) {
433
- const { title, slides } = input;
589
+ const { title, slides, findings = [], auditNote, checks } = input;
434
590
  const total = slides.length;
435
591
  const escapedTitle = escapeHtml(title);
436
- const stageSlide = total > 0 ? slideNode(slides[0]) : "";
437
- const thumbs = slides.map((s, i) => thumbButton(s, i === 0, i === 0 ? "" : slideNode(s))).join("");
592
+ const findingsByPage = /* @__PURE__ */ new Map();
593
+ for (const f of findings) {
594
+ const list = findingsByPage.get(f.page);
595
+ if (list) list.push(f);
596
+ else findingsByPage.set(f.page, [f]);
597
+ }
598
+ const countFor = (slide) => findingsByPage.get(slide.index + 1)?.length ?? 0;
599
+ const stageSlide = total > 0 ? slideNode(slides[0], countFor(slides[0])) : "";
600
+ const thumbs = slides.map((s, i) => thumbButton(s, i === 0, i === 0 ? "" : slideNode(s, countFor(s)), countFor(s))).join("");
438
601
  const initialCounter = total > 0 ? counterText(slides[0], total) : "0 / 0";
602
+ const auditPanel = findings.length > 0 ? `<section id="pf-audit-panel"><h2>Audit findings (${findings.length})</h2><div id="pf-audit-list">${findings.map(findingPanelEntry).join("")}</div></section>` : "";
603
+ const findingsDataScript = findings.length > 0 ? `<script type="application/json" id="pf-audit-findings">${embedJson(findings)}</script>` : "";
604
+ const auditNoteHtml = auditNote !== void 0 ? `<span id="pf-audit-note">${escapeHtml(auditNote)}</span>` : "";
605
+ const checksLine = checks !== void 0 ? `<div id="pf-audit-checks">audit checks: svg ${checks.svg} \xB7 pixels ${checks.pixels}</div>` : "";
439
606
  return `<!doctype html>
440
607
  <html lang="en">
441
608
  <head>
@@ -448,9 +615,12 @@ function buildPreviewHtml(input) {
448
615
  <header>
449
616
  <span id="pf-title">${escapedTitle}</span>
450
617
  <span id="pf-counter">${initialCounter}</span>
618
+ ${auditNoteHtml}
619
+ <button type="button" id="pf-export-btn">Export revision requests</button>
451
620
  </header>
452
- <div id="pf-stage-wrap"><div id="pf-stage">${stageSlide}</div></div>
621
+ <div id="pf-stage-wrap"><div id="pf-stage">${stageSlide}</div><aside id="pf-side">${checksLine}${auditPanel}${ANNOTATE_PANEL}</aside></div>
453
622
  <nav id="pf-filmstrip" aria-label="slides">${thumbs}</nav>
623
+ ${findingsDataScript}
454
624
  <script>${JS}</script>
455
625
  </body>
456
626
  </html>
@@ -569,6 +739,9 @@ function formatAuditReport(report, ir) {
569
739
  lines.push(
570
740
  `audited ${report.pagesAudited} page${report.pagesAudited === 1 ? "" : "s"}, ${report.pagesSkipped} skipped, ${report.findings.length} finding${report.findings.length === 1 ? "" : "s"}`
571
741
  );
742
+ if (report.checks.pixels === "completed") {
743
+ lines.push("pixel-contrast check: completed");
744
+ }
572
745
  const note = placeholderNote(ir);
573
746
  if (note) lines.push(note);
574
747
  return lines.join("\n");
@@ -586,23 +759,23 @@ ${formatIssues(v.errors)}`
586
759
  );
587
760
  }
588
761
  await resolveLocalAssets(v.ir, baseDir);
589
- const report = auditDeck(v.ir);
762
+ const report = opts.pixels ? await auditDeck(v.ir, { pixels: true }) : auditDeck(v.ir);
590
763
  const hasFindings = report.findings.length > 0;
591
764
  const output = opts.json ? JSON.stringify(report, null, 2) : formatAuditReport(report, v.ir);
592
765
  return { output, hasFindings };
593
766
  }
594
- async function runPlanValidate(planPath) {
595
- const raw = await loadIrFile(planPath, "plan");
596
- const v = validatePlan(raw);
767
+ async function runSpecValidate(specPath) {
768
+ const raw = await loadIrFile(specPath, "spec");
769
+ const v = validateSpec(raw);
597
770
  if (!v.ok) {
598
- throw new PptfastError(formatInvalidPlanError(v.errors));
771
+ throw new PptfastError(formatInvalidSpecError(v.errors));
599
772
  }
600
- const plan2 = v.plan;
601
- const axes = resolveScenario(plan2.scenario);
602
- return `OK \u2014 ${plan2.pages.length} pages, scenario ${axes.mode}/${axes.delivery}/${axes.audience}, theme "${resolvePlanThemeId(plan2)}"`;
773
+ const spec2 = v.spec;
774
+ const axes = resolveNarrative(spec2.narrative);
775
+ return `OK \u2014 ${spec2.pages.length} pages, narrative ${axes.strategy}/${axes.pacing}/${axes.audience}, theme "${resolveSpecThemeId(spec2)}"`;
603
776
  }
604
777
  function runSchema(mode) {
605
- const schema = mode === "style" ? styleJsonSchema() : mode === "plan" ? planJsonSchema() : irJsonSchema();
778
+ const schema = mode === "style" ? styleJsonSchema() : mode === "spec" ? specJsonSchema() : irJsonSchema();
606
779
  return JSON.stringify(schema, null, 2);
607
780
  }
608
781
  function runThemes(asJson) {
@@ -610,22 +783,22 @@ function runThemes(asJson) {
610
783
  if (asJson) return JSON.stringify(themes, null, 2);
611
784
  return themes.map((t) => `${t.id.padEnd(12)} ${t.label}`).join("\n");
612
785
  }
613
- function runScenarios(asJson) {
786
+ function runNarratives(asJson) {
614
787
  if (asJson) {
615
788
  return JSON.stringify(
616
789
  {
617
- presets: SCENARIO_PRESETS,
618
- modes: MODE_DEFINITIONS,
619
- deliveries: DELIVERY_BUDGETS,
790
+ presets: NARRATIVE_PRESETS,
791
+ strategies: STRATEGY_DEFINITIONS,
792
+ pacings: PACING_BUDGETS,
620
793
  audiences: AUDIENCE_VALUES
621
794
  },
622
795
  null,
623
796
  2
624
797
  );
625
798
  }
626
- const rows = Object.values(SCENARIO_PRESETS).map((p) => ({
799
+ const rows = Object.values(NARRATIVE_PRESETS).map((p) => ({
627
800
  id: p.id,
628
- axes: `${p.axes.mode}/${p.axes.delivery}/${p.axes.audience}`,
801
+ axes: `${p.axes.strategy}/${p.axes.pacing}/${p.axes.audience}`,
629
802
  themes: p.themeRecommendations.join(", ")
630
803
  }));
631
804
  const idWidth = Math.max(...rows.map((r) => r.id.length));
@@ -674,6 +847,9 @@ ${formatIssues(v.errors)}`);
674
847
  if (aliasNote) notes.push(aliasNote);
675
848
  if (opts.htmlOut) {
676
849
  const htmlPath = join4(outDir, "preview.html");
850
+ const hasPlaceholder = ir.slides.some((slide) => slide.placeholder);
851
+ const auditReport = hasPlaceholder ? void 0 : auditDeck(ir);
852
+ const auditFindings = auditReport?.findings ?? [];
677
853
  const html = buildPreviewHtml({
678
854
  title: ir.filename,
679
855
  slides: ir.slides.map((slide, i) => ({
@@ -682,10 +858,16 @@ ${formatIssues(v.errors)}`);
682
858
  type: slide.type,
683
859
  svg: svgs[i],
684
860
  placeholder: slide.placeholder
685
- }))
861
+ })),
862
+ findings: auditFindings.map((f) => ({ page: f.page, slideId: f.slideId, code: f.code, message: f.message })),
863
+ auditNote: hasPlaceholder ? "audit overlay skipped \u2014 deck has unfilled placeholder pages; fill every page and re-run `pptfast preview --html` to see audit findings" : void 0,
864
+ checks: auditReport?.checks
686
865
  });
687
866
  await writeFile2(htmlPath, html);
688
867
  notes.push(`note: wrote self-contained preview to ${htmlPath}`);
868
+ if (auditFindings.length > 0) {
869
+ notes.push(`note: audit found ${auditFindings.length} finding${auditFindings.length === 1 ? "" : "s"} \u2014 see preview.html`);
870
+ }
689
871
  }
690
872
  return notes.length > 0 ? `${ok}
691
873
  ${notes.join("\n")}` : ok;
@@ -716,7 +898,7 @@ async function runAssemble(target, opts = {}) {
716
898
  const summary = `wrote ${outPath} (${outIr.slides.length} slides, ${placeholderCount} placeholder${placeholderCount === 1 ? "" : "s"})`;
717
899
  const notes = [];
718
900
  if (generatedSeed !== void 0) {
719
- notes.push(`note: generated seed ${generatedSeed} \u2014 add "seed": ${generatedSeed} to deck.plan.json for revision stability`);
901
+ notes.push(`note: generated seed ${generatedSeed} \u2014 add "seed": ${generatedSeed} to deck.spec.json for revision stability`);
720
902
  }
721
903
  if (materializedLayoutCount !== void 0) {
722
904
  notes.push(
@@ -730,16 +912,16 @@ async function runDisassemble(irPath, outDir) {
730
912
  const v = validateIr(raw);
731
913
  if (!v.ok) throw new PptfastError(`invalid IR:
732
914
  ${formatIssues(v.errors)}`);
733
- const { plan: plan2, pages } = disassembleDeck(v.ir);
915
+ const { spec: spec2, pages } = disassembleDeck(v.ir);
734
916
  const ids = Object.keys(pages);
735
917
  for (const id of ids) assertSafeFileSegment(id, "slide id");
736
- const planPath = join4(outDir, "deck.plan.json");
918
+ const specPath = join4(outDir, "deck.spec.json");
737
919
  await mkdir2(outDir, { recursive: true });
738
920
  try {
739
- await writeFile2(planPath, JSON.stringify(plan2, null, 2) + "\n", { flag: "wx" });
921
+ await writeFile2(specPath, JSON.stringify(spec2, null, 2) + "\n", { flag: "wx" });
740
922
  } catch (e) {
741
923
  if (e.code === "EEXIST") {
742
- throw new PptfastError(`${planPath} already exists \u2014 refusing to overwrite an existing deck project`);
924
+ throw new PptfastError(`${specPath} already exists \u2014 refusing to overwrite an existing deck project`);
743
925
  }
744
926
  throw e;
745
927
  }
@@ -761,13 +943,75 @@ ${formatIssues(v.errors)}`);
761
943
  );
762
944
  const pagesNote = ids.length > 0 ? `${ids.length} page file${ids.length === 1 ? "" : "s"} to ${pagesDir}` : "no pages (every slide was a placeholder)";
763
945
  const assetsNote = assetCount > 0 ? `, and ${assetCount} asset file${assetCount === 1 ? "" : "s"} to ${assetsDir}` : "";
764
- return `wrote ${planPath}, ${pagesNote}${assetsNote}`;
946
+ return `wrote ${specPath}, ${pagesNote}${assetsNote}`;
765
947
  } catch (e) {
766
- await rm(planPath, { force: true }).catch(() => {
948
+ await rm(specPath, { force: true }).catch(() => {
767
949
  });
768
950
  throw e;
769
951
  }
770
952
  }
953
+ async function runMigrate(input, output, cwd = process.cwd()) {
954
+ const resolvedInput = resolve5(cwd, input);
955
+ if (await isDeckDirectory(resolvedInput)) {
956
+ return runMigrateDeckDir(resolvedInput, output, cwd);
957
+ }
958
+ return runMigrateIrFile(resolvedInput, output, cwd);
959
+ }
960
+ async function runMigrateDeckDir(dir, output, cwd) {
961
+ const planPath = join4(dir, PLAN_FILENAME);
962
+ const sourceSpecPath = join4(dir, SPEC_FILENAME);
963
+ if (!await pathExists(planPath) && await pathExists(sourceSpecPath)) {
964
+ throw new PptfastError(
965
+ `${dir} has ${SPEC_FILENAME} but no ${PLAN_FILENAME} \u2014 this deck project is already migrated, nothing to do`
966
+ );
967
+ }
968
+ const raw = await loadIrFile(planPath, "plan");
969
+ const migrated = migrateDeckPlanToSpec(raw);
970
+ const outDir = resolve5(cwd, output);
971
+ const specPath = join4(outDir, SPEC_FILENAME);
972
+ await mkdir2(outDir, { recursive: true });
973
+ try {
974
+ await writeFile2(specPath, JSON.stringify(migrated, null, 2) + "\n", { flag: "wx" });
975
+ } catch (e) {
976
+ if (e.code === "EEXIST") {
977
+ throw new PptfastError(`${specPath} already exists \u2014 refusing to overwrite, delete it first or choose a different -o`);
978
+ }
979
+ throw e;
980
+ }
981
+ return `wrote ${specPath} \u2014 run \`pptfast spec validate ${specPath}\` to confirm it, then delete ${planPath} (a directory with both files present is rejected)`;
982
+ }
983
+ async function runMigrateIrFile(filePath, output, cwd) {
984
+ const raw = await loadIrFile(filePath);
985
+ const version = typeof raw === "object" && raw !== null ? raw.version : void 0;
986
+ if (version === "2") {
987
+ throw new PptfastError(
988
+ "pptfast migrate does not support IR v2 (spec \xA715.3: v2 has no real users) \u2014 run `pptfast validate` on the v2 file to see the full v2\u2192v4 combined field mapping and rewrite it by hand"
989
+ );
990
+ }
991
+ if (version !== "3") {
992
+ throw new PptfastError(
993
+ `pptfast migrate only converts an IR v3 file (version: "3") or a deck project directory containing ${PLAN_FILENAME} \u2014 got version ${JSON.stringify(version)} in ${filePath}`
994
+ );
995
+ }
996
+ const parsed = PptxIRV3Schema.safeParse(raw);
997
+ if (!parsed.success) {
998
+ const detail = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
999
+ throw new PptfastError(`invalid IR v3 file ${filePath}:
1000
+ ${detail}`);
1001
+ }
1002
+ const migrated = migrateIrV3ToV4(parsed.data);
1003
+ const outPath = resolve5(cwd, output);
1004
+ await mkdir2(dirname2(outPath), { recursive: true });
1005
+ try {
1006
+ await writeFile2(outPath, JSON.stringify(migrated, null, 2) + "\n", { flag: "wx" });
1007
+ } catch (e) {
1008
+ if (e.code === "EEXIST") {
1009
+ throw new PptfastError(`${outPath} already exists \u2014 refusing to overwrite, delete it first or choose a different -o`);
1010
+ }
1011
+ throw e;
1012
+ }
1013
+ return `wrote ${outPath} (migrated IR v3 \u2192 v4)`;
1014
+ }
771
1015
 
772
1016
  // src/cli/update.ts
773
1017
  import { execFile } from "child_process";
@@ -872,43 +1116,60 @@ program.command("validate").description("Validate an IR JSON file, deck project
872
1116
  }
873
1117
  });
874
1118
  program.command("audit").description(
875
- "Deterministic geometry audit (overflow, out-of-bounds, low-contrast, overlap) \u2014 exits 1 when it finds anything"
876
- ).argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptfast/decks").option("--json", "machine-readable output (the full AuditReport)").action(async (target, opts) => {
1119
+ "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"
1120
+ ).argument("<target>", "IR JSON file, deck project directory, or bare name under ~/.pptfast/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) => {
877
1121
  try {
878
- const { output, hasFindings } = await runAudit(target, { json: opts.json });
1122
+ const { output, hasFindings } = await runAudit(target, { json: opts.json, pixels: opts.pixels });
879
1123
  console.log(output);
880
1124
  if (hasFindings) process.exit(1);
881
1125
  } catch (e) {
882
1126
  fail(e);
883
1127
  }
884
1128
  });
885
- 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("--plan", "print the deck plan schema instead").action(
886
- (opts) => console.log(runSchema(opts.plan ? "plan" : opts.style ? "style" : void 0))
887
- );
888
- var plan = program.command("plan").description("Deck plan commands (spec \xA75)");
889
- plan.command("validate").description("Validate a deck plan JSON file against the schema and mode-aware hard gates").argument("<plan.json>").action(async (planPath) => {
1129
+ 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) => {
1130
+ if (opts.plan) {
1131
+ fail(new Error("`pptfast schema --plan` has been renamed to `pptfast schema --spec` \u2014 run `pptfast schema --spec` instead"));
1132
+ }
1133
+ console.log(runSchema(opts.spec ? "spec" : opts.style ? "style" : void 0));
1134
+ });
1135
+ var plan = program.command("plan").description("Removed \u2014 use `pptfast spec` instead");
1136
+ plan.command("validate").description("Removed \u2014 use `pptfast spec validate` instead").argument("<file>").action(() => {
1137
+ fail(new Error("`pptfast plan validate` has been renamed to `pptfast spec validate` \u2014 run `pptfast spec validate <file>` instead"));
1138
+ });
1139
+ var spec = program.command("spec").description("Deck spec commands (spec \xA76)");
1140
+ spec.command("validate").description("Validate a deck spec JSON file against the schema and strategy-aware hard gates").argument("<spec.json>").action(async (specPath) => {
890
1141
  try {
891
- console.log(await runPlanValidate(planPath));
1142
+ console.log(await runSpecValidate(specPath));
892
1143
  } catch (e) {
893
1144
  fail(e);
894
1145
  }
895
1146
  });
896
- program.command("assemble").description("Assemble a deck project directory (deck.plan.json + pages/ + assets/) into an IR JSON file").argument("<dir|name>", "deck project directory, or bare name under ~/.pptfast/decks").option("-o, --output <file>", "output IR JSON path (default: <dir>/deck.json)").action(async (target, opts) => {
1147
+ program.command("assemble").description("Assemble a deck project directory (deck.spec.json + pages/ + assets/) into an IR JSON file").argument("<dir|name>", "deck project directory, or bare name under ~/.pptfast/decks").option("-o, --output <file>", "output IR JSON path (default: <dir>/deck.json)").action(async (target, opts) => {
897
1148
  try {
898
1149
  console.log(await runAssemble(target, { output: opts.output }));
899
1150
  } catch (e) {
900
1151
  fail(e);
901
1152
  }
902
1153
  });
903
- program.command("disassemble").description("Split an IR JSON file into a deck project directory (deck.plan.json + pages/)").argument("<ir.json>", "path to the IR file").requiredOption("-o, --output <dir>", "output deck project directory").action(async (irPath, opts) => {
1154
+ program.command("disassemble").description("Split an IR JSON file into a deck project directory (deck.spec.json + pages/)").argument("<ir.json>", "path to the IR file").requiredOption("-o, --output <dir>", "output deck project directory").action(async (irPath, opts) => {
904
1155
  try {
905
1156
  console.log(await runDisassemble(irPath, opts.output));
906
1157
  } catch (e) {
907
1158
  fail(e);
908
1159
  }
909
1160
  });
1161
+ program.command("migrate").description("Convert a v3 IR file to v4, or a deck.plan.json project directory to deck.spec.json \u2014 deterministic, no model").argument("<input>", "IR v3 JSON file, or a deck project directory containing deck.plan.json").requiredOption("-o, --output <output>", "output path \u2014 an IR JSON file for a v3 file input, a directory for a deck-project-directory input").action(async (input, opts) => {
1162
+ try {
1163
+ console.log(await runMigrate(input, opts.output));
1164
+ } catch (e) {
1165
+ fail(e);
1166
+ }
1167
+ });
910
1168
  program.command("themes").description("List built-in themes").option("--json", "machine-readable output").action((opts) => console.log(runThemes(Boolean(opts.json))));
911
- program.command("scenarios").description("List named scenario presets (mode/delivery/audience axes + theme recommendations)").option("--json", "machine-readable output").action((opts) => console.log(runScenarios(Boolean(opts.json))));
1169
+ 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))));
1170
+ program.command("scenarios").description("Removed \u2014 use `pptfast narratives` instead").action(() => {
1171
+ fail(new Error("`pptfast scenarios` has been renamed to `pptfast narratives` \u2014 run `pptfast narratives` instead"));
1172
+ });
912
1173
  program.command("init").description("Scaffold a pptfast.config.json in the current directory").action(async () => {
913
1174
  try {
914
1175
  console.log(await runInit());