# OPF 0.15: catalogs, layouts and backgrounds

Design for format-audit wave C (FA-20 to FA-26). Adopted by the owner on 2026-10-07; the working copy is the [design doc](https://claude.ai/artifact/3Va6gKnmcSbsdmCMMVRXBo). This file is the copy agents implement from.

2026-10-07 · Michael Grimm

Recommendation: keep layouts as a first-class part of the OPF format, but ship zero layout records with core. Named layouts come from external catalogs that hosts register explicitly, and authoring tools embed every definition a document uses. The size win from removing layouts alone is small, about 10 KB gzipped; the real wins are one-way ownership and catalog releases that no longer need a core release.

## Recommendation

Adopt the proposed direction, in phases, with one bounded extension: layout records may contain nested placeholder groups. Core keeps the layout schema, references, composition semantics, validation and diagnostics; it stops shipping and silently falling back to its 100 built-in records.

**Pros**

- One owner per record and a one-way flow. Today records sync both ways between core and pptx.gallery, and the two repositories' docs disagree on who is the source of truth.
- Catalog releases stop requiring core releases. Today a layout change waits for a core release because the 100 records are compiled into core.
- Saved documents become self-contained. A document carries every definition it uses, so it renders the same in any engine, offline, years later.
- The engine loses hidden coupling to specific records: the hard-coded cover ids `title` and `title-subtitle`, and the `minimal` / `cool-horizon` fallbacks.

**Cons**

- The repositories' own documents (103 core example decks, the bundled narratives, gallery records, pptx-dev templates) are rewritten to the new shape. No one else uses OPF yet, so 0.15 is a clean spec: no migration command and no deprecation window.
- Documents grow by the embedded records, about 0.6 KB per distinct layout (schema fields only).
- Hosts must register catalogs explicitly. A host that forgets sees opf/unresolved-reference warnings and engine defaults, not the intended design.
- The large size win (about 85 KB gzipped) needs every catalog kind out of core's import graph, not just layouts; 0.15 does that.

**Alternatives weighed**

| Option | What it means | Why not chosen |
|---|---|---|
| Keep the 100 built-ins (status quo) | Core bundles a subset; the gallery adds the rest | Two-way sync, docs that contradict each other, catalog changes gated on core releases, documents that depend on which core version renders them |
| Bundle all 459 gallery layouts in core | One complete built-in set | About 382 KB more in every browser bundle, and core releases for every gallery change |
| Fetch catalogs at render time | Engines resolve `catalogs.*.source` over the network | Breaks offline and deterministic rendering, which is a hard requirement |
| Zero built-ins, embed on save (recommended) | External catalogs; authoring tools embed what a document uses | Needs explicit host registration and a rewrite of the repositories' own examples |

Layouts stay first-class: the schema, `Slide.layout`, inline records, precedence and diagnostics are all part of the format. Only the records move out.

## What the code does today

Today every engine resolves a layout from inline records, then host records, then core's 100 built-in records, and the built-ins are always the last fallback. These facts come from current main (core 0.14.0, render 0.14.0, pptx 0.14.0, editor 0.14.2, gallery ffbe23f, pptx-dev 3480454).

_(Diagram in the original design doc.)_

The built-ins sit under every resolution, and export sees fewer catalogs than preview.

| Area | Current behaviour | Where |
|---|---|---|
| Records | Core compiles 100 layout records (plus 10 other catalog kinds) into one JS chunk that the root, `/composition`, `/validator` and most subpaths import statically | `src/generated/catalogs.ts`, `dist/chunk-XOZ4KMSR.js` |
| Gallery | Publishes 459 layouts: 100 copied from core, 359 gallery-only; records sync in both directions | `pptx-gallery/lib/opf-catalog.mjs`, `opf/scripts/sync-gallery-catalog.mjs` |
| Resolution | Inline `catalogs.layouts.records`, then host `options.catalogs`, then the built-ins; `catalogs.layouts.source` is never read by core; nothing is ever fetched | `slide-context.ts:84-90, 136-140` |
| Consumers | Render, PPTX and editor all call core `resolveSlideContext` + `composeSlide`, but PPTX passes no host catalogs and `paginate()` takes none, so a host-supplied layout resolves in preview and not in export | render `svg.js:602`, pptx `index.js:1631`, `pagination.ts:305` |
| Diagnostics | No layout: no finding, automatic composition. Unknown id: `unresolved-layout` + `opf/catalog-reference` warning, automatic composition. A gallery-only id is reported exactly like a typo | `check-document.ts:751` |
| Precedence | Design hints: slide, host deck option, deck, layout, engine default. Composition: slide over layout, per key | `design-hints.ts:55-70`, `composition.ts:2104` |
| Coupling to specific records | Cover detection uses hard-coded ids `title` and `title-subtitle`; fallback theme `minimal` and colour scheme `cool-horizon` come from the built-ins; `chartPrimary` builds a hidden nested container with fixed weights | `composition.ts:445, 2429-2441`, `slide-context.ts:24-25` |
| Embedding | Nothing embeds on save or export. The gallery and pptx-dev embed only gallery-only records, and leak gallery metadata into them | `opf-snippets.ts:957-968`, pptx-dev `gallery-registry.ts:361` |
| Copying slides | Editor paste always renames incoming inline records to `import-<id>` with no content dedupe; a built-in reference passes through with no record; a host-only layout is lost | editor `transfer.js:141-169` |
| Updates | No "update from catalog" flow exists | — |
| Arrangement | Layout records are flat: ordered placeholders + one `composition` (`auto`/`grid`/`row`/`column`, weights, gap, padding). Nesting exists only in slide content (`blocks` groups, 32 levels) | `layout.schema.json:253-312`, `opf.schema.json:2433` |

Eight places in the docs disagree with the code, most visibly that gallery-only layouts "resolve online" (core never fetches) and that a `source` "replaces" the default (the built-ins are always appended). Phase 0 fixes them.

## Proposed architecture

Records flow one way, from the catalog that owns them, through an authoring tool that embeds them, into a self-contained saved document; engines then resolve only from that document and from catalogs the host registers.

_(Diagram in the original design doc.)_

The saved document is the only thing every engine needs; registered catalogs are optional and never fetched.

**Catalogs in a document**

`catalogs` groups embedded records by where they came from. Each group has a name; inside a group, records are keyed by kind and then by id.

```json
"catalogs": {
  "default": {
    "source": "https://www.pptx.gallery",
    "layouts": { "two-column": { "…": "…" } },
    "themes": { "minimal": { "…": "…" } }
  },
  "acme": {
    "source": "pkg:@acme/opf-catalog",
    "layouts": { "hero": { "…": "…" } }
  },
  "custom": {
    "layouts": { "q4-special": { "…": "…" } }
  }
}
```

- `default` is the catalog that bare ids come from. `custom` holds the records the document defines itself. Any other key names an extra catalog and is the prefix references use, as in `acme:hero`. Names match `^[a-z][a-z0-9-]*$`; `default` and `custom` are reserved.
- `source` identifies a catalog: a URL or a `pkg:` package reference. It appears once per group. Embedded records carry no `$schema`, `id`, origin or digest; the key is the id.
- `default` needs a `source` when it holds records. Leaving `default` out means the host's default catalog; `"default": false` means no catalog fallback, so every reference must be embedded.
- Every reference, for every kind (`Slide.layout`, `design.theme`, `design.colorScheme`, `narrative` and the rest), is either `id` or `name:id`, and every prefix must name a group. References are never URLs or `pkg:` strings; a named catalog replaces them.
- A reference inside an embedded record resolves in that record's own group first: acme's theme naming `"colorScheme": "ocean"` finds acme's `ocean` before the default catalog's.

Catalog references are for content: layouts, themes, colour schemes, font schemes, narratives, audiences, purposes and tones. Values an engine must understand to draw (`chart.type`, `language` tags and social platform keys) are engine vocabularies: the schema and the engines validate them directly, with no catalog lookup, and their labels and descriptions stay in the gallery as display metadata.

**Resolution**, one rule for every kind:

1. A bare id: `custom`, then the records embedded under `default`, then the host's catalog for `default.source` (or the host's default catalog when `default` is omitted).
2. `name:id`: the records embedded under that group, then the host's catalog for its `source`.
3. Otherwise the reference is unresolved: an opf/unresolved-reference warning names the reference and the catalog's source, and the slide uses automatic composition or the engine default. Strict export fails.

