---
title: Decks (AI presentation builder)
---

# Decks (AI presentation builder)

## What it is

**Decks** is a Gamma-style AI presentation builder inside Omniscio,
surfaced as a virtual project in the sidebar. You describe a deck in plain
language; Claude returns an editable **outline** (titles + one-line summaries);
you approve it; then Claude generates each **card** — real rich content plus a
chosen **layout** from a fixed library — with a real narrative arc and **auto
speaker notes**. The result is an ordered list of cards you can reorder, edit,
present, **publish to a shareable web link**, and **download as PDF, HTML, or
PowerPoint (`.pptx`)**.

Slides show **real images** when the model supplies one, over a **deterministic
generative-art** base (so nothing ever looks empty) — see [Real images +
generative art](#real-images--generative-art). A **presenter view** (notes + next
slide + timer) and a static **PDF / HTML / PowerPoint export** round it out.

A deck is an ordered list of **cards**. Each card holds normal rich content
(headings, paragraphs, bullets, image) plus two presentation attributes: a
**layout name** (from the finite registry) and an optional **style**
(accent-image position, full-bleed, vertical alignment). This is the unit the
generator emits and the editor renders.

### Status

Ships the data model, the two-stage AI generation pipeline (idempotent, with a cost
cap + progress + resilient parsing + atomic insert; now narrative-arc prompts + auto
speaker notes), the **context pull** (build a deck FROM a picked session/project/board: read,
clean, bound, distill into an editable grounded brief, with durable source provenance),
an **optional pre-outline interview** (a bounded Q&A that synthesizes a
brief to seed the outline), the 13-layout registry + 7 themes, the full IPC surface, the renderer
panel (deck list, create flow, card editor with reorder, inline edit + notes editing,
add / delete / restore card, export menu), **real images + deterministic generative
art**, a **presenter overlay** (notes + next slide + timer), **publish-to-web** (the
`deck-render` canonical viewer via the shared slide engine), and **PDF / HTML export**
(static print render → `htmlToPdf`, desktop-only). Still tracked as follow-ups:
paid **AI image generation** (a non-Anthropic image provider), PowerPoint export, a
WYSIWYG canvas editor, and editable-outline-affects-generation. Decks remains gated
`in-development` behind the Lab flag.

## Where to find it

Decks lives in the left sidebar as its own virtual project, so there is no panel to hunt for — turning it on adds the row, and turning it off removes it. This section covers how it is enabled, how it can also run as an isolated plugin island, and what that plugin form can and cannot do compared with the built-in panel.

### How it is gated

Decks is a **first-party built-in plugin** (`src/plugins/decks/manifest.json`,
`plugin.id: 'decks'`), enabled or disabled in **Settings → Plugins** like any other
plugin. It is **off by default** (`enabledPlugins` starts empty) and is NOT gated by
an unreleased-feature flag.

Enabling it creates the `__plugin_decks__` sidebar row (via the standard
`ensurePluginSidebarRow`) and mounts the native `DecksView`; disabling it hides the
row. The main-process CLI read routes mirror the same state — they 403 unless
`settings.enabledPlugins` includes `'decks'`. The panel renders **natively** (not as
a webview) through the `DECKS_PLUGIN_ID` special-case in the dashboard layouts
(mirroring NightyTidy 2); the manifest's stub `ui/index.html` exists only so the
sidebar row is created and is never loaded. Invariants:
[decks-plugin-contract.md](../../.claude/memory/contracts/decks-plugin-contract.md).

**The webview flip (`decks-webview`) — off by default, but reachable since 2026-07-28.**
A second, independent toggle routes the Decks panel to the standalone sandboxed **webview
island** (the "detach") instead of the native panel, so Decks can be exercised as a real
plugin. It is the `decks-webview` unreleased feature — setting `decksWebviewEnabled`, revealed
via **Settings → Lab** or `AMC_SHOW_DECKS_WEBVIEW=1` — and it defaults **off**. It used to be
`devOnly` (hard-hidden in a packaged build); that hard-hide was dropped once a packaged smoke
proved the real `ui/` bundle ships and a CDP probe proved it mounts and renders, so the toggle
now appears in a normal install. ⚠️ **The webview and the native panel read DIFFERENT data
stores**, so turning it on makes existing decks appear to vanish — they are NOT deleted, and
turning it back off restores them. Desktop-only: the flag has no mobile consumer and an
Electron `<webview>` cannot run on the mobile web client, so the toggle shows on a phone but
changes nothing there. Invariants `the-native-path-remains-the-revert` / `reachable-in-a-package-is-not-enabled` in the same contract.

