| summary | v4 concept model: theme/layout/component/narrative, the capacity dual-ownership split, and the frozen v4 IR schema | |||
|---|---|---|---|---|
| read_when |
|
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.
{ 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<Record<Slide["type"], readonly string[]>> (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.
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/<name>.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.
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.
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.
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) —maxComponentsPerSlideand the bullets item/length caps, plusbodyBaselinePx(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
bodyslotcapacity(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.
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.
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
.pptxslideMaster is intentionally near-empty. Enterprise or brand adaptation meanstheme.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 passesvalidate/audit. Preview stays read-only by design —preview.htmloverlaysauditfindings 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 arevision-request.json, andpptfast servecarried 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/*.jsonis 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.