No engine ever fetches. Hosts register catalogs by their `source` string, as `catalogSources` does today. Core ships the gallery snapshot only as the opt-in subpath `@openpresentation/opf/catalog`, which no other entry imports.

**Ownership over time**

- Saving embeds each referenced record once, in its group, together with the records it references (a theme's colour and font schemes). Catalog display metadata (`x-*`) is stripped.
- Forking a catalog record copies it into `custom` under a new id and rewrites the deck's references, so the change of ownership is visible. Editors fork automatically on the first edit of a record under `default` or a named group (core `moveToCustom(document, { kind, reference }, { id })`); a record is never edited in place inside a catalog group.
- Update from catalog covers records under `default` and named groups only. It compares each embedded record with the catalog's current one and changes nothing until the author approves the difference.
- Copying slides matches groups by `source`, not by name. References are rewritten to the target's name for that source, and a missing group is added (renamed if its name is taken). A `custom` id with different content is renamed `<id>-2`. A catalog record whose revision differs from the target's goes into `custom` as `<id>-2`, so the copied slides look the same, and the copy result lists it.
- A record under `default` or a named group that its catalog does not publish is a mislabelled record of the document's own. It still renders (embedded records always win), but when the host registers that source the validator reports opf/catalog-record-not-in-source (warning) and suggests moving it to `custom`. A record that exists in the catalog with different content is an update difference, not this finding.

## Boundaries

Each part owns one thing, and no part edits what another owns.

| Part | Owns | Does not |
|---|---|---|
| Core (`@openpresentation/opf`) | Layout schema, `Slide.layout` reference rules, resolution order, precedence, `composeSlide`, validation rules and diagnostics, the embed and copy helpers | Ship, sync or fall back to any named record; fetch anything |
| Catalogs (pptx.gallery, a company catalog, the optional default-catalog package) | The records they publish, one owner per id; published revisions; their own display metadata under `x-*` | Change engine behaviour; require a core release to publish |
| Authoring tools (editor, pptx-dev Author, gallery snippets, site playground) | Registering catalogs, offering pickers, embedding used records on save and export, copy and paste, the explicit "update from catalog" action | Copy layout defaults into `slide.design`; update embedded records without the author asking |
| Engines (render, PPTX, editor canvas) | Calling core resolution and composition with the host's registered catalogs | Keep their own catalog lookup or layout special cases |
| Saved documents | Every record they reference, embedded once under the catalog it came from | Depend on which engine or core version opens them |

What goes where for a layout like SWOT analysis:

| Concern | Lives in |
|---|---|
| Four list regions, grid, spacing, card default | Layout record (`placeholders`, `composition`, `design`) |
| Strengths / weaknesses / opportunities / threats prompts and sample text | Example recipe in the catalog (gallery data, not the record) |
| Category, search terms, thumbnail, display label | Catalog metadata (`x-gallery`), stripped on embed |
| The actual presentation content | The slide |

## Arrangement model

Recommendation: let layout records hold nested placeholder groups, reusing the composition semantics slide content already has, and capped at three levels. Flat presets cannot store the structure many published layouts promise.

**Evidence that flat presets fall short.** 320 of the 459 gallery layouts have more than one body region, and several describe structure their record cannot express:

- `chart-3x-left` and its 11 siblings store a flat row, but the engine overrides it through `chartPrimary`, a special case that builds a hidden nested container with fixed `[3,2]` weights.
- `dashboard` says "small charts in a grid" and stores two charts in a row; `business-model-canvas` says "9-box canvas" and stores a title and one text region; `kpi-scorecard`, `market-size` and `org-chart` are similar.
- Today the nesting lives in preview images, in that engine rule, and in slide content.

**Options**

| Option | Pros | Cons |
|---|---|---|
| A. Flat presets only (today) | Smallest schema; simple pickers; easy to validate | Common structures become engine special cases (`chartPrimary`) or per-slide content; records under-describe what they promise |
| B. Nested placeholder groups in records (recommended) | Expresses "two stacked panels beside a chart" once, reusably; turns `chartPrimary` into data; uses the same `Composition` object and group semantics slide content already has | More schema and validation; a rule is needed for mapping slide content into nested slots; editor UI for nested slots |
| C. Nested structure only in example content | No schema change | Structure is copied into every slide, so it cannot be updated from the catalog, and it repeats the copy-into-the-slide problem this design removes |

**Proposed extension (option B, bounded)**

- A placeholder entry may be a group: `{ "type": "group", "composition": { … }, "placeholders": [ … ] }`. A group's `composition` is the existing `Composition` object; only `minFontSize` and `overflow` inherit, as with content groups today.
- Maximum depth 3 inside a record, enforced by schema and validator.
- Slide content fills the leaves in reading order, with the same kind matching used for flat placeholders today. A slide's own `composition` or `blocks` still override the record, per key, at the root.
- `chartPrimary` stays accepted as shorthand, but is documented as sugar for a group, and gallery records that use it are rewritten to explicit groups.
- Not in scope: named semantic slots ("strengths", "threats"). Add them only if editing or automatic placement needs them; catalog descriptions alone do not justify the schema cost.

## Backgrounds and images

Decision: a full-bleed photo is a background option, not a layout, and every content picture is an image block. This replaces three overlapping mechanisms with two concepts and one fit vocabulary; it supersedes the interim `image-bleed` record merged in opf#448.

**What is tangled today**

| Property | What it does | Fit vocabulary |
|---|---|---|
| `design.background = {type: "image", image: {src, fit}}` | Full-slide picture behind everything; deck, slide (`slide.design.background`) or theme | `cover`, `contain`, `tile` |
| `design.slideImage = {src, position, size, inset, fill, alt}` | Full-slide picture or an edge band that pushes content aside; a content picture stored under `design` | `crop`, `fit` |
| `design.imageFill` | Default fit for content images | `crop`, `fit` |
| `Slide.image`, image blocks | Content pictures (`Slide.image` is shorthand for one image block, though its description says "full-slide image") | take `imageFill` |

slideImage also carries the image-treatment vocabulary behind the 15 pptx.gallery image treatments (shape, cornerRadius, border, opacity, recolor, an overlay with edge bands, aspectRatio) and the PPTX import tags. 210 layout records (15 core, 195 gallery) set `slideImage: {position: "background"}` only to opt in to the deck's image. Content images, `{ type: "image", image }`, have no fit and no treatments.

**The model**

- **Background** is the only canvas fill: theme slot, solid, gradient, pattern or image, on the theme, the deck or a slide (`slide.design.background`). It never moves content. The image form is flat: `{ "type": "image", "src", "alt", "fit", "focus": { "x", "y" }, "opacity", "recolor", "overlay" }`. The string shorthand accepts theme slots, hex colours, and image sources that start with `asset:`, `https://`, `data:`, `./` or `../`; an image source means `fit: "cover"`.
- **Image blocks** carry every content picture, with the full treatment set: `{ "type": "image", "image", "fit", "focus", "shape", "cornerRadius", "border", "opacity", "recolor", "overlay", "aspectRatio", "placement" }`. `Slide.image` is shorthand for one image block.
- **Placement** bleeds an image block to one edge: `placement: { "edge": "left" | "right" | "top" | "bottom", "size": 0.1–0.9 (default 0.5), "inset": false }`. Headings and content compose in the remaining space. A layout's image placeholder may carry the same placement. Only top-level blocks may be placed, at most one per edge, and placement is the only mechanism that moves content aside.
- **One overlay**, `{ color, opacity, edge?, size? }`, shared by backgrounds and image blocks; `color` is any ColorRef, including theme roles.
- **One fit vocabulary**: `cover | contain | stretch | tile` on backgrounds, `cover | contain | stretch` on image blocks, and `design.imageFit` for the default (`cover`).
- **Not in 0.15**: `design.slideImage`, `design.imageFill`, the `image` wrapper inside image backgrounds, the `image-bleed` layout and the layout opt-in. Any layout sits on any background.

**PowerPoint mapping.** stretch and tile map to native picture fills; cover with focus maps to a cropped fill; contain maps to a fitted fill over the background colour. A background image with alt exports as a full-slide picture at the back, so screen readers see it; without alt it stays a native slide background. An overlay exports as one shape directly above its picture. Image-block treatments keep the DrawingML mapping slideImage uses today (prstGeom, alphaModFix, grayscl and duotone, ln). Import reads a native background picture as an image background, and the shape tags become OPF_IMAGE_V1 and OPF_IMAGE_OVERLAY_V1 on image blocks.

## Example documents

Both documents use the 0.15 shape and render the same with no host catalog registered. Record and chart payloads are abbreviated.

**Gallery layout and a full-bleed photo.** One gallery record, referenced by two slides, embedded once; the cover is a background, not a layout.

```json
{
  "$schema": "https://openpresentation.org/schema/opf/v1",
  "design": { "theme": "minimal", "contentBox": false },
  "catalogs": {
    "default": {
      "source": "https://www.pptx.gallery",
      "layouts": {
        "two-column": {
          "name": "Two column",
          "placeholders": [{ "type": "title" }, { "type": "text" }, { "type": "text" }],
          "composition": { "mode": "row", "weights": [1, 1] },
          "design": { "contentBox": true }
        }
      },
      "themes": { "minimal": { "…": "…" } },
      "colorSchemes": { "cool-horizon": { "…": "…" } },
      "fontSchemes": { "aptos": { "…": "…" } }
    }
  },
  "slides": [
    {
      "title": "Shipping, rebuilt",
      "design": {
        "background": {
          "type": "image",
          "src": "./images/harbour.jpg",
          "alt": "Container ships at dawn",
          "focus": { "x": 0.5, "y": 0.7 },
          "overlay": { "color": "dark1", "opacity": 0.4 }
        }
      }
    },
    { "layout": "two-column", "title": "Before", "blocks": [{ "type": "text", "text": "Manual exports" }, { "type": "text", "text": "Two-day turnaround" }] },
    { "layout": "two-column", "title": "After", "blocks": [{ "type": "text", "text": "One click" }, { "type": "text", "text": "Same day" }] }
  ]
}
```

The cover keeps its title in the normal place over an edge-to-edge photo, tinted for contrast; no layout is involved. The two-column record says contentBox: true; the deck says false, so the deck wins and no cards are drawn. The theme's colour and font schemes are embedded with it. A slide with no layout composes automatically and produces no diagnostic.

**Company theme, a document's own nested layout and an edge image.** The theme comes from acme's catalog; the layout is defined in the document.

```json
{
  "$schema": "https://openpresentation.org/schema/opf/v1",
  "design": { "theme": "acme:brand" },
  "catalogs": {
    "default": false,
    "acme": {
      "source": "pkg:@acme/opf-catalog",
      "themes": { "brand": { "colorScheme": "ocean", "fontScheme": "inter" } },
      "colorSchemes": { "ocean": { "…": "…" } },
      "fontSchemes": { "inter": { "…": "…" } }
    },
    "custom": {
      "layouts": {
        "stacked-text-chart-right": {
          "name": "Stacked text, chart right",
          "placeholders": [
            { "type": "title" },
            {
              "type": "group",
              "composition": { "mode": "column", "weights": [1, 1] },
              "placeholders": [{ "type": "text" }, { "type": "text" }]
            },
            { "type": "chart" }
          ],
          "composition": { "mode": "row", "weights": [2, 3] }
        }
      }
    }
  },
  "slides": [
    {
      "layout": "stacked-text-chart-right",
      "title": "Revenue grew 18% on two drivers",
      "blocks": [
        { "type": "text", "text": "Enterprise seats up 31%" },
        { "type": "text", "text": "Churn down to 2.1%" },
        { "type": "chart", "chart": { "type": "column", "data": { "labels": ["Q1", "Q2", "Q3"], "series": [{ "name": "Revenue", "values": [4.1, 4.5, 4.9] }] } } }
      ]
    },
    {
      "title": "Where we ship",
      "blocks": [
        { "type": "image", "image": { "src": "./images/routes.png", "alt": "Shipping routes" }, "fit": "contain", "placement": { "edge": "right", "size": 0.45 } },
        { "type": "text", "text": "Twelve ports, three continents" }
      ]
    }
  ]
}
```

The slide content stays flat: the two text blocks fill the group's two leaves in order, and the chart fills the right slot. acme's brand theme names ocean and inter, which resolve inside acme first. "default": false means nothing falls back to the gallery, even on a host that registers it. On the second slide the image bleeds to the right edge and the title and text compose in the remaining 55%.

## Requirements

Each requirement maps to one mechanism, owned by one part.

| # | Requirement | Mechanism | Today |
|---|---|---|---|
| 1 | One owner, one-way publishing | pptx.gallery owns the default catalog; core syncs a pinned snapshot one way and ships it only as the opt-in subpath @openpresentation/opf/catalog | Two-way sync between core and gallery; contradictory docs |
| 2 | Embed used definitions on save or export, once | Core embed(document, { catalogs }) adds each referenced record once under its group, plus the records it references, with x-* stripped; every authoring tool calls it on save and export | Nothing embeds; partial embedding leaks gallery fields |
| 3 | Copying slides carries definitions and handles collisions | Core copySlides(from, to, indexes) matches groups by source, reuses identical records, renames a differing custom record to <id>-2, and moves a differing catalog revision into custom | Editor always renames to import-<id>, no dedupe; built-in and host-only layouts are lost |
| 4 | Preserve embedded definitions unless the author updates | Engines never replace embedded records. updateFromCatalog(document, catalogs, refs?) compares embedded records with the catalog's current ones and returns a reviewable change | No update flow |
| 5 | Distinguish automatic from missing | No layout = automatic, no finding. A reference that resolves nowhere = opf/unresolved-reference (warning naming the reference and the catalog's source); strict export fails | Missing ids reported as opf/catalog-reference, the same as a typo of any kind |
| 6 | Layout defaults stay defaults | Precedence stays slide, deck, layout, engine default; authoring tools write only what the author changed. A test fails if saving adds layout design keys to slide.design | The engine is right; the gallery snippet builder copied defaults (fixed in RR-58) |
| 7 | Records contain their advertised behaviour | Records declare their own composition; a bare-record test checks each record without example additions; full-bleed photos are backgrounds, not records | image-bleed relied on a gallery special case; RR-58 moved it into the record and 0.15 removes it |
| 8 | One resolution and composition path | All engines pass the same registered catalogs to core resolveSlideContext and composeSlide; PPTX and paginate() gain the catalogs they lack | PPTX ignores host layout catalogs; pagination takes none |
| 9 | No built-in records | Core's main entry imports no catalog data of any kind; theme, colour-scheme and font fallbacks are engine defaults; cover detection is a record rule | 100 layouts and 10 other kinds compiled into core; hard-coded cover ids |
| 10 | Measure, don't assume | Section below; a CI budget on core's catalog-free entry | Measured for this design |

## Measurements

Removing the 100 layout records saves about 10 KB gzipped per browser bundle (3-4%); removing every catalog kind from core's import graph saves about 85 KB (22-29%), and narratives, not layouts, are the largest part.

Method: esbuild 0.28.2, browser ESM, minified, gzip level 9, on current main. Core's catalog chunk was replaced by stubs (empty layouts, or all kinds empty), and every stubbed bundle was executed to confirm it still validates and composes.

| Bundle | Full (gzip) | Without layouts | Without all catalogs |
|---|---|---|---|
| Core `validate` + `composeSlide` | 289.6 KB | 277.7 KB (−11.9, −4.1%) | 204.5 KB (−85.0, −29.4%) |
| Core `composeSlide` only | 125.1 KB | 113.7 KB (−9.1%) | 40.7 KB (−67%) |
| opf-render `renderSlideSvg` | 390.0 KB | about −10 KB | 304.9 KB (−21.8%) |
| opf-editor main entry | 304.0 KB | about −10 KB | 218.9 KB (−28.0%) |

Catalog bytes inside each bundle, minified: narratives 137 KB, catalog indexes 49 KB, layouts 60 KB, font schemes 40 KB, languages 20 KB, other kinds 47 KB; 367 KB in total.

npm package: core packs to 3.04 MB. Catalogs ship twice, as raw `dist/spec/catalogs` JSON and as the compiled JS chunk, together 213 KB packed (7%); layouts are about 20 KB of the raw copy.

What this means for the design:

- Zero built-in layouts is worth doing for ownership and release independence, not for size.
- The size win requires taking all catalog kinds out of the import graph, including the font-scheme lookup that pulls the whole catalog into `/composition` (`script-fonts.ts:3`).
- For scale: the 0.14 font-policy and symbol-encoding modules added 78.7 KB gzipped to the renderer bundle, eight times what layouts cost.
- CI should hold a budget: core's catalog-free entry fails the build if any catalog bytes reappear in its metafile.

## Phased plan

No one depends on 0.14 yet, so 0.15 is a clean spec: no migration command, no deprecation window, and the repositories' own examples, records and sites are rewritten to it. Three phases; each ends at a gate that must pass before the next.

| Phase | Work | Repos | Gate |
|---|---|---|---|
| 0. Consistent today (core 0.14.1) | Release RR-58 (opf#448, #450, gallery#113) as core 0.14.1, then the gallery drops its two CORE_BRIDGES. PPTX passes host catalogs; paginate() accepts catalogs; editor getCatalogOptions merges sources. Fix the eight doc/code mismatches and Slide.image's description. Release-train verify retries after a publish, the gallery-snapshot bundler fix, and CLI 0.14.0 rows in the release plan and compatibility matrix | core, pptx, editor, gallery | One document with a host-supplied layout resolves identically in render, editor and PPTX |
| 1. Clean 0.15 spec | (a) Catalogs: the grouped shape, id or name:id references for every kind, the nested-reference rule, embed, copySlides, updateFromCatalog, opf/unresolved-reference. (b) Zero built-ins: the main entry imports no catalog data; the @openpresentation/opf/catalog subpath; engine defaults; the cover rule. (c) Backgrounds and images: the flat image background with fit, focus, opacity, overlay, alt and the image shorthand; image blocks with fit, treatments and placement; one Overlay; one fit vocabulary; slideImage, imageFill and image-bleed removed; PowerPoint export and import. (d) Every example, narrative, gallery record and site rewritten to 0.15. Core, render, pptx, editor and CLI release 0.15.0 in one train; gallery, pptx-dev and the site adopt it | all seven | Catalog-free bundle budget holds in CI; every authoring tool's saved document renders identically with no host catalog; golden changes reviewed |
| 2. Nested placeholder groups (0.15.x) | Schema, validator and composeSlide support group placeholders (depth 3); chartPrimary documented as sugar; the gallery rewrites chart-3x-* and other structure-promising records; the editor shows nested slots | core, render, pptx, editor, gallery | The nested example renders identically in all three engines; rewritten records match their previews or have reviewed diffs |

Sequencing: phase 0 is small and goes first. Phase 1 is one breaking release, so the spec changes once; it lands as several pull requests through the merge queue and releases together. Phase 2 is additive and follows as 0.15.x.

## Acceptance criteria and tests

The design is done when every test below passes in CI across core, render, pptx and editor, using the two example documents above plus core's example decks.

| Area | Test | Pass condition |
|---|---|---|
| Portability | Render each example with no host catalog, offline, in render, editor and PPTX | Identical resolved context and geometry in all three; golden images unchanged |
| Portability | Save from each authoring tool, then reopen in a fresh process with no catalog registered | No opf/unresolved-reference findings; every referenced record is embedded exactly once |
| Embedding | Embed into a document whose slides use 3 layout ids, one of them twice, and a theme | 3 layouts and the theme added once each under their group, with the theme's colour and font schemes; no x-*, $schema or id fields |
| References | acme:hero with acme embedded; with acme registered only by the host; with neither | Resolves from the document, then from the host; otherwise one opf/unresolved-reference naming acme:hero and acme's source |
| References | A reference with an undeclared prefix, foo:hero | Validation error at the reference |
| References | "default": false and a bare id that is not in custom | Unresolved, even when the host registers a default catalog |
| References | acme's theme names a colour scheme that acme also embeds | acme's scheme wins over the default catalog's |
| Precedence | Layout contentBox: true, deck false, slide unset; then slide true | Deck wins, then slide wins; after a save, slide.design gains no layout keys |
| Precedence | Layout composition, slide overrides one key | Slide key wins; the record's other keys still apply |
| Missing references | Slide with no layout | No finding; automatic composition |
| Missing references | Slide naming an id that resolves nowhere | One opf/unresolved-reference warning naming the id; automatic composition; strict export fails with the same id |
| Missing references | Id present only in a registered host catalog | Resolves the same in preview, editor and export |
| Copying slides | Copy a slide whose record already exists in the target with the same content | Record reused, no rename |
| Copying slides | Copy a slide whose custom id exists in the target with different content | Record renamed <id>-2, copied slides point at it, the target's own slides unchanged |
| Copying slides | Source and target name the same catalog source differently | Copied references are rewritten to the target's name |
| Copying slides | Target holds a different revision of the same catalog record | Incoming record moves to custom as <id>-2 and is listed in the result; copied slides look unchanged |
| Copying slides | Copy the same slide twice | Second copy reuses the first copy's record |
| Updates | Catalog publishes a new revision; reopen the document | Embedded record unchanged; an update check lists the difference |
| Updates | Author runs update from catalog for one reference | Only that record changes, after approval |
| Records | Each catalog record composed bare, with a minimal document and no example additions | Validates and composes without warnings |
| Nesting | Nested example in all three engines | Same leaf boxes in preview, editor and PPTX, within 0.5 pt |
| Size | esbuild metafile of core's main entry | Zero bytes from catalog modules; gzip size within the recorded budget |
| References | default.layouts.q4-special embedded; the host registers default's source, which has no q4-special; then the source has it with different content; then the source is not registered | One opf/catalog-record-not-in-source warning at catalogs.default.layouts.q4-special; then none (an update difference); then none. The embedded record renders in all three |

Backgrounds and images add these:

| Area | Test | Pass condition |
|---|---|---|
| Background fits | Each of cover, contain, stretch, tile in preview and PPTX | Same image frame in both; PPTX uses a native fill; PowerPoint opens the file cleanly |
| Focus | cover with focus at a corner on a 4:3 and a 16:9 deck | The focus point stays inside the visible crop in preview and export |
| Overlay | Title over a busy photo with and without overlay, then an edge-band overlay | Contrast check passes only with the overlay; the overlay sits between background and content; the band covers only its edge |
| Alt text | Background image with alt, then without | With: picture shape at the back carrying the alt text; without: native slide background |
| Shorthand | "asset:x", "./x.jpg", "light1", "#ffffff", "x.jpg" | Cover image, cover image, theme slot, solid fill, validation error |
| Treatments | The 15 gallery image treatments as image blocks | Same frames and paint order in SVG and PPTX as the 0.14 slideImage goldens |
| Placement | Image block with placement: {edge: "left", size: 0.45} | Image bleeds to the left, top and bottom edges; title and text compose in the remaining 55% at normal padding |
| Placement | Two blocks placed on the same edge; a placed block inside a group | Validation errors at both |
| Round trip | Export, then import, a deck with an image background, an overlay and a treated, placed image block | Imports back to the same OPF |

## Open questions

- Is three levels of placeholder nesting enough? business-model-canvas needs a 3 × 3 grid with spanning cells, which groups alone do not express. **Answered by FA-26:** the canvas is two levels, a column of a top band (partners, activities over resources, value propositions, relationships over channels, segments) and a bottom band (costs, revenue); the tall cells are the band's single regions, so no spanning is needed and the gallery record expresses it with groups.
- Should a top-level Slide.background alias exist, or is slide.design.background enough once documented?
- Which theme colour roles may overlay.color name, and what opacity should the gallery's presets default to?
