Deck projects
August 25, 2026 · View on GitHub
Directory layout
my-deck/
deck.spec.json locked spec — page order, type, heading. Sole order-of-truth
pages/<page-id>.json one file per filled page, components only (no type/heading)
assets/ local images, auto-registered by filename
Layout and fs-safety discipline live in src/cli/deck-dir.ts (header comment restates the layout). The pure assembly logic is assembleDeck/disassembleDeck in src/spec/assemble.ts — zero-fs by design (AGENTS.md's src/index.ts closure rule), so it's the CLI shell (deck-dir.ts, Node-only) that actually reads deck.spec.json/pages/*.json/assets/* off disk. assertSafeFileSegment (deck-dir.ts:95) is the CWE-22 defense every id-to-path join goes through — a slide.id/asset-id is an open z.string() at the schema layer, so this is a real, tested guard, not defense-in-depth theater. A directory that still has deck.plan.json (the pre-vocabulary-v4 filename) instead of deck.spec.json is not read directly — readSpecFile (deck-dir.ts) points at pptwise migrate instead; both files present at once is a hard error, never a guessed priority (spec §9.2).
Six-phase CLI workflow
skills/pptwise/SKILL.md is the authored playbook. The phases map onto commands as: align (pptwise schema / schema --spec / narratives --json / themes --json) → spec (write deck.spec.json, pptwise spec validate <file> — strategy-aware hard gates: boundary pages, heading length, beat rotation, page count vs. pacing, validateSpec/formatInvalidSpecError in src/spec/index.ts) → fill (pages/<id>.json in small batches, pptwise assemble <dir> -o deck.json after each batch, then pptwise validate) → audit (pptwise audit <target> [--json], docs/* cross-reference: src/svg/audit/deck-audit.ts) → preview (pptwise preview <target> -o <dir> --html — once every page is filled, this also overlays the same audit findings on preview.html, and writes a machine-readable manifest.json beside it) → revise (edit one pages/<id>.json by hand, then re-assemble → validate/audit → re-render). Every consumer command (validate/render/preview/audit) accepts a single IR file, a deck project directory, or a bare name — isDeckDirectory (deck-dir.ts) is the dispatch. Preview is read-only end to end: a reviewer who wants something changed says so in the conversation, usually with a screenshot, and the agent makes the edit through the same pages/*.json gate everything else passes — see skills/pptwise/SKILL.md's phase 6.
Live preview (pptwise serve)
pptwise serve <target> [--port 4400] [--no-open] (same target forms as every other deck-accepting command above) is the preview phase's review loop in live form, not a separate workflow — the identical preview.html markup, running off a local node:http server bound to 127.0.0.1 instead of a file pptwise preview --html writes once. A source edit (deck.spec.json/pages//assets/ for a deck project directory, or the file itself for a bare IR target) triggers a rebuild and pushes a whole-page reload to the open browser tab over SSE, 200ms-debounced so a multi-file save coalesces into one refresh. A rebuild that fails (a mid-edit invalid JSON save is the common case) shows a recoverable error banner instead of taking the server down, and clears itself on the next successful save.
The one behavioral difference from the download form is liveness: the tab re-renders on every source change, so a revision the agent saves appears in the tab the reviewer already has open, with no new link and nothing for them to click. Neither form writes to the deck — an annotation panel and a POST /revision-request endpoint existed here until 2026-08-16 and were removed together, because a reviewer describing a change in conversation (with a screenshot) reaches the agent faster than one typing into a panel whose output then has to be exported and read back. Both forms stay valid, for different needs: serve for a live local loop where the agent and a reviewer are both watching the same running deck, preview --html for a static, shareable artifact (attach to a ticket, review offline, nothing to keep running). No auth and no remote bind (127.0.0.1 only, no --host flag) — a local dev tool, not a hosted review server.
Asset briefs (pptwise asset-brief)
pptwise asset-brief <target> [--json] (src/svg/asset-brief.ts, buildAssetBrief) closes the same knowledge gap for image assets that audit closes for layout defects: an image slot's real rendered frame (image.tsx's measure — Math.min(round(w * 0.5), MAX_IMAGE_H), then cover/contain cropping) is engine-internal geometry no amount of reading the IR reveals, and generating art at the wrong aspect ratio is the single most common reason a placed image looks wrong. The brief comes from an actual off-screen render pass, never a copied constant — every asset_id an image$ \text{component} \text{references}, \text{resolved} \text{or} \text{not}, \text{gets} \text{its} \text{own} \text{dummy} 1 \times 1 \text{PNG} \text{injected} \text{into} \text{an} \text{in}-\text{memory} \text{copy} \text{of} \text{the} \text{IR} \text{for} \text{that} \text{render} (\text{never} \text{the} \text{real} $ir, never the export path), so the geometry extracted afterward is always real. A slide whose selected layout never actually draws the component (e.g. a cover layout, which never reads slide.components at all) reports rendered: false rather than being silently dropped. Same target resolution as every other deck-accepting command; purely informational, no exit-code gate. v1 scope is image components only — image_grid/image_compare and background asset specs are a natural v2 extension of the same shape, not yet covered. Each item also carries alt (A11Y-01 alt chain wave) when ir.assets.images[asset_id].alt is set — the same accessibility description that lands in the exported PPTX — so a generator agent filling in missing/suggested_prompt items can see at a glance which ids still need one written.
Placeholder pages and the --draft gate
A spec page with no matching pages/<id>.json file assembles into { placeholder: true, type, heading, subheading? } from the spec's summary, if set. This is never an error (buildSlide, src/spec/assemble.ts). Once a boundary page (cover, chapter, or ending) has a page file, its summary remains visible as subheading. A filled content page keeps summary as a fill-only prompt so it does not duplicate the authored body. validate and preview pass placeholder pages through unconditionally. render hard-refuses a deck containing one unless --draft is passed (SDK: generatePptx(ir, { draft?: boolean })), and audit skips them (auditDeck, pagesSkipped). Assemble's exact contract says a missing page always succeeds as a placeholder. A structural contradiction, such as an orphan pages/<id>.json with no matching spec id or a locked-field violation, always throws.
Locked fields
type and heading are spec-owned (LOCKED_KEYS, src/spec/assemble.ts:150) — a page file that redeclares either (even set to undefined, caught via Object.hasOwn, not !== undefined) throws before assembly proceeds. PageContent (assemble.ts:64-72) is the exhaustive shape a page file may set: components, layout, arrangement, background, image_side, footnote, notes (speaker notes — content, not locked, exported as native PowerPoint speaker notes, never rendered onto the canvas SVG).
branding is a deck-level field on deck.spec.json ("full" | "cover-only" | "minimal"), not a page-file field. Assemble copies it onto the IR as a plain passthrough, the same way it copies brand and seed. Omitted equals "cover-only" (brand logo on cover and chapter pages, no footer rule, meta, or logo on content and ending pages). "full" is the explicit declaration that draws the content-page footer and logo, and that paints confidentiality and date on cover and ending meta rows. Other postures leave those two fields off the canvas even when meta carries them. "minimal" drops the content-page footer rule and meta but keeps the logo. Layout-declared branding: "none" still wins. Theme motifs are not this field. Omitted by default. Write "full" only when every content page needs the brand footer. Assemble never infers branding from narrative.
Boundary-page render surface
PageContent above is the same shape for every page type, but not every field it allows is actually drawn onto the canvas by every type. footnote never renders on a cover, chapter, or ending page. components follow the knowable layout's slots, not a type-level ban: verdict-index and gauge-verdict declare a body slot that accepts bullets, so a locked cover may carry one bullets block when it uses either layout. A layout with no matching slot, or a page type whose pool has more than one id so the face is not knowable at validate time, still hard-rejects. validate is checkBoundaryPageContent in src/validate-core.ts. Before that gate existed, stray fields were silently dropped at render with no signal anywhere.
| type | heading | subheading | components | footnote |
|---|---|---|---|---|
cover | always | 31/37 layouts | verdict-index and gauge-verdict accept bullets. the other 35 cover layouts do not | never |
chapter | always | 32/36 layouts | 0/36 layouts | never |
content | always | 18/23 standard layouts, 3/4 image takeovers | 22/23 standard layouts, 4/4 takeovers | not two-column, gauge-point, or crayonbox-point, 0/4 takeovers |
ending | always | 30/34 layouts | 14/34 layouts declare a body slot | never |
subheading is deliberately not hard-gated on any type, on either side of the table — no type drops it on every layout, so a "this type never renders subheading" claim would be unsound and false-positive on the majority layout that does render it (this is also why subheading is absent from checkBoundaryPageContent's rule despite being one of the fields the wave's benchmark evidence first suspected). notes sits outside this table entirely by design — speaker notes, never drawn onto the canvas SVG regardless of page type (see its docstring in ir/index.ts).
~/.pptwise home and four-layer config
pptwiseHome() (src/cli/home.ts) is $PPTWISE_HOME or ~/.pptwise, read fresh every call — one predictable dotdir (same posture as .ssh/.npmrc/~/.claude), not a per-OS XDG/AppData split. $PPTPRESS_HOME and $PPTFAST_HOME remain aliases (one stderr warning when one supplies the value). Empty string counts as unset. If ~/.pptwise is missing, ~/.pptpress is copied when present, otherwise ~/.pptfast. Old directories stay in place. decksRoot() is $PPTWISE_HOME/decks by default, where a bare deck name resolves (pptwise render my-deck -o out.pptx) when no local file/directory of that name exists. userConfigPath() is $PPTWISE_HOME/config.json.
Four-layer precedence, highest wins: CLI flag > project config (pptwise.config.json, found by walking up from cwd — findConfig, src/cli/config.ts. Leftover pptpress.config.json and pptfast.config.json are still read. The new name wins when more than one exists, and they are not merged) > user config (~/.pptwise/config.json, findUserConfig, same file) > the artifact's own value (an authored IR's theme, or the schema's consulting default). Both config layers can set decksDir to redirect bare-name resolution — project's resolves against that config file's own directory (for a team that wants deck projects checked into the repo), user's against pptwiseHome(). theme/style values aren't validated against the installed set at file-read time — only once, at whichever layer actually wins (applyDeckConfig, src/cli/commands.ts). Project config also accepts outDir, the artifact root render/preview use when -o is omitted (default .pptwise under that config file, or a leftover .pptpress/ or .pptfast/ when the new directory is missing). There is no user-layer outDir: an artifact directory is a property of this working tree.
Workspace artifacts (.pptwise/)
render and preview write here when -o is omitted (src/cli/workspace.ts):
<anchor>/.pptwise/
<deck-slug>/
preview.html
manifest.json
001-cover.svg
<deck-slug>.pptx
assets/ pinned stock photos (not regenerable)
hero.jpg
hero.json
The anchor is the directory of the nearest pptwise.config.json, else cwd. The slug is the deck directory name, or the IR filename without its extension.
Two zones live here. Render output (pptx, preview.html, NNN-*.svg, manifest.json) is regenerable: delete those files and re-run, they grow back. Stock-photo files under .pptwise/<deck>/assets/ (plus sidecars) are pinned downloads. Deleting the whole .pptwise/ directory drops those photos. deck.spec.json, pages/, project assets/, theme.json, pptwise.config.json, and assemble's deck.json stay where they already are.
On a default-path preview, leftover files matching ^\d{3}-[a-z-]+\.svg$ in that deck directory are deleted first, so a shorter deck does not leave orphan SVGs. An explicit -o is never pruned.
The first time the CLI creates .pptwise/ it appends .pptwise/ to .git/info/exclude (via git rev-parse --git-common-dir, so a worktree writes the main repo's exclude). It never edits the shared .gitignore. --no-git-ignore, or a project outDir, skips that step. DSH previews stay under ~/.pptwise/previews/ and do not copy into the workspace.
Disassemble
disassembleDeck (src/spec/assemble.ts) is the IR → project-directory inverse and remains intentionally lossy. Boundary-page subheadings are recovered as PageSpec.summary, which preserves summary across assemble → disassemble → assemble. A filled content slide's subheading is not reinterpreted as summary because content summary is a fill-only prompt and PageContent has no subheading field. focus has no Slide-side home. theme.style and theme.brand overrides collapse to a bare theme-id string. A deck.json produced by assembleDeck, whose omitted layouts are already materialized, disassembles every auto-pick back out as if it had been an explicit pin. This is an accepted narrowing of revision stability for that specific reuse pattern, not a bug.