**What the plugin path can and cannot do (feature parity, 2026-07-29).** The island is NOT yet a
like-for-like replacement for the native panel, and the gap matters if Decks is published to the
marketplace. SIX host capabilities are exposed to it as bridge calls, so it WRAPS the host
implementation instead of re-implementing it: `decks.measureArtifact` (the deterministic overflow
gate that repairs text clipped past the slide frame — **but only up to a point, see below**),
`decks.renderHtml` (the deck→HTML
renderers — what makes **Export PDF / Export HTML / Publish** work at all; before it landed all
three threw, while their mocked tests stayed green), `decks.exportPptx` (**PowerPoint export**,
added 2026-07-29), `decks.assembleFile` (extract + distill a document), `decks.researchTopic`
(the web-search grounding pass) and `decks.assembleBoardContext` (**read + distill a shared
board**, added 2026-07-29). All six answer a discriminated
`{ ok: true, … } | { ok: false, reason }` so a failure can never be misread as a clean result —
see [plugin-bridge-hardening-contract.md](../../.claude/memory/contracts/plugin-bridge-hardening-contract.md)
`measureartifact-reports-whether-it-measured` / `renderhtml-reports-whether-it-rendered` and [decks-plugin-contract.md](../../.claude/memory/contracts/decks-plugin-contract.md)
`the-host-writes-the-powerpoint-file` (I34, in the export/publish contract) / `I35`.

`exportPptx` is the one that SAVES rather than returning data — a `.pptx` is a binary zip and the
generic `export.saveFile` writes utf-8 only, so the host pops the Save dialog and writes the file,
exactly as `export.savePdf` does. It carries a THIRD arm the others do not need: a user who
cancels the dialog gets `{ ok: true, saved: false }`, because a cancel genuinely ran and genuinely
did not fail. On a real save it reports `rendered` / `total`, and the plugin tells you when the
saved deck came back SHORT of real slides — the per-slide render is fail-open by design, so a deck
carrying placeholders is a real outcome, not an exotic edge.

`assembleFile` and `researchTopic` are the CREATE-side pair. Office extraction is main-process code
and a PDF rides to Claude as a native document block, neither of which the text-only AI door can
express; and research is a `tool_choice: auto` call carrying the `web_search` SERVER tool, which the
forced-tool AI door structurally cannot issue. Both are bridged for that reason and no other.

**So the island now starts a deck ALL FOUR ways** — a typed prompt, **from a file**, a guided
**interview**, and **from your work** (a past session, project **or board** you have shared with
it) — each optionally grounded by **web research**, and it reaches **all three** export formats
(PDF, web page, PowerPoint). The interview needed no new bridge method at all: it is a forced
structured-output tool call, exactly the shape the existing AI door already serves.

Boards needed a SEPARATE method rather than a third value on `assembleContext`'s `sourceKind`,
and the reason is worth knowing because it looks like extra work for nothing: the host gates
permissions **per method, not per argument**. A board accepted by `assembleContext` would have
been read under the session-history permission — so a plugin holding only that permission could
have read your boards. One method, one permission, one honest sentence.

**"From your work" behaves differently from the built-in panel, and that difference IS the
privacy model, not a limitation.** The panel can list every session and project you have, because
it *is* the app. The plugin can only list what you have explicitly shared with it — so its picker
starts empty, with a "Choose what Decks can read" button that opens Omniscio's own picker. You
choose there; Decks never browses your work to offer it to you. Two consequences worth knowing:

- **Sharing a project shares its sessions**, but sharing one session does NOT share its project.
  The asymmetry is deliberate.
- **Sharing a board shares only that board.** Boards sit inside a workspace, and sharing one does
  NOT share the others — a workspace is far wider than the thing you actually pointed at.
- **Decks never sees your messages, or your board's items.** It hands Omniscio an id and gets back
  only a distilled summary, so the underlying content never enters the plugin. That is structural
  — there is no path for it, not merely a rule being followed.
- **Boards asked separately, and on purpose.** Decks requests a distinct "read your boards"
  permission rather than reusing the session-history one, because a board is project-management
  data and not a conversation. Agreeing to share past sessions should never quietly hand over
  something else.
- You can withdraw any of it later in **Settings → Plugins**, where every grant is listed together
  — sessions, projects and boards in one place, each labelled for what it is. A grant whose target
  you have since deleted still appears there, so you can always withdraw it.

