---
title: Decks (AI presentation builder) (part 2)
---

# Decks (AI presentation builder) (part 2)

## What it is

This is part 2 of the [Decks (AI presentation builder)](decks.md) page. It covers what sits behind a finished deck — how each card is briefed, coloured and laid out, how a deck reaches the web or leaves as a file, and the data model, IPC surface and key files an agent needs in order to work on it.

## Where to find it

Decks is the virtual project in the left sidebar, and the [first half of this page](decks.md) walks through enabling it and running a generation. Everything here is reached from the deck editor once a deck exists: the theme picker sits in the editor header, the Share button publishes the open deck, and the Download menu saves it as a file.

## How it behaves

### Cost + credentials

Generation calls the Anthropic Messages API, which rejects OAuth tokens, so the
service resolves an **API-key account** via `resolveApiKeyForAiFeatures()`. No
key → a humanized error, never a crash. Every call is logged with
`trackApiCost(accountId, …)` and checked against a per-day cap
(`decksDailyCostCapUsd`, default **$5/day**), summed across the whole CLOSED set of
decks billing labels — the cap gates on the SUM, so a paid decks call whose label is
NOT a member would be neither counted toward the cap nor blocked by it. Every gate
reads that pooled figure through the one `pooledDecksSpendToday()` helper.

The `decksPremiumModel` setting does **two** things, and the second one costs money.
It picks a stronger model for generation (default off = the cheaper model), **and** it
switches on the visual self-review loop for `artifact` slides — render the slide, let
vision judge its design, repair it, re-judge, for a bounded number of passes. So premium
spends noticeably more per artifact slide than the model upgrade alone implies. That
extra spend bills under `decks-review` inside the same daily cap, and the loop is
fail-open: at the cap it skips rather than throwing, so a deck still finishes. The
outline gets `max_tokens: 4096` and each card `3072`.

### What a card is actually told

A rule in the prompt is worth nothing unless the request carries what it refers to.
For a long time the doctrine told the model *"you are shown the previous and next
slide, so do not reuse the previous slide's skeleton"* while the request sent only the
card's own title, summary, intent, layout and tone. Every card was therefore written in
isolation from identical inputs, and came back the same shape — which is why generated
decks looked machine-made no matter how the wording was tuned. It was an information
failure, not a taste failure.

Each card request now carries the deck **design brief**, the **previous and next slide**
(title + archetype — cards generate in parallel, so a neighbour's finished composition
may not exist yet, but its planned shape always does), the deck **accent**, the card's
assigned **archetype**, and — for artifact slides only — the **worked exemplars**. That
last one roughly doubles the per-card prompt (~3.1k → ~5.2k tokens, about **+$0.31 of
input on a ten-slide premium deck**), which is the price of the model seeing what "good"
looks like instead of only being told.

### Colour: chosen for the topic, overridable by the theme

The outline picks ONE accent for the deck's subject and returns it as a six-digit hex —
amber for heat, red for a diagnosis, deep green for finance. Previously the doctrine
forbade this outright ("never a hardcoded colour") and the accent was welded to the
theme, so every deck came out the same indigo whatever the subject.

The slide applies it as `--accent: var(--deck-accent, #hex)`, and that indirection is
deliberate: on the default theme nothing defines `--deck-accent`, so the topic colour
shows; the moment a reader picks a theme, `--deck-accent` is injected and the **theme
wins**. A plain inline colour could never be reclaimed, because CSS gives an element's
own declaration precedence over anything inherited. A malformed hex is dropped and the
theme accent stands.

### Presenter mode + speaker notes

Each card can carry `speaker_notes`. The generator now **always writes them** (2-4
talking-point lines that expand on the slide, never a restatement), they persist
through `CARD_UPDATE`, and they surface in two places:

- **In the app** — a notes strip under each card in the editor, editable inline.
- **In the published viewer** — a single-screen **presenter overlay** (press `S` or
  the dock **Notes** button): current slide + next-slide preview + this slide's notes
  - an elapsed timer. It's an in-page overlay (not a second `window.open`, which is
    unreliable in the sandboxed Shares iframe) driven by the same slide engine.

