--- summary: 'v4 concept model: theme/layout/component/narrative, the capacity dual-ownership split, and the frozen v4 IR schema' read_when: - first time touching the pptfast vocabulary (theme/layout/component/narrative) - adding a theme, layout, or component - unsure whether a rule belongs to pacing (editorial) or layout (geometric) capacity --- # Concepts Four nouns, each owned by exactly one layer. This doc is the map — `docs/selection-and-seed.md` and `docs/contrast-system.md` go deep on two of the mechanisms that sit on top of it. ## theme `{ id, style, brand, layouts, layoutTendencies? }` — the distributable, top-level unit (`ThemeDefinition`, `src/themes/definitions.ts:20-94`). `style` is pure design tokens (color/type/radius/spacing — `StyleTokens`, `src/themes/tokens.ts`), `brand` is identity chrome filled into a layout's optional slots (logo/footer/page-number — no slideMaster, a layout with no slot for a brand element just omits it), `layouts` is the curated layout set per page type — the one hard boundary a theme enforces. 17 built-ins (`BUILTIN_THEME_IDS`, `src/ir/index.ts:8`), all pointing every page type at the **full** registered-layout set for that type (`FULL_LAYOUTS`, `src/themes/definitions.ts:114-119`) — none of the 17 narrows any more (the last curation exclusions were reverted once `src/svg/ink.ts`'s contrast fix made every layout's text adapt to its actual background — see `docs/contrast-system.md`). `THEME_DEFINITIONS` (same file) is the record every consumer reads. `registerTheme` (`src/themes/definitions.ts`, storing into `src/themes/registered-themes.ts`'s `REGISTERED_THEMES` map) is the SDK extension seam for a caller-defined theme, `layouts` optional there too, defaulting to the same full set per page type. A theme is not appearance-only: `layoutTendencies?: Partial>` (theme-structure wave, `.issues/2026-07-26-theme-structure/plan.md`) lets a theme author declare, per page type, which layout ids `resolveLayoutId` (`docs/selection-and-seed.md`) should lean toward — a soft weight over ids already inside that same page type's `layouts` set, never a second curation boundary: an id it names outside its own page type's `layouts` set is invisible to selection (not merely down-weighted), and `registerTheme`/`definitions.test.ts` reject that as a theme-author mistake at registration/test time rather than let it silently do nothing at render time. It composes with the narrative strategy's tendency and a slide's `beat` tendency via `Math.max`, never multiplication — all three assert the same "which candidate should this slide favor" dimension, so agreement corroborates rather than compounds (see `docs/selection-and-seed.md` for the full composition rule). All 17 built-ins declare it, every one of them on cover — the page type where a theme's own visual identity reads loudest — and most also on chapter and/or ending. `bloom` and `classroom` share one object rather than two matching literals: `bloom` is a declared recolor of `classroom`, keeping its own id, palette and motif, so the 17 theme ids resolve to **16 distinct structural identities**. A page type a theme leaves undeclared still renders byte-identically to before this field existed. Measured effect (theme-structure wave, 13 themes at the time): a fixed IR + seed rendered across all 13 themes went from 1 distinct per-page layout sequence to 7. Measured again on the cover slot: 8 distinct cover sequences across the 16 identities before the allocation table, 10 after it, 9 once two declarations that `Math.max` was flattening to nothing were corrected — and identities that pick their cover exactly the way an undeclared theme does went from 8 of 16 to 0, which is the number the fix was actually chasing. ## layout A page-level template with named slots. One layout is two things kept in one place: the registry entry that declares its shape, and the JSX that draws it — both in `src/svg/layouts/.tsx`, the entry as that file's own `export const layoutDef` (`src/svg/layouts/registry.ts` only imports them and aggregates). There is no second word for this. "archetype" used to be that second word and is gone from the vocabulary; it survives in exactly two places, both kept deliberately so that merging the words could not move a single output byte: the `kind` literal (frozen by a golden registry fixture) and the `data-archetype` attribute in rendered SVG (frozen by every golden SVG and every checked-in preview). `LayoutDefinition` (`src/svg/layouts/registry.ts`): `id`, `kind`, `slideTypes`, `slots` (each a `{ name, accepts, capacity? }`), an optional `arrangements` allowlist, and an optional `narrativesOnly` strategy allowlist (renamed from `scenariosOnly`, spec §16 — an internal registry detail, not part of the public narrative vocabulary). `kind` names the two tiers: the standard tier (still spelled `"archetype"`, see above) and the image takeovers. `LAYOUT_REGISTRY` (same file) holds 37 standard layouts (36 auto-selectable + 1 pin-only) + 4 image takeovers (`image-split`/`image-top`/`image-bottom`/`image-annotate` — bespoke full-bleed layouts requiring an `image` component, dispatched outside the normal layout pool, see `resolveOneEffectiveLayoutId` in `src/svg/layout-selection.ts`). A layout's optional `pinOnly: true` (`LayoutDefinition.pinOnly`) marks it reachable only by an explicit `slide.layout` pin, never by auto-selection — the model declares that page's intent directly rather than pptfast inferring it from content; quote-stage (a thesis/quote page, capacity 1) is the first such member. A slot's `capacity` (present only on the `body` slot of content layouts) is the layout's declared geometric ceiling — bento-panel's is 6, quote-stage's is 1, every other content layout's is 4. **layout is not arrangement.** A layout is the whole page template. An `arrangement` (`Arrangement`, same file) is how the components inside the `body` slot flow — `single`, `two_column`, `assertion_evidence`, and six more. Two words, two levels, both needed: a layout's `arrangements` field is the allowlist of flows its body honors. ## component The 38 typed units that fill a slot — the IR's discriminated `Component` union (`COMPONENT_TYPES`, `src/ir/index.ts`, derived from the schema itself, not hand-copied): `bullets`, `paragraph`, `quote`, `callout`, `code`, `kpi_cards`, `chart`, `flowchart`, `architecture`, `timeline`, `comparison`, `icon_cards`, `row_cards`, `steps`, `rings`, `numbered_cards`, `roadmap`, `matrix`, `insight_panel`, `verdict_banner`, `citation`, `image`, `image_grid`, `image_compare`, `swot`, `bmc`, `waterfall`, `gantt`, `pest`, `five_forces`, `heatmap`, `sankey`, `data_table`, `device_mockup`, `cycle`, `people_cards`, `logo_wall`, `tag_row`. Each has one render component under `src/svg/components/`. `device_mockup` frames an `image`-style asset inside a themed browser-window or phone chrome (`device: "browser" | "phone"`) — for a slide whose job is proving "this is real, running software", not just illustrating a point with a photo (see `skills/pptfast/SKILL.md`'s Component selection table for the exact `image` vs. `device_mockup` split). `cycle` lays 3-8 stages out on a closed ring with arc-arrow connectors — for a process with no endpoint (it loops back to its own start, e.g. PDCA, a product lifecycle, a flywheel) where `flowchart` would have to fake a "back edge" (see the same selection table for the `flowchart` vs. `cycle` split). `people_cards` lays 2-12 people out on an equal-weight card grid, each with a deterministic initials badge derived from `name` in place of a photo — for content that is fundamentally about people (a team roster, a speaker lineup, a judging panel), where `row_cards`/`icon_cards` (both capped at 6 items) either can't hold the full roster or have no identity-anchor axis at all (see the same selection table for the `people_cards` vs. `row_cards`/`icon_cards` split). `logo_wall` lays 4-12 organization/brand logos out on an even wall, each drawn contain-fit (never cropped) on an auto-generated neutral backing panel so a transparent single-ink logo stays legible on any theme — for a sponsor/client/press/partner logo set, where `image_grid`'s cover-crop would mutilate a wide wordmark and its plain-image cells would let a transparent logo vanish into the slide background (see the same selection table for the `logo_wall` vs. `image_grid`/`device_mockup` split). `tag_row` lays 2-16 short parallel labels out as a wrapping row of pills — a tech stack, a capability set, a keyword set, applicable certifications — with an optional `emphasis: "first"` highlighting the primary tag, for content that is a set of short nominal labels rather than described items (each carrying its own text — `row_cards`/`icon_cards`) or a prose list (`bullets`) (see the same selection table for the `tag_row` vs. `row_cards`/`icon_cards`/`bullets` split). `swot`/`bmc`/`waterfall`/`gantt`/`pest`/`five_forces`/`heatmap`/`sankey` are *full-body* components (`FULL_BODY_TYPES`, `src/svg/component-traits.ts`) — each must be the sole component on its slide (`checkFullBodyExclusivity`, `src/api.ts`) and fills the entire content rect itself rather than stacking alongside siblings. ## narrative Three axes — `strategy` (5-way argument style), `pacing` (3-way density/typographic tier), `audience` (tone-only, no render effect yet) — `NarrativeProfile`, `src/narrative/index.ts:63-67`. 7 named presets (`NARRATIVE_PRESETS`, same file:299, each carrying soft `themeRecommendations`) plus a per-axis default chain (`resolveNarrative`, `src/narrative/index.ts:378-412`: omission → default, typo → hard `PptfastError`). `strategy` feeds layout selection (`docs/selection-and-seed.md`), and `pacing` feeds the capacity split below. Renamed from `scenario` (`mode`/`delivery`) in the vocabulary-v4 rewrite (spec: `.issues/specs/2026-07-19-pptfast-narrative-spec-vocabulary.md`) — `mode` was too generic a name for what it actually controls (argument structure), and `delivery` conflated density, tier, and mode of address into one axis. `audience` keeps its original name and values unchanged. A v4-labeled document that still writes the pre-rename field names or enum values (`scenario`, `mode`, `delivery`, `mode: "narrative"`, `delivery: "text"`/`"presentation"`) is hard-rejected, not rescued (spec §16, reversing an earlier §15.4 call to alias-rescue it): `scenario` fails `PptxIRSchema`'s `.strict()` parse as an unrecognized key, and `mode`/`delivery`/old enum values fail `resolveNarrative`'s own runtime check, both listing the current vocabulary. `pptfast migrate` remains the only path for genuinely old (v3) documents — a v3-versioned document still hard-rejects at `validateIr` with a migration pointer, before the schema parse ever runs. ## Capacity: dual ownership Two independently-owned ceilings, `min()`'d together at validate time — never one table: - **Editorial budget** (pacing, content discipline — "how many things belong here"): `PACING_BUDGETS[pacing]` (`src/narrative/index.ts:262-266`) — `maxComponentsPerSlide` and the bullets item/length caps, plus `bodyBaselinePx` (the paragraph/bullets/callout trio's sole font-size input, nothing else): `dense`=20px/5 components/6×48-char bullets, `balanced`=24px/4/5×40 (the narrative default), `spacious`=32px/3/4×30. - **Geometric capacity** (layout, physical fact — "how many things fit"): the resolved layout's `body` slot `capacity` (`resolveEffectiveLayoutBodyCapacity`, `src/svg/layout-selection.ts`). `ir-quality.ts`'s density gate takes `min(editorial, geometric)`. This is why validate must resolve the same layout selection render will use — the parity discipline `docs/selection-and-seed.md` documents. A slide's optional `notes` (speaker notes, `src/ir/index.ts`) sits outside this split entirely — content layer, not geometry: it never reaches the canvas SVG, so it carries nothing for either ceiling to measure. **A card list's own `items` bound sits outside both ceilings** (bench-driven fix round, defect E) — `row_cards`/`icon_cards`/`numbered_cards`' schema array bounds (3-6/2-6/3-8) are neither pacing (their type scale is hardcoded, none of the three reads `bodyBaselinePx`) nor a layout's `body` slot `capacity` (that counts components on a slide, not items inside one card-list component). Measured directly (`SvgComponent.measure`, realistic item content, `src/svg/components/{row,icon,numbered}-cards.tsx`): `row_cards` stacks items in one column with no row-wrap, so height grows linearly (~115-136px/item) — its schema max of 6 already exceeds a typical single-column content rect (3-4 items fit ~330-450px) and roughly halves again under `two_column` (2-3 items fit), which is exactly the benchmark's "5 items, 2 dropped" — while `icon_cards`/`numbered_cards` are grids that self-limit to <=2 rows by construction regardless of item count, so their own schema maxes (6, 8) stay well inside even a tight ~250-330px budget at every width tested. Adjudication: leave every one of the three schemas unchanged — a flat `items` ceiling can't honestly encode a per-arrangement geometric fact a single-vs-`two_column` width swing already halves for any stackable component, and `row_cards`' 3-6 range communicates editorial intent ("this many substantial entries can belong here"), not a rendering guarantee. The runtime already degrades gracefully at every width (`layoutContentFit`'s drop path, `row-cards.tsx`'s own item clip) — what was missing was visibility, now closed by the `content-dropped`/`content-truncated` advisory audit findings rather than a schema change. ## v4 schema freeze `PptxIRSchema` (`src/ir/index.ts:811-855`) is frozen as of the 0.4.0 npm release — future evolution is additive only (new optional fields, new enum members). Any breaking change ships under a new top-level `version` value with the same hard-reject-and-migration treatment v3 got. `version` now defaults to `"4"` (an omitted version is v4, not v3), `filename`/`narrative`/`theme` are all optional with schema-level defaults (`"presentation"`/general preset/`consulting`) — see `src/ir/index.ts` for the full defaulting chain and README's "The IR" section for the user-facing field list. ## Settled decisions — do not relitigate Foundational adjudications, carried forward unchanged by the vocabulary-v4 rename. Proposals that contradict these need an explicit product decision, not a wave plan: - **No slideMaster, ever (Keynote-style).** Brand chrome is drawn flat into each slide's SVG through a layout's optional slots. The real `.pptx` slideMaster is intentionally near-empty. Enterprise or brand adaptation means `theme.brand` + style tokens + `registerTheme` — never PPTX template import or master adaptation, which belong to a different product category. - **Every revision flows through the deck project's gates.** The single source of truth is `deck.spec.json` + `pages/*.json`, and every edit passes `validate`/`audit`. Preview stays read-only by design — `preview.html` overlays `audit` findings and shows the deck, and that is all it does (`docs/deck-projects.md`'s six-phase workflow). It once carried in-page annotations that exported a `revision-request.json`, and `pptfast serve` carried a submit control that POSTed the same file straight to the deck directory; both were removed on 2026-08-16, since a reviewer describing a change in conversation — usually with a screenshot — reaches the agent faster than one filling in a panel whose output has to be exported and routed back. The gate itself never depended on that machinery: `pages/*.json` is written by the agent's own deliberate edit and by nothing else, which is what the settled decision actually protects. A full in-preview editor stays deliberately deferred. - **The model owns semantics, the engine owns geometry.** No workflow may ask a model to emit coordinates, pixel sizes, or free-form SVG. Weak-model stability is the product's first invariant — capability additions that reintroduce render variance (e.g. native PowerPoint chart internals the audit cannot measure) ship as explicit opt-ins, never defaults.