**ONE capability remains host-only:**

1. **The AI visual-review pass is STRUCTURALLY BLOCKED.** The bridge's AI door is text-only (no
   image parameter), and there is an unanswered spend question on top. It cannot be built as things
   stand, so a published listing must say so: a deck made through the plugin does not get the
   visual-review pass a native-panel deck gets.

**Two things about the plugin's create path differ from the built-in panel, and both are
deliberate.** First, the plugin refuses a **larger file** than the panel does (~24 MB vs 25 MB):
a file crosses the plugin bridge as base64 inside a 32 MiB-capped payload, so the honest ceiling is
lower, and the plugin says so up front rather than failing after the read. Second, **web research
bills a different budget** from the slides: research and file distillation come out of the deck
budget, while the outline and cards come out of the plugin's AI budget. Those two can run out
independently — so if research is skipped because its budget is gone, the plugin tells you at the
outline review, before it spends anything on slides, and tells you again on the finished deck.
"Budget used up", "found nothing useful" and "couldn't run" are three different messages, not one.

**The overflow gate guarantees a MEASURED slide, not a FITTING one (2026-07-28).** Its repair is
bounded on purpose — 6% per pass, two passes — so it rescues a slide that is slightly too full and
**deliberately leaves a badly overflowing one alone**. Treat it as a safety net for near-misses, not
a guarantee every slide fits. This was proven on a real render, and proving it exposed a defect
worth knowing about: because the bridge used to infer its violation count from *"did the card
change?"*, an unfixable slide came back unchanged and reported `violations: 0` — **"measured and
clean", for the worst slides in the deck** — while the gate logged nothing at all in that case. The
host now REPORTS `{ status, violations, repaired }` and nothing downstream infers it; the unfixable
case logs; and the island tells the AUTHOR (a line on the deck-ready panel, attention-toned,
non-blocking, fired only for measured-and-not-fixed). Details in
[decks-plugin-marketplace-publish-preconditions-detach-build-deck-contract.md](../../.claude/memory/contracts/decks-plugin-marketplace-publish-preconditions-detach-build-deck-contract.md)'s
"the NATIVE panel does not tell its author about an unfixable slide" section. ⚠️ The
NATIVE panel still discards those counts, so it does not yet tell its author — the one place the
built-in app is now behind the plugin. The end-to-end proof is `npm run decks:measure-proof`; it is
a script and not a vitest test because the gate needs a real `BrowserWindow`, which the unit lane
cannot construct.

**⚠️ The plugin ships a COMMITTED bundle, and changing island source does NOT update it.**
`src/plugins/decks/ui/` is a build artifact checked into the repo, and **nothing in the root build
rebuilds it** — the island is built only by its own `vite build`. So editing anything under
`src/plugins/decks/web/src` changes what the code says and NOT what the plugin ships. On 2026-07-28
the shipped bundle sat 8 hours and three feature commits behind its source — no overflow gate, no
honest reporting, no export/publish — while every check read green, because the existing guards test
the bundle's NAME, its EXISTENCE, and that the packaged smoke is WIRED, and none tested whether it
matched its source. **The rule: the committed island bundle must be rebuilt whenever island source
changes, and a content stamp now enforces it** — a build-failing guard compares hashes of BOTH the
source and the emitted bundle
([decks-plugin-marketplace-publish-preconditions-detach-build-deck-contract.md](../../.claude/memory/contracts/decks-plugin-marketplace-publish-preconditions-detach-build-deck-contract.md)'s
`committed-bundle-matches-source`). Fix a
failure with `cd src/plugins/decks/web && npm run build`, which rebuilds AND re-stamps in one
command, then commit both.

**⚠️ Do NOT verify a rebuild by grepping the bundle for identifiers.** It is minified, so local
names are mangled away and a bare presence check reports a SUCCESSFUL rebuild as a failure. Only
what a minifier cannot safely rename survives — bridge method names read off the host API object,
store keys, object properties (`measureArtifact`, `renderHtml`, `overflowingCount`,
`preferCSSPageSize`). Host-side-only strings such as the gate's `not-measured` status never cross
into the island at all, whatever the freshness. Use an OLD→NEW differential on surviving markers,
never a presence check on arbitrary symbols.

Users who had the older **Decks Lab toggle** (`decksEnabled`) on are carried over to
the enabled plugin by a one-time startup migration (`decksEnabled → enabledPlugins`),
so nobody loses access on upgrade.