### Export (PDF + HTML + PowerPoint)

A **Download** menu in the editor saves the deck as a file (native Save dialog, so
**desktop-only** — all three channels sit in `web-access-ws.ts` `BLOCKED_CHANNELS`):

- **PDF** — a dedicated **static** render (`renderDeckPrintHtml`, no JavaScript —
  the shared `htmlToPdf` runs with JS disabled) lays every slide out as its own 16:9
  page, then `htmlToPdf(html, { preferCSSPageSize: true })` prints it. It reuses the
  shared slide engine, so the PDF matches the on-screen deck.
- **HTML** — saves the self-contained published viewer document as-is.
- **PowerPoint (`.pptx`)** — see below.

All three refuse a 0-card deck and humanize a write failure; a cancelled dialog returns
`{ filePath: null }` and never runs the render.

#### Export to PowerPoint (`DECK_EXPORT_PPTX`)

A real downloadable presentation that opens in PowerPoint / Google Slides / Keynote. It is
**image-fidelity**: every slide is a full-bleed picture of the deck's OWN static print render,
so the `.pptx` is pixel-identical to the on-screen deck and the PDF — charts, diagrams and
`artifact` slides included.

> **The trade-off:** slide text is a **picture**, not editable text. You can present and share
> the file, but you cannot edit its wording in PowerPoint. That is inherent to the approach, not
> a gap to be patched — editable text would be a different export.

- **Render** — [deck-pptx-render.ts](../../src/main/services/decks/deck-pptx-render.ts) captures
  each card via `renderDeckPrintHtml(deck, [card])` in a hidden, **JavaScript-disabled,
  sandboxed** `capturePage` window, so an AI-authored `artifact` slide renders inert. Captures
  are single-flight with a 20 s per-slide timeout.
  Capture size is **1280×720** — deliberately not larger: a hidden window bigger than the display
  gets clamped by the OS, producing a short, non-16:9 image that then stretches.
  The capture also force-freezes the deck's entrance animations, because `capturePage` shoots one
  frame and would otherwise catch text mid-fade.
- **Package** — [pptx-builder.ts](../../src/main/services/decks/pptx-builder.ts) writes a 16:9
  OOXML `.pptx` using Node's built-in `zlib` (adds no dependency), assembled in memory and
  written in ONE atomic save.
- **Never a hole** — a slide that fails to render becomes a **titled placeholder**, so the
  `.pptx` always has exactly as many slides as the deck
  ([deck-pptx-export.ts](../../src/main/services/decks/deck-pptx-export.ts)).
- **Honest about degraded output** — because the render is fail-open, a fully failed export would
  otherwise save a valid file full of blank placeholders and report success. The export returns a
  `{ rendered, total }` tally, and the editor warns when any slide fell back — naming it plainly
  when none rendered.
- **Costs nothing** — pure local rendering, no model call.

Locked by `deck-pptx-image-fidelity` in
[decks-contract.md](../../.claude/memory/contracts/decks-contract.md).

## For agents

### Testing seam (`AMC_DECKS_AI_FAKE`)

Launch with `AMC_DECKS_AI_FAKE=1` and generation returns deterministic, schema-valid
content with **no API call and no cost** (mirrors `AMC_WRITER_AI_FAKE`). This drives
the end-to-end E2E (`tests/e2e/ui/decks-flow.spec.ts`) through the real create →
generate → edit → delete flow without paid calls. A separate opt-in, paid smoke
(`tests/unit/decks/deck-generation-real-smoke.test.ts`, gated on
`AMC_DECKS_REAL_SMOKE=1` + `ANTHROPIC_API_KEY`) is the live-model proof; it is skipped
by default so the gate never spends money.

### Data model — `decks` + `deck_cards`

Created by migration `src/main/db/migrations/20260618225846-decks-tables.ts`.

