<!-- plank help: presentations | updated 2026-08-27 | summary: A slide deck comes out in one of two real formats — an editable .pptx, or an art-directed HTML → PDF that cannot be edited — and the format is asked, never assumed: read the format question before you start building. Both share one house style with two themes (art-directed dark by default, light typographic on request), validated palettes, thirteen slide primitives including full-bleed imagery, persistent brand chrome, plus a tool that renders the finished deck, reports what is broken (including text that is illegible over its own photograph), exports the PDF, and runs a critique pass on whether the deck is any good. -->

# How Plank builds a slide deck

> **Just need to build one?** The whole route — download the builders into `/workspace/scripts/deck/`, write the build script beside them, run the QA before handing over — is one short page: [Build a deck: the route](/help/deck). This page is the style behind it, and it is long; read it for the primitives, the palettes and the format argument.

When Plank builds you a slide deck it comes out as a real file, in one of two formats, and **which one is asked, not assumed**:

- **`.pptx`** — editable text, shapes and native charts you can open in PowerPoint, Keynote or Google Slides and change by hand. Never a picture of slides, never a PDF pretending to be one.
- **HTML → PDF** — one self-contained HTML document printed to PDF at exactly one page per slide. Full-bleed photography, layered gradient scrims, real letter-spacing: the art-directed look a shape-based format cannot draw. **You cannot edit it.**