## How it behaves

The two-stage flow is what keeps you in control: nothing is generated until you have approved an outline, and a deck can start from a typed prompt, a file you insert, a guided interview, or work you have already done.

### The two-stage generation flow

Generation is deliberately two-stage so you stay in control and never pay for a
full deck you didn't approve. An **optional pre-outline interview** (see below) can
run first to enrich the prompt:

1. **Outline** — `decks:generate-outline { prompt, numCards, tone?, audience?, brief? }`
   calls Claude for a structured outline (`{ title, cards: [{ title, summary,
intent, suggestedLayout }] }`), creates the deck shell, sets its status to
   `'outline'`, and **stages the outline in memory** keyed by `deckId`. Returns
   `{ deckId, outline }`. Nothing is fully generated yet. A `brief` (from the
   interview) is folded into the generation prompt so the deck is tailored to it.
2. **Cards** — `decks:generate-cards { deckId }` reads the staged outline,
   sets status `'generating'`, generates each card's content + layout in a
   bounded-concurrency pass (emitting `decks:generation-progress` as it goes),
   persists the cards, and flips status to `'ready'`. On failure the deck goes
   to `'error'` and the progress push reports `status: 'error'`.

The staged outline is process-local (a restart mid-flow just means you
re-generate the outline) — never a durable claim.

#### Reliability: idempotent, resilient, atomic

Both paid stages are hardened so they never double-charge or half-build a deck
(see the [decks contract](../../.claude/memory/contracts/decks-contract.md) for the
test-locked invariants):

- **Idempotent generate (no double-spend).** A duplicate delivery of either paid
  call — a double-click, a WebSocket replay, a retry — charges exactly once. The
  outline path dedups on a renderer-minted `clientRequestId`; the cards path dedups
  on `deckId`. Concurrent duplicates share one in-flight run; a failure is never
  cached (the next attempt re-runs).
- **Resilient parsing (one bad block never kills the deck).** Card output is
  salvaged, not strictly rejected: an unknown layout falls back to `text`, a bad or
  over-long block is dropped or truncated to the schema caps, and a fully-empty card
  becomes an explicit placeholder so generation always completes. The outline only
  fails if _not a single_ card is usable.
- **Atomic card insert.** Generated cards are written in one transaction — a
  mid-batch failure rolls the whole batch back, so a deck is never left half-built.

### Build from your work — the context pull

The create screen's source switch (**Prompt | From a file | From a session | From a project | From a board**)
lets a deck start from a file you insert, from work you already did in Omniscio, or from a
Mission Control board mirrored locally, instead of a blank prompt:

1. **Pick a source.** The session picker lists your 50 most recent sessions (silent
   recipe lanes and unresolvable projects filtered out); the project picker uses the
   standard project select.
2. **Read my work.** `decks:assemble-context { sourceKind, sourceId }` runs the
   four-stage assemble in `deck-context-service.ts`: READ the transcript(s)
   (`getFullSessionHistory` / `listSessions` — main process only), CLEAN agent noise
   (`stripAgentNoise`, the continuous-summary filter), BOUND to the
   `decksContextTokenBudget` setting (default 20k tokens; head + tail kept, middle
   dropped with an omission marker; project mode allocates newest-first and reports
   `coverage` — "Included N of M sessions"), then DISTILL with one metered call
   (cost label `decks-context`, same daily cap) into
   `{ suggestedTitle, suggestedAudience, brief }`.
3. **Edit the brief.** The response seeds an editable "What I understood from your
   work" textarea (≤ 8000 chars) — the faithfulness guard: you see and correct what
   the deck will be grounded in before anything is generated. An unusable distill
   falls back to a grounded no-AI brief; an empty/unknown source is a friendly error
   before any paid call.
4. **Generate.** The edited brief flows into the EXISTING
   `decks:generate-outline { …, brief, sourceRef }` seam. The deck row persists its
   origin (`decks.source_ref_json`), and the editor shows a quiet "from _source_"
   pill. A duplicate delivery of the paid assemble dedups on `clientRequestId`
   exactly like the outline path.