`decks`: `id` (PK), `title`, `theme_id`, `format`, `status`
(`draft|outline|generating|ready|error`), `source_prompt`, `source_ref_json`
(nullable provenance `{ kind, id, title, assembledAt }` for a context-pulled deck;
added by `20260709200336-add-source-ref-json-to-decks.ts`), `is_deleted`,
`created_at`, `updated_at`.

`deck_cards`: `id` (PK), `deck_id`, `position`, `layout`, `content_json` (the
validated block union), `style_json`, `speaker_notes`, `is_deleted`,
`created_at`, `updated_at`.

Both tables are **global per install** (no `account_id`) and soft-deleted —
every read filters `AND is_deleted = 0`. Card content is the small, stable block
union in `deck-types.ts` (`heading | paragraph | bullets | image`).

Card soft-delete is **tri-state** so undo stays exact: `0` = active, `1` =
user-deleted, `2` = cascade-deleted (hidden because its _deck_ was deleted).
Restoring a deck un-hides only the cards its own delete cascaded (`2 → 0`); a card
you deleted on purpose (`1`) stays deleted and is never resurrected.

### IPC channels (`decks:*`)

Defined in `src/shared/ipc-channels/decks.ts`; handlers in
`src/main/ipc/decks-handlers.ts`; Zod schemas in `src/shared/ipc-schemas/decks.ts`;
response types in `src/shared/ipc-response-map/decks.ts`; push payloads in
`src/shared/push-event-schemas/decks.ts`.

| Constant                    | Channel string              | Purpose                                                            |
| --------------------------- | --------------------------- | ------------------------------------------------------------------ |
| `DECK_LIST`                 | `decks:list`                | list all non-deleted decks                                         |
| `DECK_GET`                  | `decks:get`                 | fetch one deck + its cards                                         |
| `DECK_CREATE`               | `decks:create`              | create a blank deck                                                |
| `DECK_UPDATE`               | `decks:update`              | patch title / theme / status                                       |
| `DECK_DELETE`               | `decks:delete`              | soft-delete a deck                                                 |
| `DECK_GENERATE_OUTLINE`     | `decks:generate-outline`    | create deck shell + stage an AI outline                            |
| `DECK_GENERATE_CARDS`       | `decks:generate-cards`      | generate + persist cards from the staged outline                   |
| `DECK_INTERVIEW_TURN`       | `decks:interview-turn`      | one turn of the optional pre-outline interview                     |
| `DECK_ASSEMBLE_CONTEXT`     | `decks:assemble-context`    | read a picked session/project into an editable grounded brief      |
| `DECK_ASSEMBLE_FILE`        | `decks:assemble-file`       | read an inserted file into an editable grounded brief              |
| `DECK_PUBLISH`              | `decks:publish`             | render the deck + publish it to a public Shares URL                |
| `DECK_EXPORT_PDF`           | `decks:export-pdf`          | download the deck as a PDF (native Save dialog, **desktop-only**)  |
| `DECK_EXPORT_HTML`          | `decks:export-html`         | download the deck as a self-contained HTML file (**desktop-only**) |
| `DECK_EXPORT_PPTX`          | `decks:export-pptx`         | download the deck as a PowerPoint `.pptx` — one slide-image per card (**desktop-only**) |
| `CARD_CREATE`               | `decks:card-create`         | add a blank card (optionally after a given card)                   |
| `CARD_UPDATE`               | `decks:card-update`         | patch a card's layout / content / style / notes                    |
| `CARD_DELETE`               | `decks:card-delete`         | soft-delete one card (undo via `CARD_RESTORE`)                     |
| `CARD_RESTORE`              | `decks:card-restore`        | restore a user-deleted card                                        |
| `CARD_REORDER`              | `decks:card-reorder`        | reorder a deck's cards                                             |
| `DECKS_CHANGED`             | `decks:changed`             | push — list/cards changed, refetch                                 |
| `DECKS_GENERATION_PROGRESS` | `decks:generation-progress` | push — `{ deckId, done, total, status }`                           |