Both are built from the same primitives, the same two themes and the same text budgets, so the choice is about what the deck has to do — not about learning a second tool. See [Which format](#which-format-pptx-or-html--pdf).

This page is written for both you and the assistant. The assistant fetches it at `https://plank.md/help/presentations.md` before building a deck, so every deck comes out in the same house style.

## First: ask, then build

**This is the first step, not an optional one.** Before writing a single slide, the assistant asks a short batch of questions and waits for the answers. A deck is a piece of persuasion aimed at particular people in a particular room, and the four or five facts that decide its shape are almost never in the original request. Guessing them produces the failure this section exists to prevent: a competent, well-formed twelve-slide deck aimed at nobody, which the reader has to be talked through because it was never built for them.

### Read this before you choose the questions: the format is not yours to assume

**Decide nothing about the output format before you ask.** There are two, and picking the wrong one is expensive in a way no amount of good slides repairs: an HTML → PDF deck **cannot be edited**, and a `.pptx` **cannot be art-directed**. The three lines that decide it:

- It has to **land** — a pitch, a brand deck, a launch, a conference talk → `plank_slides.py` → **HTML → PDF**.
- It has to be **edited** — re-cut, re-branded, reused as a template, opened in PowerPoint → `plank_deck.py` → **`.pptx`**.
- A board pack, a financial review, a monthly report → **ask**; usually `.pptx`, because these get re-cut.

A request like "make a fancy pitch deck" does **not** answer this: it names a genre, and a genre only *points*. Reading "fancy" as permission to start building is how the user who wanted a deck that lands gets a shape-based one. So the format is a **question you ask** — it is in the table below — unless the request has already answered it in words ("something I can edit", "just a PDF", "a deck the board can re-cut"), in which case say which you picked and why. Full routing and what each format gives up: [Which format](#which-format-pptx-or-html--pdf).

HTML → PDF also depends on something the request can't answer: a renderer in this workspace. Before you commit to that route, check `command -v chrome-headless-shell >/dev/null 2>&1 || [ -x /opt/chrome-headless-shell/chrome-headless-shell ] || [ -n "$PLANK_CHROME" ]`. Empty means this workspace cannot print HTML — say so plainly and build `.pptx` instead: you lose the full-bleed look, but the deck still ships. See [Which format](#which-format-pptx-or-html--pdf).

### How the assistant asks

It uses the **`question` tool**, which is built in and always available — the same mechanism behind the multiple-choice card you see in a chat. It takes a list of questions, each with a short header, the question itself, and a set of options:

```
question(questions=[
  {
    "header": "Format",
    "question": "Does the deck need to be editable, or does it need to look art-directed?",
    "options": [
      {"label": "Editable", "description": ".pptx — opens in PowerPoint, changed by hand"},
      {"label": "Art-directed", "description": "HTML → PDF — full-bleed imagery, cannot be edited"},
      {"label": "You choose", "description": "Pick from what the deck is for"}
    ]
  },
  {
    "header": "Audience",
    "question": "Who is in the room, and what do they already know?",
    "options": [
      {"label": "The board", "description": "Knows the business, not this project"},
      {"label": "The finance team", "description": "Knows the numbers in detail"},
      {"label": "A prospective customer", "description": "Knows nothing about us"}
    ]
  },
  ...
])
```

You answer by tapping an option — or by typing your own, since a "Type your own answer" row is added automatically. Set `"multiple": true` on a question that can take more than one answer. **Ask them all in one call.** One `question` call carrying four questions is one card you answer once; four calls across four turns is an interrogation, and it is the single most common way this goes wrong.

### What to ask

**Format is always asked, and it is outside the count** — it is the one answer that picks which builder runs, so skip it only when the request has already answered it in words. Then pick **three to five** of the rest, the ones the request has not already answered. Never the whole table.

| Ask | Why it changes the deck |
|---|---|
| **Format** *(always, unless already answered)* | Editable, or art-directed? `.pptx` opens in PowerPoint and can be re-cut by hand; HTML → PDF gets full-bleed photography and a real type engine and **cannot be edited**. This is the answer that picks the builder, which is why it does not compete with the three-to-five. See [Which format](#which-format-pptx-or-html--pdf). |
| **Audience** | Who is in the room, and what do they already know? Decides how much you explain and what you can assume. |
| **The decision** | What should they do or decide when it ends? A deck without one is a report, and a report does not need slides. |
| **Genre** | A business report or a pitch? Picks the theme (`report` over the default `studio`), and it is the strongest *hint* at the format — but a hint is not an answer, so it does not replace the format question. |
| **Length** | Roughly how many slides, or how long is the slot? Ten minutes is not twelve slides. |
| **Brand or template** | Is there a company template, brand colours or a logo to match? See [Your own brand](#your-own-brand). |
| **Imagery** | Real photos to use, a generated image, or none at all — typographic is often the better call for a financial or reporting deck. Real photos are files already in the workspace; either way this decides whether `hero` and `image_content` are in play. Offer generation only when it is actually available for this workspace — check with `[ -n "$PLANK_IMAGE_URL" ]` — and if it isn't, say so plainly and offer the other two options instead of promising something that will fail; don't nag the user to install a skill they didn't ask about. Generated images cost a few cents each against the workspace's AI allowance, unlike the rest of the deck. |

### The rules that keep this short

- **Once, up front, in one batch.** Not one question per turn.
- **Never ask what you have already been told.** "A pitch for the board on why we should buy X, ten minutes" answers audience, decision, genre and length. Ask about format, brand and imagery. For format specifically, only words about *editing or finish* answer it — "something I can edit", "just a PDF", "a deck the board can re-cut". "A fancy pitch deck" does not.
- **Cap the rest at five.** Three is usually better. Format sits outside that count, not inside it.
- **Do not stall.** If the user says "just make it", answers only some, or does not answer at all, **build the deck** using stated assumptions. Say what you assumed in one line, **and name the format among the assumptions** — "Built for a board audience, asking for pilot sign-off, twelve slides, `studio`, HTML → PDF because it has to land — say the word and I'll rebuild it as an editable `.pptx`" — so a wrong assumption is cheap to correct. A deck that exists is correctable; a question sitting unanswered is not a deliverable.

### Then: the storyline, before anything is built

With the answers in hand, **propose the deck as a list of titles and stop there.** Nothing is rendered yet. The storyline is the argument; the slides are only its packaging, and this is the last cheap moment to be wrong about it — a full build is minutes of tool calls, and one with generated imagery spends real money against the workspace's AI allowance. Rewriting a line in a list costs nothing.

It is six to fifteen lines in your reply, each one the title a slide will carry, in order, each stating what that slide claims. **The first line is the answer** — see [Writing the slides](#writing-the-slides).

```
1. Q3 support cost fell 40% — triage is why
2. Volume rose 18% while cost per ticket fell 49%
3. Two of the three cost drivers are now automated
4. The third needs two analysts
5. Approve two hires by 15 September
```

Two rules keep this from becoming a second interrogation. It is **a list, not another question round** — no `question` call, no options; "here's the storyline, tell me what's wrong or say go". And **the no-stall rule applies unchanged**: no answer, no corrections, or "just make it" means build the storyline you proposed and say that is what you did.

## Designing a deck from scratch

This is the method for a deck whose design is **yours** — a pitch, a launch, an
investor update, a board pack. (A deck the customer already has is the next
section; there the design is the asset and none of this applies.)

Do the three parts in order and write each one down before you draw anything.
Skipping straight to slides is how a deck comes back well-formed and forgettable:
every rule below exists because a deck that skipped it looked machine-made.

### Part one: think

1. **Frame.** Who is in the room, what they already believe, the single decision
   this deck must get, and what they *lose* by saying yes. Read alone or presented
   live? Density follows from that answer.
2. **Skeleton.** Take the canonical arc for this genre — for a seed round:
   purpose, problem, why now, product, how it works, market, model, traction,
   competition, team, ask. Adapt it and say what you dropped and why. Do not
   invent an arc.
3. **Governing thought.** One sentence the whole deck supports. Anything that
   does not support it is cut, however good it is.
4. **Claim chain.** Per slide: the claim — a finding, not a subject — and the
   evidence that settles it, marked `have` / `missing` / `assumed`. Check it is
   MECE: no two slides making the same point, and the set sufficient on its own.
5. **Close the gaps.** Every `missing` line is resolved one of three ways: research
   it with `websearch` / `webfetch` and keep the source, ask the user, or carry it
   as an assumption that is **printed on the slide**. An unsourced number never
   renders silently, and your own method — `N×P×12`, "cite the count" — is never
   content.

   **If the brief already supplies the evidence for every claim, skip this step
   entirely and say in one line that you did.** Research only what the brief leaves
   open. This is not a shortcut, it is correctness: a founder's own price list is
   not a claim a search engine can settle. Measured on the same subject, a brief
   that carried its own numbers took the whole deck from 825 s to 379 s with no
   loss of quality.
6. **Pacing.** Where does tension build and where does it release? Those beats get
   a visual break in part two.

### Part two: design the system once

Write the design system down **once**, as CSS custom properties, before the first
slide. Then obey it. This is what makes twelve slides look like one deck without a
template doing it for you.

7. **Identity.** One line: what this deck should feel like, derived from *this*
   subject. If a choice would work equally well on any other deck, it is the wrong
   choice.
8. **Palette.** Five or six named values: ground, ink, one accent, two neutrals,
   and semantic good/bad if numbers carry direction.
   - Roughly **60 / 30 / 10** — one ground dominates, one secondary carries
     structure, the accent is a tenth or less. The accent means *look here*; the
     moment it is also on every heading and border it means nothing.
   - State the measured contrast of body text on ground. **4.5:1 minimum**, checked
     for every pair you actually use, including text over imagery.
   - **Never pure `#000`.** White on pure black halates — it is why Material and the
     Apple HIG both use a dark grey. A near-black with a hue bias reads chosen; a
     pure grey reads unconsidered.
   - A saturated accent calibrated on a light ground burns on a dark one.
     **Re-pick it per ground** rather than reusing the hex.
   - Never encode meaning by hue alone. Carry it in the label, the sign or the
     position too, so it survives colourblindness and a bad projector.
9. **Type.** Two or three faces with explicit roles: display, body, and one for
   figures and labels.
   - `fc-list : family` reports about 228 families, but almost all of them are
     exotic-script Noto. The usable Latin/Cyrillic set is **Inter, Noto Sans /
     Noto Sans Display, Noto Serif / Noto Serif Display, Fira Code** and the metric
     clones. Inter alone will not give a deck a voice.
   - So fetch a display face and **inline it**. Never link a CDN — the file has to
     stand alone. Ask Google Fonts with a browser User-Agent and you get woff2
     (~12 KB) instead of TTF (~64 KB):

     ```bash
     UA='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
     curl -sS -A "$UA" 'https://fonts.googleapis.com/css2?family=<Family>:wght@400;700&display=swap'
     # then curl each fonts.gstatic.com .woff2 out of that CSS and base64 it into a @font-face src
     ```

     Check the face covers Cyrillic and Kazakh (ә і ң ғ ү ұ қ ө һ) before committing
     to it. If it does not, keep it for display and set body in one that does.
   - Pair by contrast of structure, not similarity. A serif or a distinctive display
     against a neutral sans works; two neutral sans faces just look like a mistake.
   - One scale ratio (1.25 or 1.333), stated as variables, obeyed everywhere. A
     1920×1080 slide over a 13.33×7.5 in surface is **144 DPI, so 1 pt = 2 px** —
     which is what makes "never below 18 pt" usable here:

     | Role | Size on a 1080-tall slide |
     |---|---|
     | supporting text | **36 px floor** (18 pt — below this it belongs in a handout) |
     | body | 40–48 px |
     | slide title | 72–88 px |
     | section or statement | 110–160 px |
     | one hero number | 200 px+ |

   - Tracking: tight on large display (−0.02 to −0.04 em), normal at body size,
     loose on small caps and eyebrows (+0.08 em). All-caps below 24 px without extra
     tracking is unreadable.
   - Running text stays around 45–75 characters a line. Weight carries hierarchy
     more cheaply than size; three weights is enough.
10. **Grid and space.** Twelve columns, one margin, one spacing scale, all stated as
    variables. Whitespace is a material, not what is left over — empty space is what
    makes the one important thing look important. On full-bleed slides put the
    subject and the headline on the third-lines rather than dead centre. **Every
    slide has exactly one focal point;** if two things compete, one is not on this
    slide.
11. **Layout vocabulary.** Choose six to eight slide forms up front and assign one
    to each claim by what the claim needs — a metric trio, a comparison matrix, a
    stepped process, a full-bleed statement, a table, a two-column contrast. Never
    the same form twice in a row. Four rules hold the set together: **contrast** (the
    important thing looks different, not merely bigger), **repetition** (the same
    element means the same thing on every slide, in a fixed position), **alignment**
    (every edge lines up with the grid or with another edge), **proximity** (the gap
    between groups is bigger than the gap within one).
12. **Numbers and charts.** Inline SVG; no libraries.
    - The message picks the form, not the data. Write the sentence you want the
      reader to conclude, name the comparison, then draw it:

      | The claim is about… | Draw |
      |---|---|
      | part of a whole | one pie only at 2–3 slices, otherwise a stacked bar |
      | ranking of items | horizontal bar, sorted by value, never alphabetically |
      | change over time | line for many points, column for few |
      | spread of a range | histogram or dot plot |
      | relation of two | scatter |

    - **Label directly.** Put the series name at the end of its own line and delete
      the legend; put the value at the end of its own bar and delete the axis. A
      legend makes the eye travel between the key and the mark, and the cost of that
      trip is the most-replicated finding in slide research.
    - Erase anything that is not data: no 3-D, no drop shadows, no heavy gridlines,
      no background fill, no redundant axis. If removing a mark loses no information,
      remove it.
    - **Emphasise one mark** — the bar the claim is about — in the accent, and leave
      every other mark neutral. That is what makes a chart argue instead of report.
    - Many series: small multiples on one shared scale beat one crowded chart. Bars
      start at zero; truncating an axis to exaggerate a change is a lie.
13. **Imagery.** This one cuts both ways and the deck is lost at either extreme.
    - **A pitch, a launch, a brand deck needs pictures.** The image carries the thing
      the words cannot, it is what a room remembers, and a deck of nothing but type
      and diagrams reads as a report someone put on a projector. Plan the imagery in
      this step, alongside the palette — not as decoration added at the end.
    - **On those three genres the cover and the section breaks carry an image by
      default**, and going without one is the choice you have to justify — the other
      way round from every other slide. This is written down because the honest
      reading of the rule below is otherwise "skip it": asked for a pitch with
      generation switched on, a run reasoned *"original transaction-flow graphics
      rather than decorative stock imagery"* and shipped fifteen slides of pure type.
      That is the right instinct about DECORATION and the wrong conclusion about a
      COVER. A generated image is not stock: you write the prompt, so it can be the
      specific thing this deck is about — this company's warehouse, this city at the
      hour the work happens — which is exactly what a stock library cannot sell you.
    - **A picture that carries no argument costs comprehension**, measurably: a
      decorative photo, a background texture, an icon beside every bullet all compete
      for the same attention. The test is one question per image — *what does this
      let the reader understand that the words alone would not?* No answer, no image.
    - When an image is the argument, let it be full-bleed and large, with a scrim
      heavy enough that text over it still measures 4.5:1 **against the rendered
      pixels**, not against your intention.
    - A board pack, a financial review or anything printed is the exception: there,
      type and charts alone are correct.
    - **Check that you can actually get a picture before the plan depends on one.**
      Real photos are files already in the workspace; generation is opt-in per
      workspace, so test it with `[ -n "$PLANK_IMAGE_URL" ]` at this step, not after
      you have designed around it. If neither is available, say so in the reply and
      design a typographic deck on purpose - an original inline SVG built from the
      subject is a real answer, a stock-looking placeholder never is.
    - **When it IS available, the `image-generation` skill is how you use it** -
      read that skill rather than improvising the HTTP call. For a deck the useful
      settings are `"size": "1536x1024"` (landscape, the right shape for a slide) and
      `"quality": "low"` for anything sitting behind text, which is dramatically
      cheaper and indistinguishable under a scrim. Generate **once**, at plan time, for
      the two or three slides that carry an argument a picture can make - a cover, a
      section break, the one concept with no chart. A retry loop is the most expensive
      mistake available here, and every image is billed to the customer.
    - **Then inline it.** The skill writes a file into `assets/`, and a self-contained
      deck cannot reference a file: ship the image as a `data:` URI in the HTML.
      A deck that points at `assets/cover.png` looks perfect on the machine that
      built it, prints correctly to PDF, and arrives as a broken frame the moment the
      `.html` is shared on its own. `plank_deck_qa.py` flags that, because it is
      invisible until somebody else opens the file.
14. **Texture and gradients.** `linear-gradient` and `radial-gradient` are exactly
    what a gradient in a PDF *is*, so they translate straight through and cost
    nothing. Use them freely.
    - **Never `repeating-linear-gradient`, `repeating-radial-gradient` or
      `conic-gradient` in a deck that will be printed.** Chromium cannot express
      those three, so it falls back to a shading whose colour a reader has to
      *compute once per pixel* — and Preview on macOS and iOS rasterises the whole
      element box before it clips, so the price is set by the size of the box the
      gradient sits on, not by how much of it is visible.
    - Measured on a real twelve-slide deck: **43 seconds to open**, 26 of them on the
      title slide, and **27 of the 43 spent on one hairline grid that drew zero
      pixels** — it was clipped away to nothing and still cost more than half the
      deck. The page stays blank until it finishes, so it reads as a corrupt file
      rather than a slow one, which is how it got reported.
    - For a grid, a scanline, or any repeating texture, use a repeating SVG
      `background-image` — a `data:` URI holding one tile. Same look, still vector,
      and measured at **0.02s against 0.49s** for the gradient version of the same
      overlay. For a sweep, `linear-gradient` at an angle.
    - `plank_deck_qa.py` fails a deck on this twice: once from the CSS, and again
      from the rendered PDF, so it also catches one arriving inside an embedded SVG.
15. **Rhythm.** Invert the ground on the beats you found in step 6. A deck that is
    one tone throughout has no emphasis anywhere.

### Part three: build, then look

16. **A deck is a fixed canvas that SCALES. It must never reflow.** This is the one
    rule that separates a deck from every other HTML thing you build, and getting it
    wrong is not subtle: measured on a real generated deck at 1280, 1440 and 1920 px
    wide, the type stayed the same physical size while the canvas grew, so the
    proportions changed at every width — dead space under the text at 1920, and at
    1280 the footer note ran straight through the slide counter. Fluid layout is
    correct for a document and wrong here, because a slide is a composition: you
    placed things relative to each other, and only a uniform scale preserves that.

    Lay out ONE fixed stage and scale it to the viewport. Everything inside is in
    absolute px against that stage and never in `%`, `vw`, `vh` or `clamp()`:

    ```html
    <style>
      :root { --slide-w: 1920px; --slide-h: 1080px; --k: 1 }
      html, body { margin: 0; height: 100%; overflow: hidden }
      #stage { position: absolute; top: 50%; left: 50%;
               width: var(--slide-w); height: var(--slide-h);
               transform: translate(-50%, -50%) scale(var(--k));
               transform-origin: center center }
      @media print { html, body { overflow: visible }
                     #stage { position: static; transform: none } }
    </style>
    <script>
      const fit = () => document.documentElement.style.setProperty('--k',
        Math.min(innerWidth / 1920, innerHeight / 1080));
      addEventListener('resize', fit); fit();
    </script>
    ```

    The print block resets the scale, so the PDF still comes out at full size — which
    is why this defect survives a PDF review and only shows up in a browser.

    **The transform has to PLACE the stage, not only scale it.** `transform:
    scale(var(--k))` on a box centred by `place-items: center` (or `margin: auto`)
    is the wrong shape, and it is the one that gets written. A transform never
    changes layout, so the stage's box stays 1920×1080; in any narrower window it
    overflows its `overflow: hidden` parent, and the alignment does not move it — the
    browser centres it by setting the parent's **initial scroll offset**, once, at
    first layout, and nothing ever recomputes it. Measured on a real generated deck,
    same document, at 1040×975: correct in a top-level tab, **440 px right and clipped
    inside an iframe**, and 180 px off in the tab as soon as the window was resized.
    The first number is the one that matters, because a deck is READ in an iframe —
    that is what the Plank file viewer is.

    `translate(-50%, -50%)` is a percentage of the ELEMENT, so it recentres at every
    scale with no scroll offset in the story. That is why it is in the snippet above,
    and `plank_deck_qa.py` now fails a deck without it
    (`deck-stage-does-not-recentre`). The other correct answer is `zoom`, which
    shrinks the layout box as well as the pixels — it is what the HTML slide builder
    uses, and it needs no translate.

    **And nothing in a deck may call `history.replaceState`, `history.pushState`,
    `localStorage` or `sessionStorage`.** That same iframe is sandboxed with no same-origin privilege,
    so each of those throws a `SecurityError` instead of returning and takes the rest
    of the handler with it — the arrow key that was going to advance the slide simply
    stops working. It works perfectly in the browser you built it in, which is why it
    ships. Wrap the call in try/catch, or keep the state in a variable
    (`deck-uses-origin-locked-api`).
17. **Build it.** One `<section>` per slide at 1920×1080, self-contained — fonts
    base64 inline, images as data URIs, nothing fetched from the network. Give every
    slide a **stable id** (`id="s07"`) and keep the tokens in one `:root` block and
    the fonts in one `<style>`: that is what makes a later "change just this slide"
    edit a three-line diff instead of a rewrite. Arrow-key navigation and a slide
    counter, plus an `@page` rule that puts exactly one slide on one landscape page.
18. **Print it and look at it.** Print to PDF with headless Chromium, rasterise every
    page, and call `plank_look_at` on the images. Per slide: does one thing dominate,
    is any text cut off or overflowing, is anything unreadable against what is behind
    it, is the accent still scarce, do slides of the same kind match, does the deck
    read as one designed object. Fix, re-render, look again.

    Run `plank_deck_qa.py` on the HTML as well. On a deck you wrote by hand it cannot
    measure geometry — there is no manifest — so it checks what is a property of the
    source (that the file stands alone, that it will print one slide per page, that no
    gradient will make the PDF crawl) and then tells you, in the report, everything it
    did not look at. **A clean report there is not a checked deck.** The looking is the
    check.

    **Then time the PDF, because looking cannot see this one.** A page that takes
    twenty seconds to appear rasterises identically to one that takes ten
    milliseconds, so every check above passes a deck nobody can open. Count the
    shadings a reader has to run a program for:

    ```bash
    python3 -c "import re,sys;print(len(re.findall(rb'/ShadingType\s+1\b',open(sys.argv[1],'rb').read())))" deck.pdf
    ```

    Anything above `0` is a defect — see step 14 for what causes it and what to use
    instead.
19. **Say what you chose.** In the reply that delivers it: the palette, the faces and
    the layouts, and why each came from this subject — then offer the exports (PDF,
    editable `.pptx`, Google Slides, or a shareable link).


## Working from a deck the customer already has

"Take this deck and make one like it for X" is a different job from building one, and everything above assumes you are the author. Three rules, and they are not optional.

**Look at the source before you change it.** Convert it and open the PNGs — `plank_deck_qa.py` will do it — the same way you would look at your own deck before handing it over. You cannot decide which slides are wrong, or which are fine, by reading XML. A run that edited three slides on a deck it had never looked at replaced the wrong three.

**Never map supplied assets to slides by filename order or ordinal position.** Given three photos and six people, "three images, three directors, slides 8-9-10" is a guess wearing the costume of a rule. Identify each asset — you can see attached images, so look at them, and compare against what the slides already carry — and place it by what it *is*. If two are genuinely ambiguous, ask; one `question` call with the names costs a turn and a wrong face on a named person costs the deal. Then say which slide each one went to, so a wrong guess is visible rather than buried.

**When the deck is the customer's own credentials or press deck, its look IS the asset — keep it.** A production house, an agency or a studio pitching itself has one deck that carries its identity: palette, page size, type, and the photographs of its own people. Porting it to a new client means new *content* — brief, team, numbers, timeline — inside that unchanged identity. The new client's brand belongs on the slides as content: their name, their logo where the source puts a client logo, their project in the metadata row. It does not become the theme.

So when you ask the branding question for a deck like this, **the recommended option is to keep the source's visual language**, not to restyle it into the client's colours. Restyling is the right answer only when the customer says so, or when the deck is *theirs to be branded* — an internal report, a template they own, a deck going out under the client's name. Getting this backwards produces a deck that looks considered and has erased the thing it was selling: a studio's pitch arriving in the prospect's corporate colours, with the studio's own faces dropped.

Two things follow that are easy to miss. **Keep the page size** — a source at 10×5.62in that comes back at 13.33×7.5in is a different document, whatever it looks like. And **keep the imagery you were given**: dropping the portraits out of a talent roster removes the roster.

### Open the source. Do not re-derive it.

This is the mechanic that decides whether the three rules above are even achievable, and it is the one that keeps getting skipped.

**Start from the customer's file and edit it** — `prs = Presentation("their-deck.pptx")` — then change what the new client needs: retype the runs, swap the pictures you were asked to swap, drop the slides that do not apply. Do **not** start from `Presentation()` and rebuild their look from constants you read off their deck.

The difference is not stylistic. A run that rebuilt from empty re-typed the page size and the accent correctly, guessed the typeface **wrong** (shipped Carlito into an Arial deck), and took the picture count from **38 to 6** — because a constant can be retyped and a photograph cannot. The studio's logo lockup came back as its name typed into a text box. Everything you do not think to re-type is silently gone, and it is gone in exactly the places a customer looks first.

Opening the file gives you all of it for free: masters, theme, fonts, page size, every image, the logo, the chrome. Then the only thing that changes is what should change — the content.

**`plank_deck.py` is for decks you author.** It draws the Plank house style onto blank slides; it has no way to carry someone else's design across, and reaching for it on a port is how a deck comes back in our colours instead of theirs. When the customer's look is the asset, the tool is python-pptx on their file.

**Use `plank_deck_edit.py`.** Editing was awkward and building was easy, which is most of why building kept winning: python-pptx has no API for deleting a slide, duplicating one, or retargeting text without flattening its formatting. The toolkit is those missing pieces and nothing else — it never draws anything.

```bash
mkdir -p /workspace/scripts/deck
curl -sSfL -o /workspace/scripts/deck/plank_deck_edit.py https://plank.md/help/assets/plank_deck_edit.py
```

```python
from plank_deck_edit import *

prs = open_deck("their-press-deck.pptx")
print(inventory(prs))                      # look before you touch
keep_slides(prs, [0, 1, 2, 5, 9, 12, 15])  # a press deck carries extra
replace_text(prs, {"орбитекс форте": "halyk bank"})
swap_picture(prs.slides[3], "portraits/viktor.jpg")
save(prs, "halyk-proposal.pptx")
```

`inventory` prints every slide and shape with its index, position and text, so the plan is made against what is in the file rather than against a guess — and so your reply can say which slide each change landed on. `replace_text` edits **runs**, which is what preserves the design: in a well-made deck the label and its value are already separate runs, so replacing "орбитекс форте" leaves the grey "brand " grey without anyone having to know that was the rule. A string that matches nothing raises, because silently changing nothing is how a port ships the previous client's name. `swap_picture` replaces in place, keeping position, size and z-order — `add_picture` plus a delete puts the new photograph on top of the text it was meant to sit behind. And `clear_links` drops hyperlinks while keeping the text: replacing a link's words leaves the link, so a rewritten bullet keeps its blue underline and still points at the previous client's showreel.

Ported this way, the reference deck's slide 2 came back with the same ten shapes at the same coordinates in the same colours, sizes and italics as the source. Only the words changed.

If you would rather not take the dependency, these are the two mechanics it is built on:

```python
# Delete a slide you do not need (most ports subtract - a press deck carries
# more than one brief needs). Removes it from the presentation's slide list.
xml_slides = prs.slides._sldIdLst
for sld in list(xml_slides)[i:j]:
    xml_slides.remove(sld)

# Replace a picture in place, keeping its position, size and z-order.
old = slide.shapes[k]                      # the picture to swap
new = slide.shapes.add_picture(path, old.left, old.top, old.width, old.height)
old._element.addnext(new._element)
old._element.getparent().remove(old._element)
```

**A slide the source does not have: duplicate one, then retarget it.** This is the answer to "add two slides in our template", and it is the only one that works — the design is *copied* rather than described, so the typeface, the accent, the chrome, the page number and the background photograph all come across without anyone having to notice they exist. Pick the existing slide whose shape is closest to what the new one needs, `duplicate_slide()` it, move it into place, and rewrite its text with `replace_text()` / `set_text()`. Two runs doing exactly this returned all 16 original slides byte-identical and two new ones indistinguishable from the deck. What does not work is drawing a fresh slide in the house style and hoping it matches: nothing about "dark deck, lime accent" tells you the display type is italic.

**The completeness check is the one that fails silently, so do it by counting.** `replace_text()` is **case-sensitive** and matches the paragraph as it reads, so `{"ВИКТОР КИМ": "…"}` renames the profile heading and leaves `Виктор Ким` standing in the candidates list two slides earlier. Pass every casing the deck actually uses, then **check the returned hit count against the number of occurrences you found when you looked** — and grep the result for the old string before you hand it over. A run that skipped this replaced one of two occurrences and reported "no old occurrences remain", which is worse than missing it, because the report is the thing a reviewer trusts.

**Then prove you kept it.** `plank_deck_qa.py` takes the deck you ported FROM and reports what the port lost:

```
python3 /workspace/scripts/deck/plank_deck_qa.py ported.pptx --source their-original.pptx
```

It compares page size, typeface, the display italics, the accent and the picture count, and reports each as a `fidelity-*` finding against the deck as a whole.

It also asks the other question, which is the one that gets a proposal thrown out: **is everything on it true of this client?** Where the geometry lets it match a slide to the one it came from, it reports two `port-*` findings. `port-stale-figure` is a slide whose heading changed while a figure on it did not — a named director carrying the previous candidate's day rate, which is the kind of error a client finds before you do. `port-stale-link` is a hyperlink whose words were rewritten while its destination was not — which is how a ported talent slide keeps pointing at the previous director's showreel, and it is invisible on the slide. `port-unchanged-slide` lists the slides that came across with nothing rewritten; that one is **info, not a warning**, because leaving a slide alone is often right — a production company's own credentials do not change with the client — and no measurement can tell that apart from a slide nobody got to. Read them.

Neither of these fires on a rebuilt deck, because a rebuild matches nothing. That is correct: it has larger problems, and they are the `fidelity-*` findings above. These are whole-deck aggregates on purpose — matching slide 4 to slide 6 is a guess, and a wrong mapping produces confident nonsense, while counts and sets catch the failure that actually happens. A port that was edited from the source comes back with none of them; a port that was rebuilt lights up. Run it before you hand anything over, and treat a `fidelity-` error as what it is: not their deck any more.

## Which format: .pptx or HTML → PDF?

Ask the format question — see [the question table](#what-to-ask), where it is the one question exempt from the three-to-five cap — then route on the answer. Say which one you chose and what it costs — in one line, in the reply that delivers the deck. A user who wanted to tweak a headline and got a PDF has been let down by a decision nobody told them about.

| The deck is… | Build it with | Because |
|---|---|---|
| A pitch, a brand deck, a launch, a conference talk — it has to **land** | HTML you design yourself → PDF, [the method above](#designing-a-deck-from-scratch) | Full-bleed imagery, layered scrims and a real type engine, with an identity taken from the subject. This is the ceiling. `plank_slides.py` is the fallback when the deck has to be geometry-checked. |
| A board pack, a financial review, a monthly report | either — ask | Usually `.pptx`, because these get re-cut. HTML if it is presented once and archived. |
| Something the customer will **edit**, re-brand, or reuse as a template | `plank_deck.py` → `.pptx` | A PDF is finished. If "send me the deck" means "send me something I can change", it is this one. |
| Something that must open in PowerPoint on somebody's laptop | `plank_deck.py` → `.pptx` | Obviously. |

**What each one gives up, stated plainly:**

- **HTML → PDF is not editable.** No shapes to nudge, no chart data to double-click, no text to retype. Changing anything means changing the build script and re-printing. If that is not acceptable, it is the wrong format — and the way to find out is to ask before you build, not after.
- **`.pptx` is less visually capable.** It draws shapes. A photograph can only carry a single linear wash over it, a card is a flat rectangle with hard corners, and there is no blend mode, no gradient mesh, no sub-point hairline. It looks like a good deck; it does not look art-directed.
- **HTML → PDF also needs a renderer this workspace might not have.** It prints with headless Chromium. Run [the availability check above](#read-this-before-you-choose-the-questions-the-format-is-not-yours-to-assume) *before* you build: empty means this workspace cannot print HTML, so say so plainly and build `.pptx` — you lose the full-bleed look, the deck still ships.

Everything else is shared: same `studio`/`report` themes, same validated palettes, same 12-column grid, same primitives with the same names and signatures, same text budgets, same QA tool. Switching a finished deck script from one to the other is a change of import line.

**There are two HTML routes, and they trade the same thing in opposite directions.** `plank_slides.py` writes the HTML *and* a `.slides.json` manifest beside it, which is what lets `plank_deck_qa.py` measure every word position, every overflow and every collision — at the cost of the design being the house style rather than one derived from the subject. **Designing the HTML yourself** — [the method above](#designing-a-deck-from-scratch) — is the ceiling for how a deck can look, and it has no manifest, so no tool will ever measure its layout. That is a real trade, not a preference: on the hand-authored route the render-and-look pass is **not optional**, it is the only geometry check that exists. Take it for a pitch or a launch; take `plank_slides.py` when the deck is routine and being checked matters more than being memorable.

## What you get

- A `.pptx` or an HTML/PDF deck at 16:9, the standard widescreen aspect ratio.
- **The art-directed dark look by default** — near-black surfaces, italic display type, the Plank blue accent used sparingly, full-bleed photography where you have a picture worth showing, and a persistent header/footer carrying your brand. The light typographic style is one argument away: `new_deck(theme="report")`.
- Native, editable charts — not images of charts. Double-click one in PowerPoint and its data is right there.
- Speaker notes on every slide, not just the ones that seem to need them.
- Text set in Inter and JetBrains Mono, resolved on whatever machine opens the file. A machine without those fonts installed substitutes its own — the deck still opens and reads correctly, just in a different typeface.

## The house style

### Two themes, and the dark one is the default

`new_deck()` builds in **`studio`**; `new_deck(theme="report")` builds in **`report`**.

| | `studio` (default) | `report` |
|---|---|---|
| Looks like | A pitch, a brand deck, a launch | A board pack, a financial review, anything printed |
| Surface | Obsidian `#0A0A0F`, card `#1A1A22`, hairline `#2E2E3A` | Warm ivory `#FAFAF7`, card `#F0EDE8`, hairline `#DDD8D0` |
| Ink | `#FAFAF7`, soft `#A2A2AE` | `#3A3A42`, soft `#6B6B75` |
| Accent — rules and bars | Plank blue `#4F6DF5` | Plank blue `#4F6DF5` |
| Accent — small labels | `#8FA6FF` (8.5:1 on obsidian, 7.5:1 on the card) | `#3F58CE` (5.7:1 on ivory, 5.1:1 on the card) |
| Display type | Italic | Upright |
| Signals | good `#34D399`, bad `#FF6B6B`, warn `#F5B02B` | good `#34D399`, bad `#EF4444`, warn `#F59E0B` |

The dark theme's `bad` is a lighter red than the light theme's on purpose: `#EF4444` is only 4.0:1 on obsidian.

**Both themes accent in Plank blue.** They differ in surface, type style and imagery — not in brand colour. What differs is the *lightness step* used for small type, and that is a contrast requirement rather than a style choice: at 9pt and 11pt the accent is body text, so it needs 4.5:1, and plain `#4F6DF5` gives only 4.55:1 on obsidian and **3.98:1 on the dark card** — under the floor exactly where the step numbers and card labels sit. The light theme had the same problem in the other direction and slightly worse (4.15:1 on ivory, 3.72:1 on stone). So each theme carries one extra step of the identical hue — lighter on dark, darker on light, both within 2.3° of Plank blue in OKLCH. Rules and bars are large shapes with a 3:1 floor and stay pure `#4F6DF5` in both.

Over a photograph both themes use `#8FA6FF`, because a scrim can only guarantee light type on a dark wash.

**What a theme is not allowed to change is geometry, type sizes or budgets.** They are shared, so the same deck script builds in either look and there is exactly one budget table below to keep true. Rebuilding a deck in the other theme changes its colour, its type style and whether it carries photographs — never what fits on a slide.

**Choosing between them.** Persuasion takes `studio`; scrutiny takes `report`. A deck that will be printed, photocopied, read on paper or projected onto a badly-lit wall is a `report` deck. If the user asks for "the light version", "как раньше" or a deck to print, pass `theme="report"` and say you did.

### Chart series palettes — fixed order, do not reorder or substitute

There is one per surface, because colorblind-safety is a claim about a palette **and the ground it is drawn on**, and it does not transfer between them. Each was validated in this exact sequence. Every chart primitive assigns from its theme's list in order.

**`studio`, on obsidian `#0A0A0F`** — worst adjacent CVD ΔE 16.1 (protan), worst normal-vision ΔE 18.2, all six ≥3:1 contrast, all six inside the dark-mode OKLCH lightness band 0.48–0.67: `#4B8EFF` blue, `#C08A1F` ochre, `#00A4BF` teal, `#30AE40` green, `#9B5CD7` violet, `#DE5077` rose.

**`report`, on ivory `#FAFAF7`** — worst adjacent CVD ΔE 15.1, worst normal-vision ΔE 15.6, all six ≥3:1 contrast: `#4F6DF5` blue, `#C2701F` ochre, `#0E9F9F` teal, `#4C7A2E` green, `#9B5DE5` violet, `#C2456B` rose.

The dark list was re-derived from scratch, not lightened by eye. The ivory list fails the dark-mode lightness band outright, and its ochre and green fall under 3:1 on near-black — so "the same colours, a bit brighter" would have shipped a palette with no guarantee behind it.

**Type.** Inter throughout; JetBrains Mono is reserved for tabular data — table body cells, where a column of figures needs to line up — and does not extend to a KPI's own value or delta, which have nothing to line up with (see below). Cover title 44pt/700, tracking −0.03em. Slide title 30pt/700, tracking −0.03em, bottom-anchored in a box tall enough for two lines *plus a 6pt inset underneath them*, so a long title grows upward and never collides with the accent rule under it. That inset is load-bearing: a rendered glyph sits slightly below its own line box, and with the box ending exactly where the type ended, every titled slide in every deck put its title's descender outside the shape that owned it. Raising the box height alone does nothing about it — bottom-anchored text moves down with the box bottom — so the space has to be reserved as an inset the type cannot use. Section-divider label 40pt/700, tracking −0.03em — sized to match the closing slide, not the smaller eyebrow caption it started as. Statement 32pt/700. Body text 16pt/400. KPI value 40pt/700, Inter. Eyebrow labels (section numbers, compare-card labels, hero eyebrows) 11pt/700, tracked +0.08em. Cover footers, closing contact lines and quote attributions take the same tracking at 400 in ink-soft — they are quiet metadata, not headings, and the builder does not embolden them. The brand chrome runs quieter still at 9pt, tracked +0.06em.

**Display type is italic in `studio` and upright in `report`** — covers, slide titles, statements, section labels, quotes, hero titles and closings. It is the cheapest single thing that makes a deck read as art-directed rather than as a template, and because it is a style and not a size it costs the budgets nothing measurable: Inter Italic's advance widths sit within 1% of the upright, well inside the 9% headroom every proportional estimate already carries.

**Geometry.** 13.333in × 7.5in (16:9). Margins 0.75in left/right, 0.6in top, 0.7in bottom. A 12-column grid with 0.2in gutters — every primitive places itself on this grid, never at an arbitrary coordinate.

**Two chrome bands are reserved on every slide**, whether or not the deck sets a brand: a 0.24in metadata row starting at the top margin (0.60in), and a 0.25in footer ending at the bottom margin (6.55–6.80in). They sit *inside* the safe margins, not in them — content in the margin is cropped by projectors, and the QA pass calls it an error. Reserving them unconditionally is deliberate: a deck built without a brand lays out identically to the same deck with one, so adding a logo late does not silently re-flow every slide, and there is one budget table instead of two that have to agree.

The title band therefore starts at 0.94in, the accent rule sits at 2.17in, the body band runs 2.44in → 6.55in, and source notes live in their own strip at 6.24–6.49in, immediately above the footer.

**Vertical rhythm.** Under the accent rule there is a 4.11in *body band*, and every body primitive is measured against it rather than against the slide. A block that ignored it — a fixed-height KPI tile, a fixed-height process card, a three-row table — used to end between 48% and 57% down a 16:9 slide with nothing under it, which reads as unfinished rather than as deliberate whitespace. Blocks still *start* at the top of the band, never lower: floating a row an inch below the accent rule makes it read as unrelated to its own title. They grow downward into it two ways, depending on what sets their height:

- **Cards** — KPI tiles and process steps — have a height the content doesn't set, so the layout sets it: 3.29in, with the card's own content centered inside. Not the full band: at 3.70in a tile's three short lines float in the middle of a mostly empty surface, which doesn't fix the hole in the slide, it moves the hole inside the card. A process card also runs with its step count, because two steps are 5.7in-wide cards and a step carries far less ink than a 40pt KPI value.
- **Rows** — bullets, compare points, table rows — are a count times a pitch, so the *pitch* stretches to fill the band and stops at a cap. Content already filling the band is spaced exactly as it was; short content gets more air, not bigger type. The cap is the point: two bullets on a slide should be two well-spaced bullets, never two enormous ones.

A block that fills the band reaches 3.70in and stops just above the source-note strip — that is what the fill fraction is for, and it is why a chart is 3.70in tall rather than the 3.90in it was before the chrome bands existed. Emptiness left under genuinely short content is correct and is left alone; see [Whitespace](#making-it-look-considered).

## Slide types

Fourteen primitives, each a function in the builder below. Reach for the one that matches what the slide is actually for — don't force content into the wrong shape to avoid learning a new primitive.

- **`cover`** — the opening slide: title, optional subtitle, presenter and date.
- **`section_divider`** — a numbered beat between sections of the talk, full-bleed surface.
- **`statement`** — one bold claim, centered, nothing else competing with it.
- **`bullets`** — a titled slide with up to six points, each with as many optional sub-points as fit inside the 8-line total budget.
- **`compare_two`** — two labeled cards side by side, for a before/after or an A-versus-B.
- **`kpi_row`** — two to four headline numbers in tiles, each with a label, an optional delta, and an optional `tone` that colors the delta.
- **`table_slide`** — a data table with an optional source note underneath.
- **`chart`** — a native, editable chart; you pick it by what the reader should take away, not by which chart type looks nice.
- **`quote`** — a single quotation with its attribution, full-bleed surface.
- **`labelled_blocks`** — two to four labelled cards, drawn as a grid (2×2 for four), with an optional eyebrow above the title's rule and an optional lead under it. The shape for a set of peers: four capabilities, four guarantees, four markets. Reach for this rather than `bullets` whenever each point has a NAME as well as a sentence — a flat list of four is read as an ordered list, and they are not ordered.
- **`process`** — up to five numbered steps laid out as a row of cards. A step is a string, or `{"label": …, "text": …}` when the stage has a name worth setting apart from its description.
- **`hero`** — a full-bleed photograph with the title over it, plus an optional eyebrow and lead line. The opening of a pitch, the beat before a section, the one slide that has to land as an image rather than as an argument.
- **`image_content`** — the picture on the right five columns of the body band, up to four points on the left seven. An argument that needs a picture beside it rather than under it.
- **`closing`** — the final slide: headline, optional call to action, optional contact line.

`hero` and `image_content` are the two that take a file path. See [Images](#images) below — the rules there are not optional.

## Text budgets

Every primitive measures its text against the box it has to fit in and raises `DeckError` rather than trusting anyone to remember a number. The measurement happens **before** anything is drawn, so a slide that cannot hold its content is refused instead of being built and then discovered later. Going over budget means the slide is carrying two ideas, not that the font should shrink to fit — the fix is to shorten the text or split the slide, never to reduce the type size.

The budgets are the check that runs while the deck is being written, and not the only one: once the file exists it can be rendered and looked at — see [Checking the deck](#checking-the-deck).

**A budget is a measurement, not a character count.** Each character costs an estimated fraction of an em — 0.52 for Latin lowercase, digits and punctuation, 0.58 for Cyrillic, 0.68 for uppercase in any script, 1.0 for CJK and emoji, 0.26 for a space — plus about 9% headroom. JetBrains Mono runs are billed at their exact 0.60em advance instead, with no headroom, because a monospace font has no wider glyph to miss. The builder then wraps the string into its real box the way a renderer does, and raises if it needs more lines than the box has.

The practical consequence: **the same box holds roughly 10% fewer Cyrillic characters than English ones**, and fewer again if the text is uppercase. That is why the table below gives two numbers. Neither is the rule — the measurement is. The old single number was calibrated on English prose and was shipped, on this page, to Russian readers; a Russian line at the English limit overflowed its card.

Two more rules the check enforces:

- **No line breaks or tabs.** Every budgeted string is a single line by construction. `"Q1\nQ2"` is five characters and passes any cell budget, and it doubles the height of a fixed-height row.
- **No unbreakable token wider than one line.** Word wrapping cannot break *inside* a token, so a long URL, a product code, or a compound like `Перерасчёт-отчётности` runs off the side of a narrow card however short the whole string is. Break it with a space or a hyphen.

| Slide part | Approx. capacity (English / Russian) |
|---|---|
| Cover block (title + subtitle) | The two share ONE frame, so they share one budget and it is measured on the pair. Title up to 6 lines of ~26 / ~23 chars (editorial cap 90); subtitle up to 2 lines of ~67 / ~60 (cap 140) — but what decides the slide is the block against the space above the presenter strip. If the title fits alone and the pair does not, **the subtitle is dropped and the cover is still drawn**, with a Python warning naming what went. A cover is the one slide whose absence is worse than any compromise in it |
| Cover footer, closing contact | one line, ~127 / ~115 chars |
| Slide title | 2 lines of ~52 / ~46 chars, and at most 80 chars — the title box is bottom-anchored and grows upward, so the accent rule and body below it never move. The box is 1.10in of type plus a 6pt bottom inset that type may not use. On `image_content` the title only has seven columns: 2 lines of ~27 / ~24 chars |
| Section divider label | one line, ~33 / ~29 chars |
| Statement | 4 lines of ~39 / ~35 chars, and at most 140 chars |
| Bullets | 6 items max; **8 lines total** across bullets *and* sub-bullets combined, counting the lines each one **wraps to** — so a bullet may run long, it just spends more of the eight. ~75 / ~67 chars per line of bullet (the em-dash marker counts), ~81 / ~73 per line of sub-bullet |
| Compare (`compare_two`) | 5 points per side max. A point may wrap; what is budgeted is the **body band**, so each side may spend **8 lines** across its points however it likes. ~44 / ~39 chars per line, ~53 / ~48 per card label. The two cards are a matched pair and both take the taller side's height |
| KPI row | 2–4 tiles. The value, the label and the delta share ONE stack inside the tile, and **2.45in of it** is what is budgeted — a 40pt value line is three label lines tall, so no per-field line count can express the trade. In practice that is a one-line value plus **up to 8 label lines** (7 with a delta), which at 4 tiles is roughly 130 / 115 characters of label. The label was capped at one line until 2026-09-18, which is about 12 Cyrillic characters and cannot say what a number means. The delta's colour comes from the tile's optional `tone` (`good` / `bad` / `neutral`), never from its sign |
| Table | 6 columns × 8 rows max. **A cell may wrap and its row grows to hold it**; what is budgeted is the table's total height against the body band (3.70in), where a one-line row costs 0.30in and each extra line 0.20in. So four rows with a two-line column fit and eight do not, which the old one-line-per-cell rule could not tell apart. A cell may not exceed 4 lines. Header (Inter): ~41 / ~36 chars at 3 columns down to ~18 / ~16 at 6. Body cell (JetBrains Mono, exact): 36 per line at 3 columns down to 16 at 6. A row with the wrong number of cells is rejected outright |
| Process | 5 steps max. The card is **content-sized**: a step may use as many lines as the body band holds (**9** at most — 8 when the step carries a label), and the card grows to fit the fullest one, no further. A short row is not stretched to a layout height; it sizes to its content and the row sits optically in the band. An optional per-step `label` is one line, at most 40 chars, and costs the step one of its own. Per line, from the card width: ~46 / ~41 chars at 2 steps, ~28 / ~25 at 3, ~19 / ~17 at 4, ~14 / ~12 at 5. There is no character cap — the old flat 32 refused about a third of what the card could carry |
| Quote | 4 lines of ~49 / ~44 chars, and at most 180 chars; attribution one line, at most 60 |
| Closing title | 3 lines of ~27 / ~24 chars, and at most 60 chars; CTA 2 lines, at most 90 |
| Chart source note | one line, ~147 / ~131 chars |
| Chart category labels | one line each, sharing the plot width: ~33 / ~29 chars at 4 categories, ~22 / ~19 at 6 |
| Hero (`hero`) | Title 3 lines of ~27 / ~24 chars, and at most 60 — tighter than a slide title's 80 on purpose, because a long sentence on top of a photograph is read by nobody. The box is bottom-anchored like a slide title's and holds all three lines: 2.07in of type (three 48pt line boxes plus 4.8pt for the ascender overshoot) plus the same 6pt bottom inset. It used to be 1.86in, which is 2.7 of them, and a 54-character Russian title — well inside the cap — escaped 14.8pt out of the **top** of it. Eyebrow one line, at most 40 chars, and it sits above the first *line* of the title rather than above the box, so it does not float when the title is short. Lead 2 lines of ~67 / ~60, at most 110 |
| Image + content (`image_content`) | 4 points max. A point may wrap; what is budgeted is the body band, so the points may spend **8 lines** between them however they like — the same eight `bullets` gets, because it is the same band at the same size. ~58 / ~52 chars per line (the em-dash marker counts). Caption one line, ~60 / ~53, at most 70 |
| Labelled blocks (`labelled_blocks`) | 2–4 blocks. Label one line, at most 40 chars. The card is content-sized and every card takes the tallest one's height, so the budget is the whole block — lead, cards and the row gutter — against the body band (4.11in; this primitive draws no source note, so the strip reserved for one is not reserved against anything here). On a 2×2 **the lead costs BOTH rows**, so a one-line lead buys every card a line of its own. Eyebrow one line, at most 48 chars, and it takes the accent rule's slot: there is nowhere above the title, because the brand metadata row ends at 0.84in and the title box starts at 0.94in |
| Brand chrome | Metadata row one line, ~112 / ~101 chars — it shares the top band with the logo, which is sized by height and under an inch wide, so the row gets nine columns rather than the six it used to. Footer line one line, ~107 / ~95. Both are measured once, in `set_brand`, not per slide |

The character caps above (40, 60, 70, 80, 90, 110, 140, 180) are editorial limits on the display-tier slides, applied on top of the measurement: a cover title that long is a writing problem before it is a layout one. The body-tier primitives — bullets, compare, process — carry no character cap at all; they are measured against the band and nothing else, because a body slide going long is a layout question and the layout can answer it.

## Images

Two primitives take a picture: `hero(prs, image, title, eyebrow=None, lead=None, notes="")` and `image_content(prs, title, image, points, caption=None, notes="")`. Both take a **path to a file that already exists in the workspace** — the builder consumes images, it does not make them.

**Confirm the file is there before you reference it.** The builder checks too, and refuses with a `DeckError` before the slide is created rather than after — a missing image is the one thing in a deck that fails *silently*, as an empty frame that looks like a design choice. Paths are relative to wherever the build script runs, so keep the script and the images in the same place and use the same relative path you would use to open the file.

**Text over a photograph always gets a scrim, and this is structural.** Both primitives lay a dark gradient wash between the picture and the type, and there is no argument that turns it off. That is not a stylistic preference — it is the only thing that makes the type legible against a picture nobody has vetted, and "pick a darker photo" is not a guarantee when the next photo is a coin toss. Text over an image is always light type on that wash, in **both** themes: a photograph is not a surface whose colour anything knows, so the light theme does not put graphite on it.

The wash is heaviest across the bottom third, where the title, the lead and the footer sit, and nearly clear across the middle so the picture is still a picture. `image_content` uses a narrow strip under its caption instead of washing the whole image, for the same reason.

**And it is checked.** `plank_deck_qa.py` rasterises every slide, so for each word rendered over a picture it reads the pixels that actually ended up behind it and computes the real WCAG contrast ratio. Under 3:1 is an error; 3:1 to 4.5:1 is a warning. See [Checking the deck](#checking-the-deck).

If you hand-place a text box over a picture rather than using these primitives, you own the scrim yourself — and the check will tell you if you got it wrong.

## Charts

`chart(prs, title, goal, categories, series, source, notes)` picks the chart type from `goal`, not from taste — say what the reader should take away and the builder picks the shape that shows it:

| Goal | Chart type |
|---|---|
| `comparison` | clustered column |
| `trend` | line |
| `composition` | stacked column |
| `part` | doughnut |
| `progress` | bar (ranked, reading top-to-bottom) |

Native charts are vector and fully editable — a reader can click into the data — and they inherit the deck's fonts and series palette automatically. The chart title states the takeaway ("Support costs fell 40% after triage"), never just the dimension being plotted ("Support costs"). Every chart carries a source note, and that note is measured like any other string.

**Bar and column charts are pinned to a zero baseline.** `comparison`, `composition` and `progress` set the value axis minimum to 0, so a small difference can't be exaggerated by a truncated axis — bars encode length, and a length measured from an arbitrary floor lies. `trend` is deliberately left on PowerPoint's autoscale: a zero baseline on a time series usually flattens the very movement the chart exists to show.

`goal="part"` draws one doughnut ring: it rejects more than one series (a ring can only show one), and rejects more than six categories, since a doughnut colors its *slices* and the validated palette has six slots — group a long tail into "Other" rather than adding a seventh slice. Every other goal takes at most twelve categories: past a year of months the labels overlap and the chart is really a table.

Ragged data is rejected, not silently drawn. A series whose value list is shorter than the categories plots partial data; a longer one drops its tail. Both raise, as does a value that is neither a number nor `None` (which is a deliberate gap). `table_slide` refuses a ragged table the same way: every row must carry exactly as many cells as there are headers.

Charts follow the type split stated above: JetBrains Mono only in table body cells, Inter everywhere else — headers, source captions and axis labels included. Mono's fixed advance works against a display number, opening a visible gap around a comma or a leading minus and a wider one before a Cyrillic unit, which is why a KPI's value is Inter too.

If what you need can't be expressed as a native OOXML chart — a waterfall, a gantt, a sankey, an annotated combo chart — fall back to `matplotlib` rendered to a PNG and placed as an image. Do this knowingly: Plank workspaces have no fonts installed, so `matplotlib` renders in its default DejaVu Sans, and the result will not match the rest of the deck's typography. Use it only for the shapes OOXML genuinely cannot express, not as a shortcut around the native chart primitive.

## Writing the slides

Everything above makes a slide *well-formed*. None of it makes a slide *good*. A deck can clear every budget and still open with a title that names a topic instead of stating a finding, spend six slides making one point, and bury the decision on slide nine. These are the rules the code can't check.

> These rules are the deck-specific narrowing of a cross-format standard that ships as two installable skills — **Deliverable Writing** and **Deliverable Design**, which extend the same bar to HTML documents and dashboards. For a deck, this page wins on deck specifics and carries the worked examples. If either skill is installed in this workspace, invoke it as well before you deliver. See [Skills](/help/skills).

**Open with the answer, then spend the deck proving it.** The first slide after the cover states the conclusion. Everything after it is support: the arguments that hold the conclusion up, and under each argument its evidence. Never build to a recommendation over twenty slides — the room decides in the first minute whether it is listening, and a reader who already has the answer reads what follows as proof rather than waiting to find out why they are in the room.

| A deck that opens | Reads as |
|---|---|
| Market overview → Our services → Case studies → Our proposal | Four slides in, nobody knows what is being asked of them |
| Manual re-counting costs us ₸40m a year → Where the loss comes from → The pilot removed two of the three sources → Approve the rollout by 1 October | The finding first; the rest is why it is true and what to do |

**The title states the finding, not the subject.** The title carries the claim; the body carries the evidence. Read the titles alone, top to bottom, and you should have the argument — starting with the first one, which should already be the answer and not a promise of one.

| Instead of | Write |
|---|---|
| Support costs | Support costs fell 40% after triage |
| Q3 results | Q3 revenue grew 12%, all of it from renewals |
| Hiring plan | Two more analysts before October, or Q4 slips |

**One idea per slide.** This is the same rule the budgets enforce from the other side: when the text won't fit, the cause is almost always two ideas sharing a slide, not a font that's too big. Split it, and give each half a title that states its own finding.

**Every number needs a baseline and a direction.** "40%" measures nothing. "40% lower than Q2" is a finding. Units always, and always say what the number is measured against — last quarter, plan, the other vendor. A KPI delta follows the same rule: `-40% vs Q2`, not `-40%`.

**And say whether the direction is good news — the sign can't.** `kpi_row` takes an optional `tone` per tile, one of `"good"`, `"bad"` or `"neutral"`, and that is the only thing that colors the delta:

```python
{"value": "-2.0", "label": "Handoffs per ticket", "delta": "-2.0 vs Q2", "tone": "good"}
{"value": "-12%", "label": "Revenue per account", "delta": "-12% vs Q2", "tone": "bad"}
{"value": "2.4x", "label": "Tickets per day", "delta": "2.4x vs Q2"}
```

**A delta with no `tone` is neutral ink, whatever its sign.** The builder used to read the sign — leading `+` green, anything else red — and painted `-2.0 handoffs per ticket`, `-31% churn` and `-40% cost` in the same error red as a failure. For a large class of business metrics down *is* the win, so the sign genuinely cannot tell: `-40%` is a win on cost and a loss on revenue. The builder no longer guesses, because guessing is what produced that bug, and a wrong color is worse than no color — a neutral delta still reports the number; a red one reports a failure that didn't happen. If a delta deserves green or red, say so with `tone`. A misspelled tone raises `DeckError` rather than silently falling back.

**Bullets on one slide take the same grammatical shape.** Same shape means the reader compares content instead of untangling wording. Not "Costs down 18% / We hired two analysts / Latency improvements", but "Cut costs 18% / Hired two analysts / Cut p95 latency 40%".

**Say the "so what".** Name the decision or the action, and name who owns it. "Approve two hires by 15 September" is a so-what; "we should consider hiring" isn't. A deck that ends without one is a report, and a report doesn't need slides.

**Cut the filler.** "Overview", "Introduction", "Next Steps" name a slot in a template, not a finding — retitle the slide with what it actually says. No warm-up sentence before the first point, and no bullet that restates the title in other words.

**Speaker notes carry the nuance the slide can't.** The builder refuses to build a slide without them, so use them for something: where the number came from, the caveat it would be dishonest to leave out, the answer to the obvious objection, the figure you'd quote if asked. Not a transcript of the bullets — the audience can read those.

## Making it look considered

A budget proves the text fits inside its box, and nothing else. Balance, hierarchy and whether the deck holds together are judgement. These are the habits that stand in for the look you can't take.

**Hierarchy is size, weight and placement — in that order.** The headline is *visibly* larger than what supports it, not a point or two larger, and the takeaway sits where the eye starts: upper-left for a Russian or English reader, dead centre on a `statement`. The primitives already do this. A hand-placed box that parks the conclusion bottom-right undoes it.

**One focal point per slide, and the eye should reach it first.** Decide what the reader should see first — the number, the chart, the claim — and let everything else support it. If two elements compete for that job, one belongs on its own slide or in the notes.

**Two typefaces, and no colour from outside the theme.** The house style ships exactly two faces (Inter, JetBrains Mono) and two validated six-colour series palettes, so the rule is really *do not add*: no third typeface, and no hex that isn't a theme token or a series colour. Weight and size make hierarchy inside one family — that is what they are for. The six series colours are meaning, not decoration: assigned in a fixed, validated order so a colour keeps its meaning across the deck. If a slide needs emphasis, use position, size or whitespace. `plank_deck_qa.py` counts the typefaces and warns on a third; colour it leaves to you, because a workspace can legitimately override `primary` and `accent` and the tool can't tell that from a slide painted red for effect.

**Layout follows the shape of the content — so pick the primitive, don't invent a layout.** Text with a picture beside it is `image_content`; a claim against a counter-claim is `compare_two`; grouped metrics are a `kpi_row` of two to four; ordered stages are `process`. Reaching for a new arrangement almost always means the content wants a primitive that already exists.

**Whitespace is deliberate, not leftover.** A crowded slide reads as unfinished even when every string fits — and so does the opposite, a small block of content clinging to the top of an empty slide. The primitives handle the second case for you by growing into the body band, but they can't invent content: if a slide is still half empty, it wants another point or a different primitive, not a taller box. Sit on the 12-column grid and repeat spacing rather than tuning it per slide — consistency of spacing, type size and colour is most of what separates a professional deck from an amateur one, and a reader notices an inconsistent gap long before an imperfect one.

**Type can be the picture.** A deck without photographs is not a lesser deck. Large expressive type as the visual driver — `statement`, `section_divider`, a cover carrying nothing but the claim — is a real alternative to imagery, and usually the better answer for a financial or reporting deck. It is also the answer when [the imagery question](#what-to-ask) comes back "none".

**Charts stay legible.** The chart rules above already cover the takeaway title, the zero baseline and the source note — follow them. Beyond that: name a series in words the reader doesn't have to match to a colour. A single series draws no legend at all, so put its name in the title ("Weekly signups, 2026"); six series and a bottom legend means the reader is doing matching work, so split the chart or group the tail into "Other".

**Consistency across the deck.** The fourth KPI slide should look like the first: same primitive for the same job, same order of elements, same wording of labels.

## The builder

Don't transcribe it — download it:

```bash
mkdir -p /workspace/scripts/deck
curl -sSfL -o /workspace/scripts/deck/plank_deck.py https://plank.md/help/assets/plank_deck.py
```

It goes in `/workspace/scripts/deck/plank_deck.py` — that exact directory, and that exact name: the underscore matters, since `import plank-deck` is a Python syntax error. Put your own `build_*.py` in `/workspace/scripts/deck/` too, so `from plank_deck import *` resolves with no `PYTHONPATH` and no `cd` — a bash call does not inherit the previous call's directory, which is why a bare relative filename is the one thing that reliably breaks here. Never hand-write a file called `plank_deck.py`: it imports, it measures nothing, and every later build in the workspace picks it up instead of the real builder. Short version of all of this: [Build a deck: the route](/help/deck).

### Every call, and the shape of every argument

This is the whole surface. You do not need to read the source to write a deck — and you should not, it is two thousand lines and reading it costs more than building the deck. `curl` it, call these, and read the file only if a `DeckError` sends you somewhere this table does not cover.

```python
new_deck(theme="studio")                     # or "report"; returns prs
set_brand(prs, meta=(), logo=None, confidential=None, city=None,
          year=None, slide_numbers=True)
save(prs, path)                              # returns path

cover(prs, title, subtitle=None, presenter=None, date=None, notes="")
section_divider(prs, number, label, notes="")
statement(prs, text, notes="")
bullets(prs, title, items, notes="")
compare_two(prs, title, left, right, notes="")
kpi_row(prs, title, kpis, notes="")
table_slide(prs, title, headers, rows, note=None, notes="")
chart(prs, title, goal, categories, series, source, notes="")
quote(prs, text, attribution, notes="")
process(prs, title, steps, notes="")
hero(prs, image, title, eyebrow=None, lead=None, notes="")
image_content(prs, title, image, points, caption=None, notes="")
closing(prs, title, cta=None, contact=None, notes="")
```

**Every slide takes `notes` and every slide requires it.** A slide built without speaker notes is refused — that is deliberate, and it is the one guard here that is about the deck being *presentable* rather than about it fitting. Write the note you would actually say out loud.

The compound arguments, with their limits:

| Argument | Shape | Limit |
|---|---|---|
| `bullets(items)` | `"point"`, or `("point", ["sub", "sub"])` | 1–6 items, 8 lines total |
| `kpi_row(kpis)` | `("value", "label")`, or `{"value":…, "label":…, "delta":…, "tone":…}` | 2–4 tiles |
| `compare_two(left/right)` | `("label", ["point", …])`, or `{"label":…, "points":[…]}` | 1–5 points a side |
| `table_slide(headers, rows)` | `["Header", …]` and `[["cell", …], …]` | 1–6 columns, 1–8 rows |
| `chart(categories, series)` | `["Q1", …]` and `[("Series name", [1, 2, 3]), …]` | one series per validated palette slot; `goal="part"` takes exactly one |
| `process(steps)` | `"step text"`, or `{"label":…, "text":…}` | 1–5 steps |
| `labelled_blocks(blocks)` | `("label", "text")`, or `{"label":…, "text":…}`; plus `eyebrow=` and `lead=` | 2–4 blocks |
| `image_content(points)` | `"point text"` | 1–4 points |

`tone` on a KPI tile is `"good"`, `"bad"` or omitted, and colours the `delta` only. `goal` on a chart is what the reader should take away, not a chart type — see [Charts](#charts).

**A `DeckError` means the slide is carrying too much, not that the font should be smaller.** Every primitive measures its text against the box before it draws anything, so a refused call adds no slide at all and leaves nothing to clean up. Split the slide, cut the copy, or move the detail to the notes.

## The HTML slide builder

When the genre says HTML → PDF, the builder is `plank_slides.py`. It needs `plank_deck.py` in `/workspace/scripts/deck/` beside it — it **imports** the themes, the grid, the budgets and the chart-goal vocabulary from it rather than restating them, which is what guarantees the two paths cannot drift apart:

```bash
mkdir -p /workspace/scripts/deck
curl -sSfL -o /workspace/scripts/deck/plank_deck.py   https://plank.md/help/assets/plank_deck.py
curl -sSfL -o /workspace/scripts/deck/plank_slides.py https://plank.md/help/assets/plank_slides.py
```

```python
from plank_slides import *

deck = new_deck(lang="ru")            # or new_deck(theme="report", lang="en")
set_brand(deck, meta=(("brand", "ACME"),), city="Almaty", year="2026")
cover(deck, "Складская логистика без ручного пересчёта",
      subtitle="…", presenter="…", date="…", notes="…")
hero(deck, "photo.png", "Каждый час склада считают вручную",
     eyebrow="ПРОБЛЕМА", lead="…", notes="…")
save(deck, "deck.html")
```

Every primitive on this page exists here under the same name with the same arguments, and refuses the same over-long text — the budgets are imported, not re-implemented. Three differences worth knowing:

- **`new_deck(lang=…)`.** Sets the document language so a browser hyphenates and quotes correctly. It changes no geometry.
- **`cover(image=…)`.** The HTML cover can carry a full-bleed photograph, which the `.pptx` cover cannot.
- **`image_content` bleeds.** The picture reaches the top and right edges of the slide instead of sitting in a box, with its own scrim protecting the metadata row.

`save()` writes **two** files: `deck.html`, and `deck.slides.json` beside it. The second is the authored geometry of every block — the thing a `.pptx` carries inside itself — and the QA tool needs it to tell you a word left its box. Keep them together; hand over the PDF.

**The `.html` has two geometries, and you don't have to do anything about it.** Opened in Plank — or in a browser, or on a phone — the slides **scale to fit the width they're given** and stack one above another, so the deck is read by scrolling; nothing scrolls sideways and nothing is cropped, at any window size. That is screen only. **Printed, every slide is still exactly 1280×720**, one slide per page, which is what makes the PDF the fixed-size artefact it has always been — the scaling is inside `@media screen` and cannot reach it. It is plain CSS, so it works with JavaScript off and the file stays self-contained. The deck never scales *up*: past 1280px wide it centres at full size rather than blowing the type up.

**The PDF comes out of the QA pass, not the builder**, exactly as it does for a `.pptx`. That is deliberate: the PDF you hand over is by construction the one that was checked.

```bash
python3 /workspace/scripts/deck/plank_deck_qa.py deck.html     # prints deck.pdf, then inspects it
```

## Your own brand

A workspace can steer the house style by adding a `## Brand` block to its `AGENTS.md`:

```markdown
## Brand
- theme: studio
- primary: #0B5FFF
- accent: #F2A65A
- fonts: Söhne / Söhne Mono
- logo: assets/acme-logo.png
- footer: ACME · Confidential · Almaty · 2026
- meta: brand ACME / agency Plank Studio
```

**That block is guidance the assistant reads and applies by hand. Nothing parses it** — so it is not how you set a customer's colours. It is a place to record the brief. Two of its keys now have a mechanical route, and you should use it.

### The colours: `new_deck(accent=, ink=, ground=)`

```python
prs = new_deck(accent="#DAFF00", ground="#000000", ink="#FFFFFF")
```

`ground` is the page, `ink` is the type on it, `accent` is the one colour that is theirs. Everything else — the card surface, the hairline, the softer ink — is **derived** from those three, so the palette stays a palette rather than three colours dropped into somebody else's. Pass one, two or all three; anything you leave out keeps the base theme's.

**Every derived pair is measured before the theme exists**, against the same WCAG floors `plank_deck_qa.py` measures the render with: 4.5:1 for ink, the soft ink and small accent labels, on both the ground and the card; 3:1 for the accent as a rule or a bar. A pair that fails raises a `DeckError` naming the pair and the ratio, which is a fixable brief. The alternative is a deck that is built, handed over and only then found to be unreadable.

One pair fails more often than the rest: a brand accent chosen for a logo is frequently too dark to set 9pt type in. That is what `accent_text=` is for — a lighter or darker step of the same hue, leaving the graphic accent alone. It is exactly what both built-in themes already do (`#4F6DF5` as a rule, `#8FA6FF` as a label on obsidian).

```python
prs = new_deck(theme="report", accent="#0B5FFF", accent_text="#0A47B8")
```

`brand_theme(...)` is the same thing as a value, if you want to build the `Theme` once and hand it to several decks: `new_deck(theme=brand_theme(accent="#DAFF00", ground="#000000", ink="#FFFFFF"))`.

**The chart series palette is not derived and does not move.** Its six colours were validated colorblind-safe as that exact sequence on that exact surface; a brand accent is a different problem and three inputs cannot solve it. If a customer has a real chart palette, pass `series=` on `Theme` directly and accept that the validation is yours now.

### The chrome: `set_brand(...)`

The chrome is one call, and it is separate from the colours above on purpose: a logo and a footer are content, a palette is a theme.

```python
prs = new_deck()                      # or new_deck(theme="report")
set_brand(prs,
          meta=(("brand", "ACME"), ("agency", "Plank Studio")),
          logo="assets/acme-logo.png",
          confidential="Confidential", city="Almaty", year="2026")
```

Every slide then carries the metadata row and the logo lockup across the top, and the confidentiality line, city, year and slide number across the bottom. Call it once, before the first slide. A deck with no `set_brand` still numbers its slides — the bands are reserved either way.

Four things to know before you override:

- **`theme` picks between `studio` and `report`.** That is the one key that is not a colour, and it changes more than a colour does. See [Two themes](#two-themes-and-the-dark-one-is-the-default).
- **`primary` and `accent` in the `## Brand` block are a brief; `new_deck(accent=…)` is the mechanism.** Read the block, then pass the colours. They move the accent rule and the eyebrow labels and never touch the chart series palette — see above for why.
- **An accent that works on your surface may not work over a photograph.** Plank blue is fine on ivory and marginal on the scrim, which is why the light theme carries a second, lifted value (`#8FA6FF`) for chrome and eyebrows that sit over an image. `brand_theme` checks the accent against the ground and the card; it cannot check it against a photograph nobody has chosen yet, so render the hero slides and let the QA pass measure them.
- **Overriding `fonts` invalidates every number in the text budgets above.** They are derived from Inter's and JetBrains Mono's measured advance widths. Another typeface has different widths, the assistant has to re-derive them by hand, and nothing in a workspace can render the result to check the answer. Expect to review the first deck yourself.

`logo` is a path relative to the workspace root. **Confirm the file actually exists before referencing it.** `set_brand` refuses a path that does not resolve, which is one error at the top of the script rather than a broken image frame on every slide — but a deck with no logo is correct, so leave the key out rather than guessing at a filename. `fonts` is written as `heading / mono`; body text follows whatever the heading font is, the same way Inter and JetBrains Mono pair in the default.

If a workspace has a deck-building skill installed and it's explicitly invoked, that skill wins over this page on **look and content** — the brand colours, the typeface, the tone, which slides to include. An explicit instruction always beats a default. It does **not** silently win on the **toolchain**: unless it names a different builder and says why, the deck is still built with `/workspace/scripts/deck/plank_deck.py` and still checked with `/workspace/scripts/deck/plank_deck_qa.py`. A skill that says nothing about how to build has not chosen another route; it never mentioned this one.

## Checking the deck

The budgets above run before a slide is drawn. Some faults only exist once a renderer has laid the deck out — a table whose cells wrapped and grew its rows, two boxes that landed on each other, a chart whose labels collide, a picture that didn't decode. A Plank workspace can now render a `.pptx` and look at the result, so those are checkable too:

```bash
mkdir -p /workspace/scripts/deck
curl -sSfL -o /workspace/scripts/deck/plank_deck_qa.py https://plank.md/help/assets/plank_deck_qa.py
python3 /workspace/scripts/deck/plank_deck_qa.py quarterly-review.pptx
```

It converts the deck to PDF, rasterises every page to a PNG, reads back where each word actually landed, and compares that against the geometry the deck declares. Findings print per slide, saying what is wrong and what to change; it exits non-zero when anything is an error, so it drops straight into a build → check → repair loop. Re-run it after each repair.

**It takes either format** — `plank_deck_qa.py deck.pptx` or `plank_deck_qa.py deck.html` — and it is one tool, not two that agree. The formats differ in exactly two places: which reader pulls the authored geometry out (OOXML, or the `.slides.json` the HTML builder writes) and which renderer produces the PDF (LibreOffice, or headless Chromium). Every detector, threshold, finding and exit code below that is the same code running on both.

**It also produces the PDF**, in `<deck>-qa/` next to the deck. That is a deliverable in its own right — a `.pptx` deck usually has to be handed over as both an editable file and a PDF that looks the same everywhere, and for an HTML deck the PDF **is** the deliverable.

**What it detects.** Text rendering outside the shape it belongs to; text outside the slide's safe margins; words landing on top of other words, including chart labels; shapes that partially overlap; slides that came out blank or rendered invisible; template text like `Lorem ipsum`, `TBD` or `[fill this in]`; pictures whose bytes don't decode or that rendered as an empty frame; **text over a photograph that isn't legible against it**; fonts the deck asks for that aren't installed, so the renderer quietly substituted something else; and **more than two typefaces** — a `too-many-fonts` warning counting every face the deck sets. It names no culprit: nothing in the file says which two were meant, and a brand override replaces both rather than adding a third.

**The contrast check is the one that needs the render.** Nothing in the `.pptx` knows whether a title is readable over its own picture: the file records that the text is `#FAFAF7` and that a photograph is behind it, and says nothing about what colour that photograph happens to be at that spot. So for every word rendered over a picture the tool reads the pixels the renderer actually put behind it, sets aside the ones that are the glyph itself, and computes the WCAG contrast ratio against what's left. Under 3:1 is an `error` — nothing at display size survives less. Between 3:1 and 4.5:1 is a `warning`, because a title clears 3:1 but a lead line or a caption doesn't. The fix is never a bigger type size and never a different photograph; it's a scrim. See [Images](#images).

**Russian typography is checked too — and the same tool checks a document.** Most people reading a Plank deliverable read Russian, and `"прямые кавычки"` where «ёлочки» belong, a hyphen standing in for a dash, and ё left out make a deliverable read as foreign however good its structure is. None of that needs a reader; it is decidable from the bytes. So it is checked rather than suggested: «ёлочки» for Russian quotation marks, ё in words that have only one reading, an em dash between clauses and an en dash for ranges, thousands grouped the Russian way with a decimal comma, and a non-breaking space between a number and its unit in text long enough to wrap.

Because a Russian report or dashboard is a deliverable too, hand the same tool an `.html`, `.md` or `.txt` file and it proofreads the visible text with the same rules, the same report and the same exit status. Nothing is rendered in that mode, so it needs neither LibreOffice nor poppler:

```bash
python3 /workspace/scripts/deck/plank_deck_qa.py квартальный-отчёт.html
python3 /workspace/scripts/deck/plank_deck_qa.py дашборд.html
```

**So `.html` names two jobs, and you don't have to say which.** An HTML *slide deck* is rendered and measured; an HTML *document or dashboard* is proofread. The tool tells them apart by the `.slides.json` manifest `plank_slides.py` writes beside a deck: manifest means deck, none means document — so a deck is never quietly downgraded to a proofread that skips its geometry, and a deck whose manifest went missing fails loudly and asks you to rebuild rather than being read as prose. A deck gets both halves anyway: the Russian rules run on its slides too.

**The rule list is short on purpose.** Flagging correct Russian is worse than missing wrong Russian — a checker that cries wolf gets switched off, and then it catches nothing at all. So anything undecidable in isolation is left out: ё in words with a second reading (все/всё, чем/чём, узнаем/узнаём), a non-breaking space after a one-letter preposition, a missing space before %, a name quoted in «ёлочки» (1C spells its own document types «Счет на оплату покупателю», and that is not yours to correct), and anything inside a code block, a URL or a filename. A document that never writes ё anywhere is a house style, not a mistake, and gets one line in the header instead of a finding on every word.

What it still cannot check is the **writing** — mechanical AI vocabulary, verbal nouns that should be verbs, impersonal passives, empty evaluative adjectives, claims with nothing behind them. That half needs a reader, and it is what the **Deliverable Writing** skill is for.

**What it does not.** It has no opinion on whether the deck is any *good* — hierarchy, pacing, whether the chart is the right chart, whether a slide earns its place. It detects overflow, collision and blankness. It cannot detect ugly. A clean report means nothing is broken; it does not mean the deck is finished, which is what [the critique pass](#the-critique-pass) below is for.

**Findings are not yours to absorb silently.** The tool grades what it finds: an `ERROR` is a broken slide, a `WARNING` is a defect that is probably still readable — a substituted font, a descender a point outside its box. That split is deliberate and is not going to be escalated; if every warning were an error, errors would stop meaning anything.

What the split does **not** license is dropping the warnings. A run that reports "12 slides, 3 clean, 9 with findings" has found nine defects, and "no rendering errors" is a false description of it even though zero of them were errors. Warnings exist so that you can judge them — repair it, or decide it is acceptable and *say which*. See [Before handing it over](#before-handing-it-over) for what has to reach the user.

One thing the numbers depend on, and it differs by format. For a `.pptx` the renderer is LibreOffice, not PowerPoint — line breaking differs slightly, so treat a finding within a point or two of tolerance as a near miss rather than a fact. For an HTML deck there is no such gap: Chromium is the engine the deck was designed against, so the widths in the report are the reader's widths.

An HTML slide clips at its own edge (otherwise slide 3's overrun paints onto slide 4's page), so text that left the slide entirely used to be *absent* from the report rather than reported — the one case where "no findings" could mean "nothing could see it". It is measured now: before printing, the tool asks Chromium where every block's words really landed, and the ones that fall outside the slide come back as ordinary overflow findings, named against the box they came from. The header line says how many blocks were measured and how many words were off the slide. A deck built by an older `plank_slides.py` carries no layout markers and cannot be measured — the report says that too, instead of reporting nothing.

The other thing they depended on used to be left to you: the deck's fonts have to actually be installed, or every width in the report was measured against whatever the renderer substituted. The tool now asks fontconfig and says so — a `font-substituted` warning naming each missing face, on the first slide that asks for it. A clean run means the measurements are against the fonts the deck asked for, not that nobody checked.

## The critique pass

Everything above this point checks that the deck is **well-formed**. None of it has read the deck — see [Writing the slides](#writing-the-slides) for what that misses.

So building a deck is four steps, not three, and the third is not optional:

**build → mechanical QA → critique → revise → deliver**

The critique runs on the finished file, after `plank_deck_qa.py` and before you hand anything over. The tool prints it at the end of every run — including a clean one, because a clean mechanical report is exactly when a deck gets shipped unread. Re-run it after you revise, the same way you re-run the QA.

### The half that is mechanical

Two things come out of the tool, and both are cheap:

- **The title ladder** — every slide's title, in order, printed on its own. This is not a check. It is the titles-alone test made unavoidable: the tool already knows every title, so printing them costs nothing and removes the excuse for not reading them.
- **A few pattern prompts** — a title that matches a filler word (`Overview`, `Обзор`, `Наши преимущества`, `Спасибо`); a title of three words or fewer with no number in it; **the first slide after the cover naming a subject rather than stating the answer** (`answer-not-first`); two slides whose titles share most of their words; a slide carrying a percentage or a multiplier with no source note in sight.

`answer-not-first` is the pyramid rule mechanised as far as it goes. The tool can see that the opening title is a subject; it cannot see that a well-formed opening title states the *wrong* answer. That half is judgement question 1.

The prompts are **heuristics with real false-positive rates**, so they print as questions, not verdicts. They are typed `info`, they never become errors, and they never change the exit status. Overruling one is a perfectly normal answer — overruling it silently is not.

### The half that is judgement

These five cannot be automated, and the tool prints them without answering them. Answer all five, in words, in your reply:

1. **Titles alone.** Read the ladder and nothing else. Does the *first* line already state the answer, or only promise one? Then: does the ladder carry the argument end to end? Name the first title that made you reach for the body to understand it — that one is a subject, not a finding.
2. **Does every slide earn its place?** Name a slide that could be deleted with nothing lost, or say that none can and why. A slide repeating an earlier point counts as deletable.
3. **Is the "so what" stated?** Quote the words *on the deck* that say what the reader should do, and say who owns it. Not implied, not "we should consider" — a decision with an owner, and a date where one applies. If you cannot quote it, this is a report, and a report does not need slides.
4. **What evidence is missing?** Name every number with no source and every assertion with nothing behind it. "None" is valid only if you checked each one. A source that lives only in the speaker notes is worth saying out loud — the audience never sees those.
5. **Look at the rendered PNGs.** `plank_deck_qa.py` rasterises every slide and prints their paths; pass those paths to **`plank_look_at`** (up to six per call) and you see the slides as pictures. That is the only way to see them — `read` on an image file returns base64 you cannot use. Then judge what geometry cannot: does each slide have one clear focal point, or do two things compete? Do slides of the same kind look the same? Does anything read as unfinished — a lonely block of text, a chart with no takeaway, a half-empty card? Is every picture real content rather than placeholder art, and every photograph the right way up? Does any colour contradict the number it sits on — a rise coloured as good when a rise is bad? None of that is measurable, and all of it is visible. Ask those questions by name: in testing, the same slides reviewed with "tell me about any visual problem" surfaced nothing, and the same images reviewed against this list caught both a placeholder image and a delta coloured green on a metric that had got worse. Naming nothing is a valid answer, and a better one than a manufactured finding. The images are downsampled before you see them, so judge layout, contrast and hierarchy rather than the smallest type.

## Before handing it over

Before you consider a deck finished, check it against this list:

- No placeholder text anywhere — no "Lorem ipsum," no "TBD," no bracketed `[fill this in]`.
- Every text budget above is respected. A `DeckError` means split the slide, not shrink the font.
- Every slide has speaker notes. The builder refuses to *build* a slide without them — it validates before it draws anything, so a rejected call adds no slide at all.
- Every chart has a takeaway title and a source note.
- Every image path resolves to a real file, and every slide carrying one was looked at in the rendered PNG with `plank_look_at` — not inferred from the geometry report.
- If a logo is in play, its path resolves to a real file.
- The theme is the one the deck should be in. `studio` for persuasion; `report` for anything that gets printed or scrutinised.
- The file has a descriptive, content-specific name — never `deck.pptx`.
- `plank_deck_qa.py` reports no errors on it, and the PDF it produced is handed over alongside the `.pptx`. See [Checking the deck](#checking-the-deck).
- The [critique pass](#the-critique-pass) has been run on the final file, and its five judgement questions are answered.

### What you have to tell the user

Handing over the files is not the whole delivery. Your reply has to state three things, and they do not stop being required just because every finding was a warning:

1. **What QA found** — the count and the kinds. If that count is not zero, "no rendering errors" / «ошибок рендеринга нет» is not an honest description of the run, however many of the findings were warnings rather than errors.
2. **What you fixed** — and that you re-ran `plank_deck_qa.py` afterwards to confirm it.
3. **What you consciously left, and why** — every finding you did not repair, named, with the reason it is acceptable. Silence is not a judgement, and a finding nobody mentioned is a finding nobody decided about.

Then the five judgement answers from the critique pass. A deck that has been measured but not read has not been checked — it has been rendered.