**From a file** is the fourth source: you insert a PDF / Word / PowerPoint / Excel / CSV /
text file (`decks:assemble-file { filename, base64, mimeType }`) and `deck-file-extract.ts`
turns it into text — office docs via the shared `extractOfficeText`, text via UTF-8, and a
**PDF handed to Claude as a native document block** (no in-process PDF parse) — which
`assembleFileContext` runs through the SAME BOUND → DISTILL → editable-brief → outline path
above, with the same `decks-context` cost label + cap, `clientRequestId` dedup, and
`sourceRef.kind: 'file'` provenance. It reuses `partitionAttachments` for the type taxonomy
and the renderer's `readFileAsDataUrl` + `useFileDropZone`; an unsupported / oversized /
empty file is a friendly error before any paid call.

**From a board** is the fifth source: you pick a Mission Control board from the local SQLite
mirror (`decks:assemble-context { sourceKind: 'board', sourceId }`) and
`deck-context-service.ts` reads the board's groups, items, columns, and column values
via the PM query layer (the `pm-queries-{group,item,column}.ts` leaf modules). The formatter (`deck-board-formatter.ts`)
renders each group's items as a status-report-style document with column-type-aware
formatting — status/priority resolve their display labels from the column settings JSON,
dates and timelines parse their JSON structure, people use the text field. All formatters
have try/catch fallbacks for corrupt JSON. Items are capped at `BOARD_ITEM_LIMIT` (200)
with honest coverage reporting (`includedItems/totalItems/truncated`). The distill uses a
board-specific system prompt oriented toward status reports (not the transcript prompt).
The tab only appears when BOTH `decksEnabled` AND `missionControlEnabled` are on. The board picker
uses a `PM_LIST_BOARDS` IPC channel (desktop-only, BLOCKED in WS channels since board data
is local SQLite). `sourceRef.kind: 'board'` provenance flows through unchanged.

Invariants (grounded coverage honesty, never-dead-end fallback, cost label + cap,
idempotency, durable provenance, fake-seam fidelity, the E2E lock) are named in the
[decks contract](../../.claude/memory/contracts/decks-contract.md). The CLI route for
"turn this session into a deck" is a tracked fast-follow (approval-gated billable
pattern); the channel is manifest-classified `feature-scoped-surface` like its
siblings until then.

### Optional pre-outline interview

The create screen offers **"Interview me first"** alongside "Generate outline". It
runs a short conversational Q&A before the outline so the deck reflects context only
you have — audience, the single takeaway, must-includes, proof, tone.

- **One turn per question.** `decks:interview-turn { prompt, answers[], tone?, audience? }`
  calls Claude with a forced `emit_interview` tool that returns EITHER the next question
  (`{ done: false, question }`) or a synthesized **brief** (`{ done: true, brief }`). The
  renderer replays it, appending each `{ question, answer }` to `answers`, until `done`.
- **Bounded + always completes.** Capped at `MAX_INTERVIEW_QUESTIONS` (5) — at the cap the
  turn is forced to finish — and any malformed / empty result falls back to a brief woven
  from the answers so far, so it never loops or dead-ends.
- **Seeds the outline.** On `done` (or "Skip to outline"), the store hands the brief
  straight into `decks:generate-outline { …, brief }`; the quick prompt path is unchanged
  (the interview is opt-in). Each turn is a paid call, deduped on a per-turn `clientRequestId`
  and cost-labelled `decks-interview` under the same daily cap; the `AMC_DECKS_AI_FAKE` seam
  covers it too.

### The layout registry — single source of truth

`src/shared/decks/layout-registry.ts` defines the finite, named layout library
(`DECK_LAYOUTS`). The generator emits one of these names per card, and the
renderer maps each name to a React component. The Zod layout enum, the
generator's allowed list, and the renderer's name→component map all derive from
this one array — two guard tests keep them in lockstep: `layout-registry.test.ts`
(the registry shape) and `deck-layout-registry-completeness.test.ts` (every name
has a renderer component and vice-versa; runs in the always-on guard lane).

Layouts (13): `title`, `section`, `text`, `text-image-split`, `bullets`,
`two-column`, `stat` (big-number), `full-bleed-image`, `gallery` (image grid),
`icon-grid`, `timeline`, `comparison`, `quote`. Each carries an `intentAffinity`
(which content intents it suits) that biases the generator's choice. Adding a
layout is one registry entry + a component in **both** viewers (the parity guard
fails until they match) — the shared slide engine (below) means the static PDF
picks it up for free.