Every request channel is registered in `tests/integration/ipc-contract.test.ts`.

### Renderer

The panel is the built-in Decks plugin's virtual project (`__plugin_decks__`,
`panelOwnsLayout`). It mounts `DecksView` (deck list + editor split, mobile
aware). State lives in the co-located Zustand store
(`src/renderer/src/features/decks/decks-store.ts`): `load`, `select`,
`generateOutline`, `generateCards`, the optional interview
(`startInterview` / `submitInterviewAnswer` / `skipInterviewToOutline` /
`cancelInterview`), `addCard`, `updateCard`, `deleteCard`,
`restoreCard`, `reorderCards`, `remove`, with optimistic updates + rollback and a
`usePushListener` on `DECKS_CHANGED` / `DECKS_GENERATION_PROGRESS`. Cards render
through the layout name→component map in `card-layouts.tsx`.

**Theme selection is visual**: the wizard and the editor header use `DeckThemePicker`
(`features/decks/DeckThemePicker.tsx`) — a keyboard-accessible radiogroup of mini-slide
thumbnails rendered from each theme's actual tokens; it derives from `DECK_THEMES`, so a
new theme appears automatically. In the editor it opens as a compact popover and re-themes
the open deck instantly via the existing `DECK_UPDATE { themeId }` path.

Card management follows the deletion convention: a **per-card delete** is
recoverable and undo-toast-only (you delete cards often), while a **whole-deck
delete** confirms first (it drops all of that deck's cards) — both offer Undo. The
card editor's `CardBlock` is `React.memo`'d so editing one card doesn't re-render
the rest of a large deck.

### Key files

| What                             | Where                                                                               |
| -------------------------------- | ----------------------------------------------------------------------------------- |
| Migration                        | `src/main/db/migrations/20260618225846-decks-tables.ts`                             |
| Query layer                      | `src/main/db/queries-decks.ts`                                                      |
| Generation service               | `src/main/services/decks/deck-generation-service.ts`                                |
| Context-pull assemble            | `src/main/services/decks/deck-context-service.ts`                                   |
| Board data formatter             | `src/main/services/decks/deck-board-formatter.ts`                                   |
| File extractor (office/text/PDF) | `src/main/services/decks/deck-file-extract.ts`                                      |
| File-source accept + size cap    | `src/shared/decks/deck-file-types.ts`                                               |
| Published viewer + print (Main)  | `src/main/services/decks/deck-render.ts` (`renderDeckHtml` / `renderDeckPrintHtml`) |
| PowerPoint export orchestrator   | `src/main/services/decks/deck-pptx-export.ts`                                       |
| PowerPoint slide capture         | `src/main/services/decks/deck-pptx-render.ts`                                       |
| PowerPoint OOXML/zip builder     | `src/main/services/decks/pptx-builder.ts`                                           |
| Generative art engine            | `src/shared/decks/deck-visuals.ts`                                                  |
| Shared theme palette             | `src/shared/decks/deck-themes.ts`                                                   |
| IPC handlers                     | `src/main/ipc/decks-handlers.ts`                                                    |
| IPC channels                     | `src/shared/ipc-channels/decks.ts`                                                  |
| Shared types                     | `src/shared/decks/deck-types.ts`                                                    |
| Layout registry                  | `src/shared/decks/layout-registry.ts`                                               |
| Gating registry                  | `src/shared/unreleased-features.ts` (`decks`)                                       |
| Renderer (store + UI)            | `src/renderer/src/features/decks/`                                                  |
| Contract (invariants)            | `.claude/memory/contracts/decks-contract.md`                                        |

## Related

The [first half of this page](decks.md) covers what Decks is, how it is turned on, the two-stage generation flow and the four ways to start a deck. The plugin form described there is one of the built-in plugins, which are managed on the [plugin settings](plugin-settings-panel.md) page, and a deck built from a board pulls its material from the boards covered in [mission control](mission-control.md).
