# SkillPatch skill: research-deck

Build a conference presentation deck from a paper. Use when a request says "make a presentation", "make slides", "make a deck", "presentation from this paper", "conference talk slides", "paper talk", "slide deck", "16:9 slides", "PowerPoint-size PDF", "slides for KDD/NeurIPS/ICML/ACL/CVPR/CHI", "spotlight talk", "lightning talk", "5 minute talk", "15 minute talk", "turn this paper into a presentation", "I'm presenting this paper", "help me present this at KDD", or supplies a paper PDF/arXiv link/DOI plus a talk length. Also use for revising an existing deck — "add a slide", "cut this to 12 minutes", "change the deck colour", "the slide is overflowing", "the title slide is too dark", "make the deck match our brand". Do NOT use for printed posters (use research-poster), or for writing the paper itself.

You (the agent) were given this URL and asked to use this skill. This is a **public** skill — no SkillPatch account, API key, or prior setup is required. Two ways to use it:

**1. Use it right now** — the skill's complete file tree (SKILL.md + all reference files) is inlined below; read `SKILL.md` first, then follow it, consulting the other files as it directs.

**2. Install the exact package onto disk** (recommended if you can run a shell — this reproduces the skill byte-for-byte, including any binary assets that can't be inlined):

```bash
mkdir -p .claude/skills/research-deck
curl -sSL https://skillpatch.dev/install_skill/research-deck | tar -xz -C .claude/skills/
```

(`.claude/skills/` is Claude Code's convention; use whatever directory your agent loads skills from.)


---

## Skill files (5)

- `SKILL.md`
- `assets/deck-template.html`
- `references/slide-plan.md`
- `scripts/check.py`
- `scripts/palette.py`


### `SKILL.md`

````markdown
---
name: research-deck
description: Build a conference presentation deck from a paper. Use when a request says "make a presentation", "make slides", "make a deck", "presentation from this paper", "conference talk slides", "paper talk", "slide deck", "16:9 slides", "PowerPoint-size PDF", "slides for KDD/NeurIPS/ICML/ACL/CVPR/CHI", "spotlight talk", "lightning talk", "5 minute talk", "15 minute talk", "turn this paper into a presentation", "I'm presenting this paper", "help me present this at KDD", or supplies a paper PDF/arXiv link/DOI plus a talk length. Also use for revising an existing deck — "add a slide", "cut this to 12 minutes", "change the deck colour", "the slide is overflowing", "the title slide is too dark", "make the deck match our brand". Do NOT use for printed posters (use research-poster), or for writing the paper itself.
---

# Research deck

Turn a paper into a conference talk: one HTML file plus a PDF with one slide per page at exact
16:9 dimensions.

HTML rather than a slide editor because the content is derived from a paper — tables, numbers,
diagrams — and it is faster and more precise to lay out declaratively, version with the paper,
and re-render. The PDF opens anywhere and can be presented as-is; the HTML can also be presented
directly from a browser with arrow-key navigation.

## The one thing that goes wrong

Slides are fixed-height with `overflow:hidden`, so an over-full slide does not look broken — the
closing line is simply gone, and the slide reads as slightly bare. Never judge fit by eye; run
`scripts/check.py`, which measures every slide in a real browser and separates *clipped* (content
lost — fix it) from *tight* (padding squeezed, nothing lost — cosmetic).

The usual cause is forcing `height:100%` on a flex column wrapper, which pushes any following
kicker or note off the bottom. The template's `.cols` deliberately omits a height.

## Workflow

### 1. Read the paper completely

Read every page, including appendices — the worked example that anchors the talk is often in one.
If given a URL, fetch it; if a PDF path, read all pages.

Then build a **fact sheet** of every quantity with its table/page number before writing slides.
See `references/slide-plan.md` for the format and the habits that prevent most errors (record
every denominator; mark which numbers you derived rather than read).

### 2. Ask the user — one round, four questions

Ask these together in a single `AskUserQuestion` call, skipping any already answered. These
change the output and cannot be inferred:

1. **Talk length.** Offer 20 / 15 / 12 / 5 minutes (spotlight). This sets the slide count —
   roughly 45 s per slide — and is the single most consequential answer.
2. **Colour theme.** Offer: derive from a logo file they have · a specific brand hex · a named
   preset (deep rose, indigo, teal, forest, slate).
3. **Logos and assets.** Which exist: author/institution logo, venue logo, a QR code image (or
   should one be generated for the repo/DOI).
4. **Venue details.** Conference name, dates, city, session or poster number, repo URL, DOI.
   Anything missing is left as a visible placeholder.

Aspect ratio defaults to 16:9; only ask if the venue is known to require 4:3.

### 3. Derive the palette

```bash
python scripts/palette.py --from-image logo.png      # or --brand "#RRGGBB"
```

Emits a `:root` block and audits every text-on-background pair. Brand colours chosen for logos
are usually too light for text, so `deep` and `mid` are darkened along the hue; **use `brand` for
fills only, never body text.** Contrast has to be computed rather than judged — a mid-tone on a
pale tint can look fine at 4.4:1 and still fail.

For the title slide, projected decks read better with a ground **one step lighter than `deep`**.
Store it as its own `--title-bg` variable rather than editing `--deep`, which drives headings and
kickers on every other slide. Verify with
`python scripts/palette.py --check "#FFFFFF" "#TITLE_BG"` — white text needs ≥ 4.5:1.

### 4. Plan the arc, then write the HTML

`references/slide-plan.md` gives the five-act structure, the 23-slide default, which slides to
cut for each talk length, the per-slide layout budget (how many bullets or table rows actually
fit), and slide-craft rules.

Copy `assets/deck-template.html` and replace the `{{PLACEHOLDERS}}`. It renders as-is, so it can
be checked before content exists. Four points carried in the template:

- **Title each slide with a claim, not a label** — "Depth is the discriminator", not "Results".
- **The eyebrow does the navigation** (`01 · THE PROBLEM`), which removes the need for
  section-divider slides.
- **Land each slide with a kicker** — the dark banner is the sentence said out loud.
- **Logo in every footer**, not just the title slide: slides get screenshotted individually.

Protect the core-contribution slide, the worked example, and the thesis slide at every length —
they are the talk.

### 5. Render and check — iterate until clean

```bash
python scripts/check.py deck.html --expect 338.7x190.5 --pages 23 --selector ".slide" \
                        --raster /tmp/pv --contact-sheet /tmp/sheet.png --cols 4
```

Verifies page size and count, reports clipping per slide, and builds a contact sheet. Exit code
is non-zero while anything is clipped, so it gates the loop.

Review the contact sheet even once it passes — it catches composition problems that no fit check
can: a slide with one lonely bullet, an unreadably dense table, two adjacent slides that look
identical.

### 6. Validate every claim against the paper

Check each number against the fact sheet, and look for the specific failure modes in
`references/slide-plan.md` — overstated qualifiers, editorialised conclusions, mixed
denominators, and paper prose that contradicts its own tables. Where they disagree, follow the
table and tell the user.

### 7. Hand over

State the export recipe (Print → Save as PDF, **Margins: None**, **Background graphics ON** —
without it every fill disappears), the presenting keys (arrows / space / `F` for fullscreen),
and the confirmed page count and geometry.

Then offer the timing plan — which slides to cut for a shorter slot, and which to move after the
resources slide as Q&A backup — plus likely questions with answers drawn from the paper's stated
limitations. Speakers are rarely caught out by their own slides; they are caught out by the
limitations section.

## Files

- `scripts/palette.py` — palette from a brand colour or logo, plus WCAG contrast audit
- `scripts/check.py` — render, assert page geometry, detect clipping, rasterise, contact sheet
- `assets/deck-template.html` — working skeleton with the component set and presenter navigation
- `references/slide-plan.md` — fact sheet, five-act arc, 23-slide default, cuts per talk length, layout budget, slide craft, validation

````


### `assets/deck-template.html`

```
<!DOCTYPE html>
<!--
  DECK TEMPLATE -- copy, then replace every {{PLACEHOLDER}} and add slides.
  Renders correctly as-is, so check.py can be run before any content is written.

  16:9  = 338.7 x 190.5 mm (13.333 x 7.5 in)   <- default
  4:3   = 254   x 190.5 mm (10 x 7.5 in)
  Set @page and .slide to the same values.

  EXPORT (Chrome/Edge): Print > Save as PDF, Margins None, Scale 100, Background graphics ON.
  One slide per page. Without background graphics every fill disappears.

  PRESENT FROM THE BROWSER: Arrow keys / Space / PageUp-Down, Home, End, F for fullscreen.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{SHORT TITLE}}</title>
<style>
@page { size: 338.7mm 190.5mm; margin: 0; }

:root{
  /* slate preset — regenerate with scripts/palette.py --brand "#XXXXXX" */
  --deep:#3B4859; --mid:#607490; --brand:#94A3B8;
  --tint:#F0F2F5; --tint2:#F9F9FB; --line:#EAEDF1;
  --ink:#1A1418; --slate:#5A5560; --green:#17604A; --red:#B3261E;
  /* Title-slide ground: one step lighter than --deep reads as a warmer opening while
     keeping white text above 4.5:1. Verify with: palette.py --check "#FFFFFF" "<this>" */
  --title-bg:#4B5B72;
}
*{ box-sizing:border-box; -webkit-print-color-adjust:exact; print-color-adjust:exact; }
html,body{ margin:0; padding:0; background:#fff; }
body{ font-family:"Inter","Helvetica Neue",Helvetica,Arial,sans-serif; color:var(--ink);
      font-size:19pt; line-height:1.34; }

.slide{ width:338.7mm; height:190.5mm; position:relative; overflow:hidden;
        padding:10mm 14mm 10mm; display:flex; flex-direction:column;
        page-break-after:always; break-after:page; background:#fff; }
.slide:last-child{ page-break-after:auto; break-after:auto; }   /* else a blank final page */

@media screen{ html{ background:#2A2228; } body{ padding:20mm 0; }
  .slide{ margin:0 auto 14mm; box-shadow:0 6px 30px rgba(0,0,0,.45); } }

/* ── slide chrome ── */
.shead{ flex:none; margin-bottom:5mm; }
.eyebrow{ font-size:11.5pt; font-weight:800; letter-spacing:.16em; text-transform:uppercase;
          color:var(--mid); margin-bottom:2mm; }
.slide h2{ font-size:28pt; font-weight:800; line-height:1.08; letter-spacing:-.015em;
           color:var(--deep); margin:0; padding-bottom:2.5mm; border-bottom:1.1mm solid var(--deep); }
.slide h2 em{ font-style:normal; color:var(--ink); }
.body{ flex:1; min-height:0; }
.sfoot{ flex:none; margin-top:4mm; padding-top:2.5mm; border-top:.4mm solid var(--line);
        display:flex; align-items:center; gap:6mm; font-size:10.5pt; color:var(--slate); }
.sfoot img{ height:6.5mm; flex:none; }
.sfoot .fctr{ flex:1; }
.sfoot .fnum{ font-weight:800; color:var(--deep); font-size:13pt; }

/* ── title slide ── */
.slide.title{ background:var(--title-bg); color:#fff; padding:0; display:block; }
.title-inner{ position:absolute; inset:0; padding:18mm 20mm; display:flex; flex-direction:column; }
.title .plaque{ background:#fff; border-radius:3mm; padding:5mm 7mm; height:26mm;
                display:inline-flex; align-items:center; align-self:flex-start; }
.title .plaque img{ max-height:100%; }
.title h1{ font-size:44pt; font-weight:800; line-height:1.08; letter-spacing:-.02em;
           margin:auto 0 0; max-width:250mm; }
.title .auth{ font-size:19pt; font-weight:600; margin-top:8mm; }
.title .venue{ margin-top:7mm; font-size:13pt; font-weight:600; letter-spacing:.03em;
               padding-top:4mm; border-top:.4mm solid rgba(255,255,255,.35); }
.title .tr{ position:absolute; top:18mm; right:20mm; display:flex; align-items:center; gap:6mm; }
.title .tr .vlogo{ background:#fff; border-radius:3mm; padding:3.5mm 4.5mm; height:26mm;
                   display:flex; align-items:center; }
.title .tr .vlogo img{ max-height:100%; }
.title .tr .qr{ text-align:center; }
.title .tr .qr img{ width:26mm; height:26mm; background:#fff; padding:1.5mm; border-radius:1.5mm; display:block; }
.title .tr .qr span{ display:block; font-size:9pt; color:#F8F9FA; margin-top:1.5mm; font-weight:600; }

/* ── components ── */
h3{ font-size:14pt; font-weight:800; color:var(--mid); margin:0 0 3mm;
    text-transform:uppercase; letter-spacing:.08em; }
p{ margin:0 0 4mm; } p:last-child{ margin-bottom:0; }
b.n{ color:var(--deep); font-weight:800; font-variant-numeric:tabular-nums; }
em.i{ font-style:italic; }

ul.bl{ margin:0; padding:0; list-style:none; }
ul.bl li{ position:relative; padding-left:8mm; margin-bottom:4mm; line-height:1.3; }
ul.bl li:last-child{ margin-bottom:0; }
ul.bl li::before{ content:""; position:absolute; left:0; top:2.8mm; width:3.4mm; height:3.4mm;
                  border-radius:50%; background:var(--brand); }
ul.bl.tight li{ margin-bottom:3.2mm; font-size:14.5pt; }

/* Height is deliberately NOT forced to 100%: a fixed height here pushes any sibling that
   follows .cols (a kicker or note) off the bottom of the slide, and overflow:hidden makes
   that silent. This single rule was the cause of most clipped slides in practice. */
.cols{ display:flex; gap:8mm; align-items:stretch; }
.cols > *{ flex:1; min-width:0; }
.cols.g3{ gap:6mm; }

.card{ background:var(--tint2); border:.5mm solid var(--line); border-top:1.6mm solid var(--deep);
       border-radius:2mm; padding:5mm 5mm 5.5mm; }
.card .ic{ width:11mm; height:11mm; border-radius:2mm; background:var(--deep); color:#fff;
           font-size:14pt; font-weight:800; display:flex; align-items:center; justify-content:center; margin-bottom:3.5mm; }
.card h4{ font-size:18pt; font-weight:800; color:var(--deep); margin:0 0 2.5mm; line-height:1.14; }
.card p{ font-size:15pt; line-height:1.28; margin:0; color:var(--ink); }

.stats{ display:flex; gap:6mm; }
.stat{ flex:1; text-align:center; background:var(--tint); border-radius:2mm; padding:6mm 3mm; }
.stat b{ display:block; font-size:40pt; font-weight:800; color:var(--deep); line-height:1; letter-spacing:-.02em; }
.stat span{ display:block; font-size:12pt; color:var(--slate); margin-top:2.5mm;
            text-transform:uppercase; letter-spacing:.06em; font-weight:600; }
.big{ font-size:80pt; font-weight:800; color:var(--deep); line-height:.95; letter-spacing:-.03em; }

.quote{ background:var(--tint); border-left:2.4mm solid var(--mid); border-radius:0 2mm 2mm 0;
        padding:6mm 7mm; font-size:22pt; font-weight:700; font-style:italic; color:var(--deep); line-height:1.22; }
.note{ background:var(--tint2); border:.5mm solid var(--line); border-radius:2mm;
       padding:4mm 5mm; font-size:15pt; line-height:1.28; }
.kick{ background:var(--deep); color:#fff; border-radius:2mm; padding:4mm 5.5mm;
       font-size:17.5pt; font-weight:700; line-height:1.24; }
.kick em{ font-style:italic; }

.stg{ display:flex; gap:4mm; margin-bottom:4.5mm; }
.stg .sn{ flex:none; width:9mm; height:9mm; border-radius:50%; background:var(--deep); color:#fff;
          font-size:14pt; font-weight:800; display:flex; align-items:center; justify-content:center; }
.stg p{ margin:0; font-size:15.5pt; line-height:1.25; } .stg b{ color:var(--deep); }

table{ width:100%; border-collapse:collapse; font-size:16pt; font-variant-numeric:tabular-nums; }
th{ font-size:12pt; font-weight:800; text-transform:uppercase; letter-spacing:.05em; color:var(--deep);
    text-align:right; padding:0 0 2.5mm; border-bottom:.8mm solid var(--deep); }
th:first-child{ text-align:left; }
td{ padding:2.6mm 0; border-bottom:.4mm solid var(--line); text-align:right; }
td:first-child{ text-align:left; font-weight:700; }
tr:last-child td{ border-bottom:0; }
tr.sprd td{ font-style:italic; color:var(--slate); font-size:14pt;
            border-top:.8mm solid var(--deep); border-bottom:0; }
td.hi,th.hi{ background:var(--tint); } td.hi{ font-weight:800; color:var(--deep); }
td.best{ color:var(--deep); font-weight:800; }
table.sm{ font-size:13.5pt; } table.sm th{ font-size:10.5pt; } table.sm td{ padding:2mm 0; }
table.vs td, table.vs th{ text-align:left; } table.vs .miss{ color:var(--red); font-weight:700; }

.bar{ display:flex; align-items:center; gap:4mm; margin-bottom:4mm; }
.bar .lb{ flex:none; width:86mm; font-size:16pt; font-weight:600; }
.bar .tk{ flex:1; height:9mm; background:var(--tint2); border:.4mm solid var(--line);
          border-radius:1mm; position:relative; }
.bar .tk i{ position:absolute; top:0; height:100%; border-radius:.8mm; }
.bar .vv{ flex:none; width:38mm; text-align:right; font-size:17pt; font-weight:800; font-variant-numeric:tabular-nums; }
.bar.ok .tk i{ background:#9CCCB8; } .bar.ok .vv{ color:var(--green); }
.bar.bad .tk i{ background:var(--mid); } .bar.bad .vv{ color:var(--deep); }
.grp{ font-size:12pt; font-weight:800; color:var(--slate); letter-spacing:.05em; margin:0 0 2.5mm; }
.grp span{ font-weight:600; letter-spacing:0; }

.dir{ display:flex; gap:6mm; }
.dir > div{ flex:1; border-radius:2mm; padding:5mm 5.5mm; font-size:15.5pt; line-height:1.26; }
.dir .up{ background:#E4F1EB; border:.5mm solid #9CCCB8; }
.dir .dn{ background:#FBE4E1; border:.5mm solid #E8B4AE; }
.dir h4{ margin:0 0 2.5mm; font-size:17pt; font-weight:800; }
.dir .up h4{ color:var(--green); } .dir .dn h4{ color:var(--red); }
</style>
</head>
<body>

<!-- ═══════════ 1 · TITLE ═══════════ -->
<section class="slide title">
  <div class="title-inner">
    <div class="plaque"><img src="{{AUTHOR_LOGO}}" alt=""></div>
    <div class="tr">
      <div class="vlogo"><img src="{{VENUE_LOGO}}" alt=""></div>
      <div class="qr"><img src="{{QR}}" alt=""><span>{{Code + data}}</span></div>
    </div>
    <h1>{{PAPER TITLE}}</h1>
    <div class="auth">{{Author One · Author Two · Author Three}}</div>
    <div class="venue">{{Full venue}} &nbsp;·&nbsp; {{dates}} &nbsp;·&nbsp; {{city}}</div>
  </div>
</section>

<!-- ═══════════ 2 · content slide pattern ═══════════ -->
<section class="slide">
  <div class="shead">
    <div class="eyebrow">{{01 · SECTION NAME}}</div>
    <h2>{{Slide title as a claim, not a label}}</h2>
  </div>
  <div class="body">
    <div class="cols">
      <div><ul class="bl"><li>{{…}}</li><li>{{…}}</li></ul></div>
      <div><div class="quote">{{a pull quote or the key number}}</div></div>
    </div>
    <div class="kick" style="margin-top:6mm">{{the one sentence to land}}</div>
  </div>
  <div class="sfoot"><img src="{{AUTHOR_LOGO}}" alt=""><span class="fctr">{{Short title · venue · poster #}}</span><span class="fnum">2</span></div>
</section>

</body>
<script>
/* screen-only navigation; irrelevant to print */
(function(){
  var s = Array.prototype.slice.call(document.querySelectorAll('.slide')), i = 0;
  function go(n){ i = Math.max(0, Math.min(s.length - 1, n));
                  s[i].scrollIntoView({behavior:'smooth', block:'center'}); }
  document.addEventListener('keydown', function(e){
    if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') { e.preventDefault(); go(i+1); }
    else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); go(i-1); }
    else if (e.key === 'Home') { e.preventDefault(); go(0); }
    else if (e.key === 'End') { e.preventDefault(); go(s.length-1); }
    else if (e.key === 'f' || e.key === 'F') {
      if (document.fullscreenElement) document.exitFullscreen();
      else document.documentElement.requestFullscreen && document.documentElement.requestFullscreen();
    }
  });
})();
</script>
</html>

```


### `references/slide-plan.md`

````markdown
# Turning a paper into a talk

1. [The fact sheet](#the-fact-sheet-do-this-first)
2. [The five-act arc](#the-five-act-arc)
3. [Fitting the slot](#fitting-the-slot)
4. [Slide craft](#slide-craft)
5. [Layout budget](#layout-budget)
6. [Claim validation](#claim-validation)

## The fact sheet (do this first)

Extract every quantity from the paper into a scratch file with its table or page number before
writing any slides. A number recalled from memory halfway through drafting is how errors get in.

```
FACT SHEET
headline quantities : 198 items · 11 categories · 5 systems · 4 metrics · 7 failure types
Table 3 (p.8817)    : metric definitions, verbatim
Table 4 (p.8817)    : 5 systems × 4 metrics — full grid
Table 7 (p.8819)    : exact/derived/fabricated %, sums to ~100 per row
Table 9 (p.8820)    : proportion of RESPONSES per category   <- note the denominator
derived by me       : depth spread 8.35-7.25 = 1.10 ; relevance 9.58-9.17 = 0.41
```

Two habits prevent most errors:

- **Record the denominator with every percentage.** "% of items" and "% of trials" look identical
  on a slide and cannot share an axis. Label the group whenever both appear.
- **Mark which numbers you computed** (ranges, spreads, min/max) versus which are printed in the
  paper. Derived numbers are often the most interesting thing in the talk — they just need to be
  right, and you need to know which is which when questioned.

## The five-act arc

A paper talk is not the paper in order. Related work compresses to one slide; the contribution
gets the middle; the results build to one sentence.

| Act | Slides | Job |
|---|---|---|
| **1 · The problem** | 4 | Establish stakes, then make the difficulty concrete before any method |
| **2 · The approach** | 4 | What you built — data, system, one architecture figure |
| **3 · How we evaluate** | 3 | Metrics *before* results, or the numbers mean nothing |
| **4 · What we found** | 7 | Results building to the core contribution and its validation |
| **5 · Implications** | 4 | The thesis, mitigations, takeaways, resources |

A 23-slide default (title + 22):

```
 1  Title
 2  The setting is already deployed, and we don't know how it fails
 3  N challenges, all at once            (cards)
 4  What one instance actually requires  (concrete example)
 5  The evaluation gap                   (positioning table: line of work | measures | misses)
 6  Contributions                        (stat row + numbered list — the roadmap)
 7  Building the dataset/benchmark       (funnel + design principles)
 8  What's in it                         (distribution chart)
 9  The system                           (one left-to-right figure)
10  Evaluation: the two tracks           (why both are needed)
11  Track A — metric definitions         (table)
12  Track B — the other pipeline         (numbered stages + labels)
13  Result: what already works           (table + "so what's wrong?")
14  Result: what discriminates           (table + spread row)
15  The problem with the obvious metric  (motivates the contribution)
16  ★ CORE CONTRIBUTION                  (the taxonomy / architecture / theorem)
17  The worked example                   (the one case people remember)
18  Why it's practical                   (the validation number, big)
19  Per-condition detail                 (the dense table)
20  The thesis                           (the one sentence, as a slide)
21  Mitigations and their limits
22  Takeaways                            (5 bullets, all numeric)
23  Future work + resources + thank you  (QR)
```

**Slide 15 → 16 is the hinge.** Give the audience a reason to want the contribution before
presenting it: show the obvious approach failing, then the contribution answers it. A
contribution presented without that setup lands as a list of categories.

## Fitting the slot

Roughly **45 seconds per slide** for a paper talk, so 23 slides ≈ 17 minutes.

| Slot | Slides | Cut |
|---|---|---|
| 20 min | 23 | — |
| 15 min | 20 | the positioning table; fold metric definitions into the overview slide |
| 12 min | 16 | also the per-condition detail and one problem-framing slide |
| 5 min (spotlight) | 6 | title · problem · contribution · headline result · thesis · resources |
| 90 s (teaser) | 3 | title · contribution · one result |

**Protect the core contribution, the worked example, and the thesis slide** at every length.
They are the talk. Everything else is supporting material.

Rather than deleting slides, prefer moving them after the resources slide as backup — they are
then available for Q&A without being in the linear path.

## Slide craft

- **Title each slide with a claim, not a label.** "Depth is the discriminator" tells the audience
  what to conclude; "Results" makes them work it out.
- **One idea per slide.** If a slide needs two headings, it is two slides.
- **The eyebrow does the navigation.** A small `01 · THE PROBLEM` label costs one line and
  removes the need for section-divider slides.
- **Land each slide with a kicker.** The dark banner at the bottom is the sentence you say out
  loud. It also stops the slide reading as a pile of facts.
- **Repeat the key number.** A quantity that appears on three slides gets remembered; one that
  appears once does not.
- **Put the logo in every footer**, not just the title slide — slides get screenshotted
  individually.

## Layout budget

At 16:9 (338.7 × 190.5 mm) with 10 mm top / 14 mm side / 10 mm bottom padding:

| Zone | Height |
|---|---|
| Header (eyebrow + rule) | ~27 mm with a one-line title, ~38 mm with two |
| **Body** | **~130 mm** (one-line title) / ~120 mm (two-line) |
| Footer | ~13 mm |

Practical capacities for the body:

- Bulleted list at 19 pt: **6 bullets** of two lines each
- Table: **7 rows** at 16 pt, or 9 rows with `class="sm"`
- Two-column cards plus a closing kicker: comfortable
- Full-width figure plus a caption: figure ≤ 100 mm tall

**The single most common failure**: forcing a fixed height on a flex column row (`height:100%`
on the `.cols` wrapper). It pushes any following kicker or note off the bottom, and because
`overflow:hidden` clips rather than reflows, the slide just looks slightly bare. The template's
`.cols` deliberately omits a height for this reason. `scripts/check.py` catches it either way.

## Claim validation

Same discipline as the poster: verify every number against the fact sheet, and watch for
overstated qualifiers, editorialised conclusions, mixed denominators, and prose in the paper
that contradicts its own tables. Where paper prose and a paper table disagree, follow the
**table** and tell the author.

Offer a **timing plan** (which slides to cut for a shorter slot) and **likely questions with
answers** drawn from the paper's stated limitations. The speaker is rarely caught out by their
own slides — they are caught out by the limitations section.

````


### `scripts/check.py`

```
#!/usr/bin/env python3
"""Render an HTML poster/deck to PDF and report the three failures that actually bite.

Visual inspection misses content that is clipped by `overflow:hidden` -- the block simply
looks a bit empty. This finds it by running the page in a real browser and comparing each
container's scrollHeight to its clientHeight, so overflow is reported as a number instead
of being spotted (or not) by eye.

Usage
  python check.py page.html                                  # render + audit
  python check.py page.html --expect 1000x850 --pages 1      # assert geometry
  python check.py page.html --selector ".slide" --pages 23
  python check.py page.html --raster out/ --dpi 60           # also write PNGs
  python check.py page.html --contact-sheet sheet.png        # grid of all pages

Exit code is 1 if any assertion fails or any container overflows, so it can gate a loop.
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile

CHROME_CANDIDATES = [
    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
    "/Applications/Chromium.app/Contents/MacOS/Chromium",
    "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
    "google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge",
]

# Two different conditions look identical to a naive scrollHeight check, so the audit
# separates them by comparing the overflow against the container's own bottom padding:
#
#   overflow <= padding-bottom  ->  TIGHT.   The designed breathing room is being eaten,
#                                   but no glyph is lost. Cosmetic; often fine.
#   overflow >  padding-bottom  ->  CLIPPED. Real content is cut off by overflow:hidden,
#                                   and because it is clipped rather than reflowed, the
#                                   block just looks slightly empty. This is the one that
#                                   silently ships broken posters and decks.
AUDIT_JS = """
<script id="__audit__">
(function(){
  var sel = %s, PX = 3.779528;   // CSS px per mm
  var els = document.querySelectorAll(sel), out = [];
  for (var i = 0; i < els.length; i++) {
    var el = els[i];
    var dv = el.scrollHeight - el.clientHeight;
    var dh = el.scrollWidth  - el.clientWidth;
    if (dv <= 1 && dh <= 2) continue;
    var pb = parseFloat(getComputedStyle(el).paddingBottom) || 0;
    var label = el.id || (el.className && String(el.className).trim().split(/[ ]+/).join('.')) || el.tagName;
    out.push({
      n: i + 1, label: label,
      v: Math.round(dv / PX * 10) / 10,
      h: Math.round(dh / PX * 10) / 10,
      pad: Math.round(pb / PX * 10) / 10,
      clipped: dv > pb + 1
    });
  }
  document.title = '__AUDIT__' + JSON.stringify({count: els.length, over: out});
})();
</script>
"""


def find_chrome():
    for c in CHROME_CANDIDATES:
        if os.path.sep in c:
            if os.path.exists(c):
                return c
        elif shutil.which(c):
            return shutil.which(c)
    sys.exit("No Chrome/Chromium/Edge found. Install one, or pass --chrome <path>.")


def run(cmd, **kw):
    return subprocess.run(cmd, capture_output=True, text=True, **kw)


def audit_overflow(chrome, html_path, selector):
    """Copy the HTML with an audit script appended, load it, read results from <title>.

    Works on any HTML without the file needing to cooperate: --dump-dom executes scripts
    and prints the resulting DOM, so a title we set from JS comes back to us.
    """
    src = open(html_path, encoding="utf-8").read()
    js = AUDIT_JS % json.dumps(selector)
    # Splice by index rather than re.sub: the JS contains regex escapes like \s+, which
    # Python would try to interpret as escapes in a replacement template.
    m = re.search(r"</body\s*>", src, re.I)
    patched = (src[:m.start()] + js + src[m.start():]) if m else (src + js)
    d = tempfile.mkdtemp()
    # Keep the copy beside the original so relative asset paths (logos, QR codes) resolve.
    tmp = os.path.join(os.path.dirname(os.path.abspath(html_path)), ".__check_tmp__.html")
    open(tmp, "w", encoding="utf-8").write(patched)
    try:
        r = run([chrome, "--headless", "--disable-gpu", "--no-sandbox", "--virtual-time-budget=8000",
                 "--dump-dom", "file://" + os.path.abspath(tmp)])
        m = re.search(r"__AUDIT__(\{.*?\})</title>", r.stdout, re.S)
        if not m:
            return None
        return json.loads(m.group(1))
    finally:
        for p in (tmp,):
            if os.path.exists(p):
                os.remove(p)
        shutil.rmtree(d, ignore_errors=True)


def pdf_geometry(pdf):
    r = run(["pdfinfo", pdf])
    if r.returncode != 0:
        return None, None
    pages = size = None
    for line in r.stdout.splitlines():
        if line.startswith("Pages:"):
            pages = int(line.split()[1])
        elif line.startswith("Page size:"):
            nums = re.findall(r"[\d.]+", line)
            if len(nums) >= 2:
                size = (float(nums[0]) / 72 * 25.4, float(nums[1]) / 72 * 25.4)  # pt -> mm
    return pages, size


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("html")
    ap.add_argument("--pdf", help="output PDF path (default: alongside the HTML)")
    ap.add_argument("--selector", default=".slide, .blk, .page",
                    help="containers to audit for overflow")
    ap.add_argument("--expect", help="expected page size WxH in mm, e.g. 1000x850")
    ap.add_argument("--pages", type=int, help="expected page count")
    ap.add_argument("--tol", type=float, default=1.0, help="page-size tolerance in mm")
    ap.add_argument("--raster", help="directory to write page PNGs into")
    ap.add_argument("--dpi", type=int, default=60)
    ap.add_argument("--contact-sheet", help="write a grid image of all pages here")
    ap.add_argument("--cols", type=int, default=4, help="columns in the contact sheet")
    ap.add_argument("--chrome", help="explicit browser binary")
    a = ap.parse_args()

    chrome = a.chrome or find_chrome()
    html = os.path.abspath(a.html)
    pdf = os.path.abspath(a.pdf) if a.pdf else os.path.splitext(html)[0] + ".pdf"
    fails = []

    r = run([chrome, "--headless", "--disable-gpu", "--no-sandbox", "--no-pdf-header-footer",
             "--virtual-time-budget=8000", "--print-to-pdf=" + pdf, "file://" + html])
    if not os.path.exists(pdf):
        sys.exit("Render failed.\n" + r.stderr[-2000:])
    print("rendered  %s  (%.0f KB)" % (os.path.basename(pdf), os.path.getsize(pdf) / 1024))

    pages, size = pdf_geometry(pdf)
    if pages is not None:
        print("pages     %d" % pages)
        if a.pages and pages != a.pages:
            fails.append("expected %d pages, got %d" % (a.pages, pages))
    if size:
        print("page size %.1f x %.1f mm" % size)
        if a.expect:
            try:
                ew, eh = (float(v) for v in a.expect.lower().split("x"))
                if abs(size[0] - ew) > a.tol or abs(size[1] - eh) > a.tol:
                    fails.append("expected %gx%g mm, got %.1fx%.1f mm" % (ew, eh, size[0], size[1]))
            except ValueError:
                fails.append("could not parse --expect %r (use WxH, e.g. 1000x850)" % a.expect)

    audit = audit_overflow(chrome, html, a.selector)
    if audit is None:
        print("overflow  (audit did not report -- check the --selector matches something)")
    else:
        print("audited   %d container(s) matching %r" % (audit["count"], a.selector))
        if audit["count"] == 0:
            fails.append("selector %r matched nothing -- pass the right --selector" % a.selector)
        clipped = [o for o in audit["over"] if o["clipped"]]
        tight = [o for o in audit["over"] if not o["clipped"]]
        if clipped:
            print("\nCLIPPED -- real content is cut off (fix these):")
            for o in clipped:
                print("  #%-3d %-38s %.1fmm past a %.1fmm bottom pad%s"
                      % (o["n"], o["label"][:38], o["v"], o["pad"],
                         ("  +%.1fmm too wide" % o["h"]) if o["h"] > 0 else ""))
            fails.append("%d container(s) clipped" % len(clipped))
        if tight:
            print("\nTIGHT -- bottom padding squeezed, no content lost (cosmetic):")
            for o in tight:
                print("  #%-3d %-38s %.1fmm into a %.1fmm bottom pad%s"
                      % (o["n"], o["label"][:38], o["v"], o["pad"],
                         ("  +%.1fmm too wide" % o["h"]) if o["h"] > 0 else ""))
        if not audit["over"]:
            print("overflow  none")

    if a.raster or a.contact_sheet:
        outdir = a.raster or tempfile.mkdtemp()
        os.makedirs(outdir, exist_ok=True)
        if shutil.which("pdftoppm"):
            run(["pdftoppm", "-png", "-r", str(a.dpi), pdf, os.path.join(outdir, "p")])
            pngs = sorted(f for f in os.listdir(outdir) if f.endswith(".png"))
            print("raster    %d PNG(s) in %s" % (len(pngs), outdir))
            if a.contact_sheet and pngs:
                try:
                    from PIL import Image
                    ims = [Image.open(os.path.join(outdir, f)) for f in pngs]
                    w, h = ims[0].size
                    cols = min(a.cols, len(ims))
                    rows = (len(ims) + cols - 1) // cols
                    g = 14
                    sheet = Image.new("RGB", (w * cols + g * (cols + 1), h * rows + g * (rows + 1)),
                                      (60, 50, 58))
                    for k, im in enumerate(ims):
                        rr, cc = divmod(k, cols)
                        sheet.paste(im, (g + cc * (w + g), g + rr * (h + g)))
                    sheet.thumbnail((1800, 1800))
                    sheet.save(a.contact_sheet)
                    print("sheet     %s" % a.contact_sheet)
                except ImportError:
                    print("sheet     skipped (pip install pillow)")
        else:
            print("raster    skipped (no pdftoppm; install poppler)")

    print()
    if fails:
        for f in fails:
            print("FAIL: " + f)
        sys.exit(1)
    print("OK")


if __name__ == "__main__":
    main()

```


### `scripts/palette.py`

```
#!/usr/bin/env python3
"""Derive a poster/deck palette from one brand colour, and check WCAG contrast.

Two jobs that are easy to get wrong by eye:

1. Brand colours are usually chosen for logos on white, so they are often too light to
   carry heading or body text. Deriving a darkened "deep" and "mid" from the brand hue
   gives text-safe anchors while keeping the identity.
2. Contrast has to be computed, not judged. A mid-tone rose on a pale tint can look
   perfectly readable and still sit at 4.4:1 -- under the 4.5:1 threshold for body text.

Usage
  python palette.py --brand "#FD698D"            # derive a full palette + audit it
  python palette.py --from-image logo.png        # sample the brand colour first
  python palette.py --check "#1E7A5A" "#DCEFE7"  # one pair, contrast + verdict
"""
import argparse
import colorsys
import sys


def hex2rgb(h):
    h = h.strip().lstrip("#")
    if len(h) == 3:
        h = "".join(c * 2 for c in h)
    if len(h) != 6:
        sys.exit("Bad hex colour: %r (expected #RRGGBB)" % h)
    return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))


def rgb2hex(rgb):
    return "#%02X%02X%02X" % tuple(max(0, min(255, int(round(c)))) for c in rgb)


def _lin(c):
    c /= 255.0
    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4


def luminance(rgb):
    r, g, b = (_lin(c) for c in rgb)
    return 0.2126 * r + 0.7152 * g + 0.0722 * b


def contrast(fg, bg):
    """WCAG 2.x contrast ratio between two hex colours."""
    a, b = luminance(hex2rgb(fg)), luminance(hex2rgb(bg))
    hi, lo = max(a, b), min(a, b)
    return (hi + 0.05) / (lo + 0.05)


def adjust(hex_colour, dl=0.0, ds=0.0):
    """Shift lightness / saturation in HLS, keeping hue -- stays on-brand."""
    r, g, b = (c / 255.0 for c in hex2rgb(hex_colour))
    h, l, s = colorsys.rgb_to_hls(r, g, b)
    l = max(0.0, min(1.0, l + dl))
    s = max(0.0, min(1.0, s + ds))
    return rgb2hex([c * 255 for c in colorsys.hls_to_rgb(h, l, s)])


def mix(hex_a, hex_b, t):
    """Blend toward hex_b by fraction t. Used for tints, because additive lightness
    clamps to pure white when the brand colour is already light."""
    a, b = hex2rgb(hex_a), hex2rgb(hex_b)
    return rgb2hex([a[i] + (b[i] - a[i]) * t for i in range(3)])


def darken_to_contrast(hex_colour, bg="#FFFFFF", target=7.0):
    """Darken along the brand hue until it reaches `target` contrast on `bg`.

    Saturation is eased down slightly as lightness drops: holding full saturation while
    darkening a vivid brand hue produces a harsh, neon result that reads as a different
    brand. Bleeding a little saturation keeps the darkened anchor recognisable.
    """
    out = hex_colour
    for _ in range(120):
        if contrast(out, bg) >= target:
            return out
        nxt = adjust(out, dl=-0.01, ds=-0.006)
        if nxt == out:
            break
        out = nxt
    return out


def derive(brand):
    """Full palette. `deep` carries headings, `mid` sub-heads, brand is fills only."""
    return {
        "deep":   darken_to_contrast(brand, "#FFFFFF", 7.0),  # headings, bands, badges
        "mid":    darken_to_contrast(brand, "#FFFFFF", 4.6),  # sub-heads, rules, bars
        "brand":  brand.upper(),                              # fills, dots -- NOT text
        "tint":   mix(brand, "#FFFFFF", 0.86),                # callouts, table zebra
        "tint2":  mix(brand, "#FFFFFF", 0.94),                # faintest panel ground
        "ink":    "#1A1418",
        "slate":  "#5A5560",
        "line":   mix(adjust(brand, ds=-0.62), "#FFFFFF", 0.74),   # hairline borders
        "green":  "#17604A",
        "red":    "#B3261E",
    }


def audit(p):
    """Check every pair the templates actually use for text."""
    pairs = [
        ("deep on white",   p["deep"],  "#FFFFFF", 4.5),
        ("mid on white",    p["mid"],   "#FFFFFF", 4.5),
        ("ink on white",    p["ink"],   "#FFFFFF", 4.5),
        ("slate on white",  p["slate"], "#FFFFFF", 4.5),
        ("deep on tint",    p["deep"],  p["tint"], 4.5),
        ("ink on tint2",    p["ink"],   p["tint2"], 4.5),
        ("white on deep",   "#FFFFFF",  p["deep"], 4.5),
        ("white on mid",    "#FFFFFF",  p["mid"],  4.5),
        ("green on white",  p["green"], "#FFFFFF", 4.5),
        ("red on white",    p["red"],   "#FFFFFF", 4.5),
        ("brand on white (fills only -- expected to fail for text)",
         p["brand"], "#FFFFFF", 4.5),
    ]
    print("\ncontrast audit")
    worst = None
    for name, fg, bg, need in pairs:
        r = contrast(fg, bg)
        ok = r >= need
        tag = "ok " if ok else "LOW"
        print("  %s %-58s %5.2f:1" % (tag, name, r))
        if "fills only" not in name and (worst is None or r < worst[1]):
            worst = (name, r)
    if worst:
        print("\n  lowest text pair: %s at %.2f:1" % worst)
    print("\n  Body text needs 4.5:1; text >=18pt regular or >=14pt bold needs 3:1.")
    print("  Never set body text in `brand` -- it is a fill colour.")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--brand", help="brand hex, e.g. '#FD698D'")
    ap.add_argument("--from-image", help="sample the dominant non-transparent colour from a PNG")
    ap.add_argument("--check", nargs=2, metavar=("FG", "BG"), help="contrast for one pair")
    ap.add_argument("--css", action="store_true", help="emit only the CSS :root block")
    a = ap.parse_args()

    if a.check:
        r = contrast(*a.check)
        print("%s on %s -> %.2f:1  (%s body, %s large)"
              % (a.check[0], a.check[1], r,
                 "PASS" if r >= 4.5 else "FAIL", "PASS" if r >= 3.0 else "FAIL"))
        return

    brand = a.brand
    if a.from_image:
        try:
            from PIL import Image
            from collections import Counter
            im = Image.open(a.from_image).convert("RGBA")
            c = Counter(px[:3] for px in im.getdata() if px[3] > 200 and px[:3] != (255, 255, 255))
            if not c:
                sys.exit("No opaque non-white pixels found in %s" % a.from_image)
            brand = rgb2hex(c.most_common(1)[0][0])
            print("sampled brand colour from %s -> %s" % (a.from_image, brand))
        except ImportError:
            sys.exit("Sampling needs Pillow (pip install pillow), or pass --brand directly.")

    if not brand:
        sys.exit("Pass --brand '#RRGGBB', --from-image logo.png, or --check FG BG.")

    p = derive(brand)
    print("\n:root{")
    for k, v in p.items():
        print("  --%-6s %s;" % (k + ":", v))
    print("}")
    if not a.css:
        audit(p)


if __name__ == "__main__":
    main()

```