**A `gallery` card holds at most `DECK_GALLERY_CELLS` (4) images.** That constant
([deck-visuals.ts](../../src/shared/decks/deck-visuals.ts)) is the single source of
the cap, and all three consumers read it: the card-generation prompt QUOTES it to
the model, the in-app preview sizes its tiles from `deckGalleryArts`, and the
published viewer + PDF derive their cell count from it. It has to reach the model,
because a gallery handed more images than the cap renders the first 4 and silently
drops the rest — so an untold generator writes image prompts you pay for and nobody
can ever see (real deck 912f2b95 emitted 7). The cap governs the COUNT only: the 2×2
grid is a deliberate visual constant in both viewers, so raising it is not a one-line
change. Locked by `deck-gallery-cap.test.ts` in the always-on guard lane
(`gallery-cell-cap` in the decks contract).

### Publish to the web (`DECK_PUBLISH`)

A **Share** button in the deck editor publishes the deck as one self-contained
web page anyone can open on any device. It rides the existing **Share Artifacts**
pipeline, so a published deck also appears in the Shares panel (revoke / expire /
manage like any other share) and re-sharing an _unchanged_ deck returns the **same
URL** (content-hash dedup — no accidental duplicates).

- **One canonical viewer + one shared engine.**
  `src/main/services/decks/deck-render.ts` is the Main-process twin of the in-app
  React viewer: both render the _same_ 13-layout registry and read the _same_
  shared theme palette (`src/shared/decks/deck-themes.ts`, promoted to `shared/` so
  the two can't drift). The layout rendering itself lives in one **pure slide
  engine** string (`SLIDE_ENGINE_JS`, no `DECK`/`document`) that the browser viewer
  embeds AND the static PDF renderer evaluates server-side in a Node `vm` — so the
  interactive deck, the published deck, and the PDF all draw slides from ONE
  implementation. The same `deck-layout-registry-completeness.test.ts` guard locks
  **both** viewers to the registry. Output is fully self-contained (no external
  fonts/scripts/network) so it renders identically inside the sandboxed,
  opaque-origin Shares `/run` iframe and offline alike — 7 themes, scroll/arrow/swipe
  nav, tasteful entrance motion (reduced-motion safe), a fullscreen present mode, and
  a single-screen **presenter overlay** (below) baked in.
- **Security.** Every card value is HTML-escaped at render time; the embedded deck
  JSON is `<`-escaped so a card containing `</script>` can't break out of the inline
  `<script>`; Present is gated on `document.fullscreenEnabled` (hidden inside the
  sandbox where it can't work).
- **Handler.** `DECK_PUBLISH { id }` renders outside the publish `try` (a render bug
  is a real fault, not a masked publish failure), then calls
  `shareArtifactService.publishArtifact({ pastedContent: html, fullBleed: true,
sourceType: 'deck-publish' })`. Refuses a 0-card deck and humanizes
  firebase-quota / upload errors; a fresh publish mirrors to Firestore + emits
  `SHARE_UPDATED`. The renderer store guards a re-entrant click with a `publishing`
  flag (the Shares dedup makes a duplicate idempotent regardless).

### Real images + generative art

Image blocks now produce **pixels**, not an empty placeholder:

- **Real photos.** When a block carries a valid `http(s)` `url`, both viewers emit a
  real `<img>` (object-cover, lazy, `alt`-labelled). The URL is escaped via the
  viewer's `esc()` so a stray quote can't break out of the attribute, and an
  `onerror` removes a broken image so the art beneath shows through.
- **Generative art (the always-present base).** `src/shared/decks/deck-visuals.ts`
  is a **deterministic, seeded** mesh-gradient generator — `deckArtBackground(seed)`
  returns a plain CSS `background` value; the seed comes from the card's content
  (`deckArtSeed`). It is **theme-independent on purpose** (real media doesn't
  recolour when you switch deck theme) and pure (no `Date`/`Math.random`), so the
  published render stays byte-deterministic. Used behind image / cover / section
  slides, always under a scrim that keeps overlaid text legible — a deck looks
  designed even with zero photos. The `gallery` layout seeds one art per tile via
  `deckGalleryArts(seed)` (shared by both viewers + the PDF so they can't drift).

**Image _generation_ (a paid image model) is still deferred** — this wave renders
real URLs + procedural art; wiring an image provider is a follow-up.

## Related

This page is split across two parts: [part 2](decks-part-2.md) covers what each card is told when it is generated, the theme colour, presenter mode and speaker notes, PDF / HTML / PowerPoint export, and the data model, IPC surface and key files behind it all. The virtual-project row itself behaves like any other sidebar entry, which is covered on [the projects sidebar](projects-sidebar.md) page, and a published deck is managed with everything else you have shared — see [share artifacts](share-artifacts.md).
