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