# SkillPatch skill: research-poster

Build a conference research poster from a paper. Use when a request says "make a poster", "conference poster", "poster from this paper", "academic poster", "research poster", "print-ready poster", "poster PDF", "A0 poster", "A1 poster", "36x48 poster", "poster for KDD/NeurIPS/ICML/ACL/CVPR/CHI", "poster session", "my poster board is NxM", "turn this paper into a poster", or supplies a paper PDF/arXiv link/DOI plus board or print dimensions. Also use for revising an existing poster — "make the poster bigger/smaller", "fit my board", "change the poster colour", "the text is too small", "make the poster match our brand", "validate the poster against the paper". Do NOT use for slide decks or talks (use research-deck), 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-poster
curl -sSL https://skillpatch.dev/install_skill/research-poster | tar -xz -C .claude/skills/
```

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


---

## Skill files (7)

- `SKILL.md`
- `assets/poster-template.html`
- `references/content.md`
- `references/layout.md`
- `scripts/check.py`
- `scripts/layout.py`
- `scripts/palette.py`


### `SKILL.md`

````markdown
---
name: research-poster
description: Build a conference research poster from a paper. Use when a request says "make a poster", "conference poster", "poster from this paper", "academic poster", "research poster", "print-ready poster", "poster PDF", "A0 poster", "A1 poster", "36x48 poster", "poster for KDD/NeurIPS/ICML/ACL/CVPR/CHI", "poster session", "my poster board is NxM", "turn this paper into a poster", or supplies a paper PDF/arXiv link/DOI plus board or print dimensions. Also use for revising an existing poster — "make the poster bigger/smaller", "fit my board", "change the poster colour", "the text is too small", "make the poster match our brand", "validate the poster against the paper". Do NOT use for slide decks or talks (use research-deck), or for writing the paper itself.
---

# Research poster

Turn a paper into a print-ready poster: one HTML file plus a PDF at exact physical dimensions.

HTML is the right tool here because poster geometry is arithmetic — millimetre column widths,
row heights that must sum to the page — and CSS with `mm` units plus `@page` gives exact control
that a slide editor does not. Chrome renders it to a PDF whose page box is the physical sheet.

## The one thing that goes wrong

Content is clipped by `overflow:hidden`, so an over-full block does not look broken — it looks
slightly empty, and prints that way. Never judge fit by eye; run `scripts/check.py`, which
measures every block in a real browser and separates *clipped* (content lost — fix it) from
*tight* (padding squeezed, nothing lost — cosmetic).

## Workflow

### 1. Read the paper completely

Read every page, including appendices — the best concrete example for the poster is usually in
an appendix. 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 any
markup. See `references/content.md` for the format and the two habits that prevent most errors
(record every denominator; mark which numbers you derived).

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

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

1. **Print size.** Offer: the paper's venue norm (A0 portrait) · a stated board slot ("I'll give
   dimensions") · a standard alternative (A1, 36×48 in). If they give a board slot, run
   `scripts/layout.py --slot WxH` and use its suggestion.
2. **Colour theme.** Offer: derive from a logo file they have · a specific brand hex · a named
   preset (deep rose, indigo, teal, forest, slate). Mention that a logo yields the most
   on-brand result.
3. **Logos and assets.** Which of these exist: author/institution logo, venue logo, a QR code
   image (or should one be generated for the repo/DOI).
4. **Venue details.** Full conference name, dates, city, poster/session number, DOI, repo URL,
   licence — whatever they have. Note anything missing will be left as a visible placeholder.

If the user has already supplied something (a logo in the folder, a stated size), use it and do
not ask again. Prefer acting on what is present over interrogating.

### 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. Two things it enforces that are
easy to get wrong: brand colours chosen for logos are usually too light for text (so `deep` and
`mid` are darkened along the hue), and contrast must be computed rather than judged — a mid-tone
on a pale tint can look fine at 4.4:1 and fail. **Use `brand` for fills, bars and dots only,
never for body text.**

To check any pair later: `python scripts/palette.py --check "#FG" "#BG"`.

### 4. Solve the geometry

```bash
python scripts/layout.py --page 1000x850 --margin 22 --gutter 10 --cols 12 \
                         --header 126 --footer 30 --rows 170,108,136,186
```

Prints the column width, every span width to use verbatim in CSS, the reading measure per span
(which spans take prose vs tables only), and whether the vertical schedule sums exactly to the
page. Use `--rows auto:N` to have it distribute the remaining height.

Do not skip this and estimate. A schedule that overshoots by a few millimetres clips the last
row silently.

### 5. Plan the blocks, then write the HTML

Nine numbered blocks is the default. `references/content.md` gives what belongs in each, the
word budget per block, and what to cut; `references/layout.md` gives block plans per aspect
ratio, the type scale with its floors, and how to rebuild paper figures that will not survive
enlargement.

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

- **Numbered badges carry the reading order.** A grid without them has no obvious path.
- **The core contribution belongs in a full-width band at the optical middle** — the most-looked-at
  region, with room to render a taxonomy as cards or a wide diagram rather than a cramped column.
- **Put a visible border around the whole poster.** Boards are often shared; without it the
  poster blends into a neighbour's.

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

```bash
python scripts/check.py poster.html --expect 1000x850 --pages 1 --selector ".blk" \
                        --raster /tmp/pv --contact-sheet /tmp/sheet.png
```

Verifies page size and count, reports clipping per block, and can rasterise for a look. Exit
code is non-zero while anything is clipped, so it gates the loop.

When a block is clipped, prefer **cutting words** over shrinking type — the type floors in
`references/layout.md` exist because body text below 17 pt is not read at poster distance. Then
view the contact sheet to check the composition reads, not just that it fits.

### 7. Validate every claim against the paper

Walk each block and check each number against the fact sheet. `references/content.md` lists the
specific failure modes to look for — overstated qualifiers, editorialised conclusions, mixed
denominators, and paper prose that contradicts its own tables. Where they disagree, follow the
table and tell the user which prose conflicts, so they are not ambushed at the board.

Report plainly: what was verified, what was corrected, what remains uncertain.

### 8. Hand over

State the export recipe (Print → Save as PDF, **Margins: None**, **Background graphics ON** —
without it every fill disappears), confirm the verified page geometry, and flag any asset below
150 dpi at final size (`pdfimages -list poster.pdf`).

Then offer the booth material in `references/content.md`: a 20-second hook, a badge-by-badge
walkthrough, and likely hard questions with answers.

## Files

- `scripts/layout.py` — grid solver: sheet size from a board slot, span widths, reading measure, vertical schedule
- `scripts/palette.py` — palette from a brand colour or logo, plus WCAG contrast audit
- `scripts/check.py` — render, assert page geometry, detect clipping, rasterise
- `assets/poster-template.html` — working skeleton with the component set
- `references/layout.md` — sizes, type scale, block plans, figure rebuilds, print and travel
- `references/content.md` — fact sheet, per-block content and word budget, cuts, validation, booth prep

````


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

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

  PAGE SIZE: set @page and .sheet from scripts/layout.py output. They must agree.

  EXPORT (Chrome/Edge): Print > Save as PDF, Margins None, Scale 100, Background graphics ON.
  Without background graphics every fill disappears and the poster prints white.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{SHORT TITLE}}</title>
<style>
/* ═══════════════ PAGE — from layout.py ═══════════════ */
@page { size: 841mm 1189mm; 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;

  --gut:10mm;
  --w3:190.25mm;    /* 3 cols — prose: ~49 chars at 22pt */
  --w4:257mm;       /* 4 cols — prose: ~66 chars. Wider than this: no running prose */
  --w12:791mm;      /* full-width bands */
}
*{ box-sizing:border-box; -webkit-print-color-adjust:exact; print-color-adjust:exact; }
html,body{ margin:0; padding:0; }
body{
  width:841mm; height:1189mm; padding:25mm;
  background:#fff; color:var(--ink);
  font-family:"Inter","Helvetica Neue",Helvetica,Arial,sans-serif;
  font-size:22pt; line-height:1.34; overflow:hidden;
  border:2mm solid var(--deep);   /* separates this poster from a neighbour on a shared board */
}
/* screen-only preview; @media screen never applies to print media */
@media screen{
  html{ background:#EDEBEE; height:480mm; overflow:hidden; }
  body{ transform:scale(.40); transform-origin:top left; }
}

.sheet{ width:var(--w12); height:1139mm; display:flex; flex-direction:column; gap:var(--gut); }
.row{ display:flex; gap:var(--gut); }

/* ═══════════════ HEADER ═══════════════ */
.header{ height:150mm; background:var(--deep); color:#fff; display:flex; flex-direction:column; overflow:hidden; }
.head-main{ flex:1; display:flex; align-items:center; padding:0 12mm; gap:var(--gut); }
.plaque{ background:#fff; border-radius:3mm; display:flex; align-items:center; justify-content:center;
         padding:6mm 8mm; flex:none; height:56mm; width:154mm; }
.plaque img{ max-width:100%; max-height:100%; }
/* Shim balances a wider right-hand group so the centred middle lands on the true page centre.
   Recompute as: (right group width) - (left plaque width). */
.plaque{ margin-right:0mm; }  /* recompute if the right group is wider than the plaque */
.head-mid{ flex:1; min-width:0; text-align:center; }
.title{ font-size:56pt; font-weight:800; line-height:1.06; letter-spacing:-.01em; margin:0; color:#fff; }
.authors{ font-size:23pt; font-weight:600; margin-top:5.5mm; }
.venue-chip{ display:inline-block; margin-top:4mm; font-size:15.5pt; font-weight:600;
  background:rgba(255,255,255,.15); border:.4mm solid rgba(255,255,255,.45);
  border-radius:2mm; padding:1.4mm 4mm; letter-spacing:.02em; }
.head-right{ display:flex; align-items:center; gap:9mm; flex:none; }
.venue-logo{ width:118mm; height:56mm; object-fit:contain; background:#fff; border-radius:3mm; padding:5mm 6mm; }
.qr{ text-align:center; }
.qr img{ width:44mm; height:44mm; display:block; background:#fff; padding:2mm; border-radius:1.5mm; }
.qr span{ display:block; font-size:13pt; font-weight:600; margin-top:1.8mm; color:#F8F9FA; }

/* number strip — the paper's headline quantities, always in view */
.numstrip{ height:41mm; display:flex; align-items:center; border-top:.6mm solid rgba(255,255,255,.28);
           background:rgba(0,0,0,.14); }
.numstrip div{ flex:1; text-align:center; border-right:.4mm solid rgba(255,255,255,.20); }
.numstrip div:last-child{ border-right:0; }
.numstrip b{ display:block; font-size:37pt; font-weight:800; line-height:1; letter-spacing:-.02em; }
.numstrip span{ display:block; font-size:14pt; color:#F4F6F8; margin-top:1.6mm; letter-spacing:.04em; text-transform:uppercase; }

/* ═══════════════ BLOCKS ═══════════════ */
.blk{ position:relative; background:#fff; border:.6mm solid var(--line);
      border-top:2.6mm solid var(--deep); border-radius:2mm; padding:6.5mm 8mm 7mm; overflow:hidden; }
.blk.w3{ width:var(--w3); } .blk.w4{ width:var(--w4); } .blk.band{ width:var(--w12); }
/* Badges carry the reading order. Without them a grid has no obvious path. */
.badge{ position:absolute; top:-2.6mm; right:6mm; width:52mm; height:52mm; border-radius:50%;
        background:var(--deep); color:#fff; border:1.2mm solid #fff;
        font-size:42pt; font-weight:800; display:flex; align-items:center; justify-content:center; }
h2{ font-size:46pt; font-weight:800; color:var(--deep); margin:0 24mm 4mm 0; line-height:1.08; letter-spacing:-.01em; }
h2 small{ display:block; font-size:22pt; font-weight:600; color:var(--slate); margin-top:1.2mm; }
h3{ font-size:20pt; font-weight:800; color:var(--mid); margin:0 0 2.2mm; text-transform:uppercase; letter-spacing:.07em; }
.sp{ margin-top:4mm; }

/* ═══════════════ COMPONENTS ═══════════════ */
ul{ margin:0; padding:0; list-style:none; }
.tk li{ font-size:22pt; line-height:1.3; margin-bottom:3.4mm; padding-left:7mm; position:relative; }
.tk li:last-child{ margin-bottom:0; }
.tk li::before{ content:""; position:absolute; left:0; top:2.6mm; width:3.6mm; height:3.6mm;
                border-radius:50%; background:var(--brand); }
b.n{ color:var(--deep); font-weight:800; font-variant-numeric:tabular-nums; }

.callout{ background:var(--tint); border-left:2.2mm solid var(--mid); border-radius:0 1.5mm 1.5mm 0;
          padding:3.2mm 4mm; font-size:14.5pt; line-height:1.28; }
.kicker{ margin-top:auto; background:var(--deep); color:#fff; border-radius:1.8mm;
         padding:3.4mm 4mm; font-size:15pt; font-weight:700; line-height:1.24; }
.note{ font-size:12.5pt; color:var(--slate); line-height:1.24; margin-top:2.5mm;
       padding-top:2.2mm; border-top:.5mm dashed var(--brand); }

/* funnel: pipeline stage counts */
.fn{ display:flex; align-items:center; gap:3mm; min-height:7mm; }
.fn i{ display:block; height:4.6mm; background:var(--mid); border-radius:.8mm; flex:none; }
.fn .v{ flex:none; width:20mm; font-size:14.5pt; font-weight:800; color:var(--deep); font-variant-numeric:tabular-nums; }
.fn .t{ flex:1; min-width:0; font-size:12.5pt; line-height:1.15; color:var(--slate); }
.fn.last i{ background:var(--deep); } .fn.last .t{ color:var(--deep); font-weight:700; }

/* horizontal category bars — replace any tiny rotated-label bar chart from the paper */
.cat{ display:flex; align-items:center; gap:2.5mm; height:5.2mm; font-size:12.5pt; }
.cat .lbl{ width:88mm; flex:none; white-space:nowrap; overflow:hidden; }
.cat .track{ flex:1; height:3.4mm; background:var(--tint); border-radius:.6mm; }
.cat .track i{ display:block; height:100%; background:var(--brand); border-radius:.6mm; }
.cat .c{ width:9mm; flex:none; text-align:right; font-weight:700; color:var(--deep); font-variant-numeric:tabular-nums; }
.cat.top .track i{ background:var(--mid); }

/* taxonomy / category cards for a full-width band */
.cards{ display:flex; gap:8px; height:150mm; }
.card{ flex:1; height:100%; background:#fff; border:.6mm solid var(--line); border-radius:1.8mm;
       padding:3.6mm 4.5mm 4mm; display:flex; flex-direction:column; }
.card.hot{ border-color:var(--mid); border-width:.9mm; }
.card .hd{ display:flex; align-items:center; gap:3mm; min-height:11mm; margin-bottom:2mm; }
.card .num{ flex:none; width:9.5mm; height:9.5mm; border-radius:50%; background:var(--tint); color:var(--deep);
            font-size:15pt; font-weight:800; display:flex; align-items:center; justify-content:center; }
.card.hot .num{ background:var(--mid); color:#fff; }
.card .nm{ font-size:15pt; font-weight:800; line-height:1.13; }
.card.hot .nm{ color:var(--deep); }
.card .ds{ font-size:13pt; line-height:1.24; color:var(--slate); }
/* optional per-card worked example: the real query, then what went wrong. flex:1 pins the
   number and bar to the card's bottom edge so they line up across the whole band. */
.card .eg{ flex:1; margin-top:1.8mm; padding-top:1.8mm; border-top:.4mm dashed var(--line); }
.card .eg .q{ font-size:12pt; line-height:1.2; font-style:italic; font-weight:600; color:var(--deep); }
.card .eg .of{ font-size:12pt; line-height:1.22; margin-top:1.3mm; }
.card .eg .of i{ font-style:normal; font-weight:800; color:var(--slate); }
/* flex:none on both is load-bearing. In an over-full column flexbox, `height` is only a basis
   and the default flex-shrink:1 lets it compress — the bar collapses to zero height and simply
   vanishes, while .ds (flex:1, min-height:auto) refuses to shrink below its text. The card then
   looks fine and its bar is gone. Keep the unit inline (no display:block) to save a whole row. */
.card .rng{ flex:none; display:flex; align-items:baseline; gap:2mm; margin-top:2mm;
            font-size:19pt; font-weight:800; color:var(--deep); font-variant-numeric:tabular-nums; }
.card .rng small{ font-size:11.5pt; font-weight:600; color:var(--slate); letter-spacing:.04em; }
.card .track{ flex:none; height:3.6mm; background:var(--tint); border-radius:.6mm; margin-top:1.6mm; }
.card .track i{ display:block; height:100%; background:var(--brand); border-radius:.6mm; }
.card.hot .track i{ background:var(--mid); }

/* tables */
table{ width:100%; border-collapse:collapse; font-size:14pt; font-variant-numeric:tabular-nums; }
th{ font-size:12pt; font-weight:800; text-transform:uppercase; letter-spacing:.04em; color:var(--deep);
    text-align:right; padding:0 0 1.6mm; border-bottom:.7mm solid var(--deep); }
th:first-child{ text-align:left; }
td{ padding:1.4mm 0; border-bottom:.4mm solid var(--line); text-align:right; }
td:first-child{ text-align:left; font-weight:600; }
tr:last-child td{ border-bottom:0; }
tr.sprd td{ font-style:italic; color:var(--slate); font-size:12.5pt;
            border-top:.7mm solid var(--deep); border-bottom:0; padding-top:1.6mm; }
/* .hi marks the column that carries the argument; .best marks the winner in every OTHER
   column where higher is better. Keep that rule consistent or the emphasis reads as random. */
td.hi,th.hi{ background:var(--tint); } td.hi{ font-weight:800; color:var(--deep); }
td.best{ color:var(--deep); font-weight:800; }

/* comparison bars. Label the denominator when two groups differ (claims vs responses),
   otherwise a shared axis implies a like-for-like comparison that may not hold. */
.grp{ font-size:11.5pt; font-weight:800; color:var(--slate); letter-spacing:.03em; margin:0 0 1.5mm; }
.grp span{ font-weight:600; letter-spacing:0; }
.cmp{ display:flex; align-items:center; gap:3mm; margin-bottom:3mm; }
.cmp .lbl{ width:85mm; flex:none; font-size:13.5pt; line-height:1.14; font-weight:600; }
.cmp .track{ flex:1; height:8mm; background:var(--tint2); border:.4mm solid var(--line);
             border-radius:.8mm; position:relative; }
.cmp .track i{ position:absolute; top:0; height:100%; border-radius:.6mm; }
.cmp .rn{ width:35mm; flex:none; text-align:right; font-size:14.5pt; font-weight:800; font-variant-numeric:tabular-nums; }
.cmp.ok .track i{ background:#9CCCB8; } .cmp.ok .rn{ color:var(--green); }
.cmp.bad .track i{ background:var(--mid); } .cmp.bad .rn{ color:var(--deep); }

/* ═══════════════ FOOTER ═══════════════ */
.footer{ height:45mm; background:var(--deep); color:#fff; border-radius:2mm;
         padding:3mm 10mm; display:flex; flex-direction:column; }
.foot-row{ flex:1; display:flex; align-items:center; }
.foot-row > div{ font-size:14pt; line-height:1.22; }
.f-l{ width:250mm; } .f-c{ flex:1; text-align:center; font-weight:600; } .f-r{ width:250mm; text-align:right; }
.legal{ border-top:.4mm solid rgba(255,255,255,.3); padding-top:1.4mm; text-align:center;
        font-size:11.5pt; color:#F4F6F8; letter-spacing:.03em; }
</style>
</head>
<body>
<div class="sheet">

<header class="header">
  <div class="head-main">
    <div class="plaque"><img src="{{AUTHOR_LOGO}}" alt=""></div>
    <div class="head-mid">
      <h1 class="title">{{PAPER TITLE}}</h1>
      <div class="authors">{{Author One · Author Two · Author Three}}</div>
      <div class="venue-chip">{{VENUE}} &nbsp;·&nbsp; {{CITY}} &nbsp;·&nbsp; {{DATES}} &nbsp;·&nbsp; Poster #{{NUM}}</div>
    </div>
    <div class="head-right">
      <img src="{{VENUE_LOGO}}" alt="" class="venue-logo">
      <div class="qr"><img src="{{QR}}" alt=""><span>{{Code + data}}</span></div>
    </div>
  </div>
  <div class="numstrip">
    <div><b>{{N1}}</b><span>{{label}}</span></div>
    <div><b>{{N2}}</b><span>{{label}}</span></div>
    <div><b>{{N3}}</b><span>{{label}}</span></div>
    <div><b>{{N4}}</b><span>{{label}}</span></div>
    <div><b>{{N5}}</b><span>{{label}}</span></div>
    <div><b>{{N6}}</b><span>{{label}}</span></div>
  </div>
</header>

<!-- ROW 1 — four prose blocks at the ~69-char measure -->
<div class="row" style="height:223.5mm">
  <section class="blk w3"><div class="badge">1</div>
    <h2>Takeaways</h2>
    <ul class="tk">
      <li>{{Every bullet carries a number: <b class="n">NN%</b> …}}</li>
      <li>{{…}}</li>
    </ul>
  </section>
  <section class="blk w3"><div class="badge">2</div>
    <h2>The problem<small>{{one-line subtitle}}</small></h2>
    <p>{{…}}</p>
    <div class="callout">{{a concrete example query or case}}</div>
  </section>
  <section class="blk w3"><div class="badge">3</div>
    <h2>Method / data</h2>
    <div class="fn"><i style="width:78mm"></i><span class="v">{{raw}}</span><span class="t">{{stage}}</span></div>
    <div class="fn last"><i style="width:17mm"></i><span class="v">{{N}}</span><span class="t">{{final stage}} ★</span></div>
  </section>
  <section class="blk w3"><div class="badge">4</div>
    <h2>What we measure</h2>
    <h3>{{metric family}}</h3>
    <p>{{…}}</p>
  </section>
</div>

<!-- ROW 2 — full-width band for the system/pipeline figure (inline SVG, drawn left-to-right) -->
<section class="blk band" style="height:223.5mm"><div class="badge">5</div>
  <h2 style="font-size:26pt;margin:0 0 2.5mm">{{Pipeline / architecture}}</h2>
  <!-- Inline SVG with viewBox units == mm so coordinates read as physical sizes.
       Paint order matters: draw a rect BEFORE any text that sits on top of it, or the
       fill hides the label. -->
  <svg width="773mm" height="150mm" viewBox="0 0 773 150" xmlns="http://www.w3.org/2000/svg" style="display:block">
    <defs><marker id="ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="#3B4859"/></marker></defs>
    <!-- {{stages}} -->
  </svg>
</section>

<!-- ROW 3 — full-width band for the core contribution (taxonomy / categories as cards) -->
<section class="blk band" style="height:223.5mm"><div class="badge">6</div>
  <h2 style="margin:0 0 4mm">{{Core contribution}}</h2>
  <div class="cards">
    <div class="card hot"><div class="hd"><div class="num">1</div><div class="nm">{{Name}}</div></div>
      <div class="ds">{{one-sentence description}}</div>
      <div class="eg"><div class="q">&ldquo;{{the real query}}&rdquo;</div>
        <div class="of"><i>Observed failure &mdash;</i> {{what actually went wrong}}</div></div>
      <div class="rng">{{lo–hi%}}<small>{{of what}}</small></div>
      <div class="track"><i style="width:90%"></i></div></div>
    <div class="card"><div class="hd"><div class="num">2</div><div class="nm">{{Name}}</div></div>
      <div class="ds">{{…}}</div>
      <div class="eg"><div class="q">&ldquo;{{…}}&rdquo;</div>
        <div class="of"><i>Observed failure &mdash;</i> {{…}}</div></div>
      <div class="rng">{{lo–hi%}}<small>{{of what}}</small></div>
      <div class="track"><i style="width:30%"></i></div></div>
  </div>
</section>

<!-- ROW 4 — results. No running prose at this width: tables, charts, one-line bullets. -->
<div class="row" style="height:223.5mm">
  <section class="blk w4" style="display:flex;flex-direction:column"><div class="badge">7</div>
    <h2>{{Headline result}}</h2>
    <table>
      <tr><th>{{Model}}</th><th>{{Metric}}</th><th class="hi">{{Key metric}}</th></tr>
      <tr><td>{{…}}</td><td>{{…}}</td><td class="hi">{{…}}</td></tr>
    </table>
    <div class="kicker">{{the sentence you want repeated back to you}}</div>
  </section>
  <section class="blk w4"><div class="badge">8</div>
    <h2>{{Comparison}}<small>{{units, n}}</small></h2>
    <table><tr><th>{{…}}</th><th class="hi">{{…}}</th></tr><tr><td>{{…}}</td><td class="hi">{{…}}</td></tr></table>
  </section>
  <section class="blk w4"><div class="badge">9</div>
    <h2>{{Limits &amp; future work}}</h2>
    <ul class="tk"><li>{{…}}</li></ul>
  </section>
</div>

<footer class="footer">
  <div class="foot-row">
    <div class="f-l"><b>{{repo url}}</b><br>{{group url}}</div>
    <div class="f-c">{{Full venue name}}<br>{{dates}} &nbsp;·&nbsp; {{city}} &nbsp;·&nbsp; {{pages}}</div>
    <div class="f-r"><b>{{doi}}</b><br>Poster #{{NUM}}</div>
  </div>
  <div class="legal">{{ISBN}} &nbsp;·&nbsp; {{copyright}} &nbsp;·&nbsp; {{licence}}</div>
</footer>

</div>
</body>
</html>

```


### `references/content.md`

````markdown
# Turning a paper into nine blocks

1. [The fact sheet](#the-fact-sheet-do-this-first)
2. [What goes in each block](#what-goes-in-each-block)
3. [What to cut](#what-to-cut)
4. [Claim validation](#claim-validation)
5. [Booth preparation](#booth-preparation)

## The fact sheet (do this first)

Before writing any HTML, extract every quantity from the paper into a scratch file with its
table or page number. The poster's credibility rests entirely on these, and a number
transcribed from memory halfway through drafting is how errors get in.

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

Two habits that prevent the most common errors:

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

## What goes in each block

Total body text across all nine blocks: **≈ 850 words at A0, ≈ 650 for a smaller sheet.** This
is far less than feels natural. Enforce it by cutting, not by shrinking type.

| # | Block | Content | Words |
|---|---|---|---|
| ① | **Takeaways** | 4–5 bullets, **every one containing a number**. The only block most visitors read. | ~90 |
| ② | **The problem** | Why the setting is hard — 2–4 named challenges, plus one concrete example, plus a one-line statement of the gap. | ~85 |
| ③ | **Method / data** | Construction or setup. A funnel if there are stage counts; a category chart if there is a distribution. | ~75 |
| ④ | **What we measure** | Metric definitions verbatim from the paper, and how they are computed. Readers need this *before* the results. | ~70 |
| ⑤ | **System / pipeline** | One left-to-right figure. Almost pure diagram. | ~40 |
| ⑥ | **Core contribution** ★ | The paper's actual novelty, full-width, given the most room. Plus one worked example and one validation number. | ~130 |
| ⑦ | **Headline result** | The single most important comparison, with the sentence you want repeated back. | ~60 |
| ⑧ | **Detail / per-condition** | Secondary results table plus one-line verdicts per row. | ~70 |
| ⑨ | **Limits & future work** | Interventions or extensions, honest limitations. | ~60 |

Plus an unnumbered **at-a-glance strip** of 5–6 large numerals in the header — the quantities
that define the work, readable from 3 m.

**Write block titles as claims, not labels.** "Facts are fine. Framing isn't." stops people;
"Results" does not. The exception is method blocks, where a plain label aids scanning.

## What to cut

| Cut | Why |
|---|---|
| Related work | Compress the entire section to one bold sentence naming the gap, in ② |
| The bibliography | A poster carries zero citations; the QR carries all of them |
| Most appendix examples | Keep **one**, at full strength. It becomes what people repeat |
| Full metric-definition tables | Fold into ④ as inline definitions |
| Any figure that needs a caption to be understood | Redraw it or drop it |
| Hedging and repetition | Say each thing once |

Expect the first draft to run 2–3× the word budget. Cut to fit rather than reducing type size —
below the type floors the poster stops working at distance.

## Claim validation

After the poster renders, verify every claim against the paper before declaring it done. Walk
each block and check each number against the fact sheet, then look specifically for these,
which are the failure modes that actually occur:

- **Overstated qualifiers.** Writing "fully-cited table" where the paper says "a table sourced
  from multiple tools" invents a property. Match the paper's hedging.
- **Editorialised conclusions.** "X is solved" when the paper says "X stays below 6%". Prefer
  the paper's own summary sentence — it is usually both safer and sharper.
- **Denominator mixing.** Two rates on one axis with different bases.
- **Arithmetic you performed.** Recompute spreads, ranges, min/max.
- **Prose that contradicts a table.** Papers do contain these. Follow the **table**, and tell
  the author which prose disagrees so they are not ambushed at the board.

Report the result plainly: what was verified, what was corrected, and anything in the paper
itself that looks inconsistent.

## Booth preparation

Offer these once the poster is built — they cost little and are what the presenter actually
needs on the day:

- **A 20-second hook.** One spoken paragraph: setting, the surprising finding, the number.
- **A badge-by-badge 3-minute walk**, naming which blocks to skip.
- **Five likely hard questions with answers**, drawn from the paper's real limitations —
  sample size, single-run results, judge reliability, generalisation beyond the domain,
  anything the paper itself flags as future work. Presenters are rarely ambushed by the
  poster; they are ambushed by the limitations.

````


### `references/layout.md`

````markdown
# Poster layout reference

1. [Choosing the sheet size](#choosing-the-sheet-size)
2. [Type scale](#type-scale)
3. [Block plans by aspect ratio](#block-plans-by-aspect-ratio)
4. [Figure rules](#figure-rules)
5. [Print and travel](#print-and-travel)

## Choosing the sheet size

Run `scripts/layout.py --slot WxH --inset 33` and use what it suggests. Two rules behind it:

- **Print a little smaller than the allotted space.** Visible clearance on each side reads as
  deliberate; a poster butted to the edges reads as a mistake, and boards are rarely square to
  the millimetre.
- **Mount flush to the top of the slot, not centred.** Poster boards sit on stands, so the
  bottom band ends up below comfortable reading height. Give away the dead space at the bottom.

Common cases:

| Situation | Sheet |
|---|---|
| No constraint given | A0 portrait, 841 × 1189 mm — fits nearly every board |
| A stated board slot | `layout.py --slot` output |
| Shop wants a standard size | A0 841 × 1189, A1 594 × 841, B1 707 × 1000 |
| Slot is near-square | Landscape at ~1.15–1.2:1 — see the four-column plan below |

**1000 mm is a standard roll width**, so a custom height on a 1000 mm roll usually costs the
same as a standard size. Ask.

## Type scale

Anchored to A0 viewed at 1–2 m. For a smaller sheet, scale by the ratio of the sheet's long
edge to 1189 mm, then round — but never below the floors.

| Element | A0 | Floor |
|---|---|---|
| Title | 76 pt bold | 56 pt |
| Authors | 30 pt · affiliations 22 pt | 20 pt |
| Section title | 46 pt bold | 28 pt |
| Badge numeral | 42 pt in a 52 mm circle | 22 pt / 19 mm |
| Body | 22 pt | **17 pt — hard floor** |
| Table body | 20 pt | 14 pt |
| Caption / footer | 18 pt | 11 pt |

Two things matter more than the exact numbers:

- **Body text below 17 pt stops being read at poster distance.** If it doesn't fit, cut words.
- **Line length beats font size.** `layout.py` prints the character count per span. Prose
  belongs in spans measuring 45–75 characters; past ~95 characters use tables, charts, or
  one-line bullets only. A 4-column block at A0 measures ~93 characters — too long for
  paragraphs, fine for a results table.

## Block plans by aspect ratio

Nine numbered blocks is a good default: enough to cover a paper, few enough to stay scannable.
Badges carry the reading order, so the grid does not have to be strictly linear.

**Portrait (0.71:1, e.g. A0).** Four columns for prose rows, two full-width bands:

```
HEADER (logos · title · authors · QR) + number strip
① takeaways | ② problem | ③ method | ④ metrics   (4 × 3 cols = 190mm each)
⑤ PIPELINE — full-width band, drawn left-to-right
⑥ CORE CONTRIBUTION — full-width band            ★
⑦ result (4 cols) | ⑧ comparison (4) | ⑨ future (4)
FOOTER
```

At A0 a 3-col span (190 mm) measures ~49 characters at 22 pt and a 4-col span (257 mm) ~66 —
both ideal for prose. **Avoid putting prose in a span wider than 4 columns**: an 8-col block at
A0 is 524 mm ≈ 135 characters, which the eye cannot track back. If a wide block must carry
prose, split it into internal sub-columns.

**Landscape / near-square (1.15–1.25:1).** Four equal columns with two full-width bands as a
horizontal spine — this is what makes landscape pay off:

```
HEADER + number strip
① takeaways | ② problem | ③ method | ④ metrics     (4 × 3 cols)
⑤ PIPELINE — full-width band, drawn left-to-right
⑥ CORE CONTRIBUTION — full-width band              ★
⑦ result (4) | ⑧ comparison (4) | ⑨ future (4)
FOOTER
```

**Where the core contribution goes.** Whatever the paper's headline contribution is — a
taxonomy, an architecture, a theorem, a dataset — put it in a **full-width band at the
optical middle**. That is the most-looked-at region, and a band gives room to render it as
7-ish cards or a wide diagram rather than a cramped column.

## Figure rules

Paper figures are drawn for a single column at 300 dpi and almost never survive enlargement.
Assume each needs rebuilding:

| Paper figure | Poster treatment |
|---|---|
| Bar chart with rotated tick labels | Rebuild as **horizontal** bars, values labelled at the ends, no axis |
| Heatmap in viridis/jet | Recolour to a single-hue ramp monotonic in lightness, **print every cell value** |
| Vertical flow diagram | Redraw **horizontally** for a landscape band; inline SVG with `viewBox` units = mm |
| Multi-stage table (pipeline counts) | Convert to a tapering funnel — the numbers become the graphic |
| Grouped bars of signed deltas | Emphasise the zero line, direct-label values, add `+`/`−` glyphs so it does not rely on hue |
| Dense table (5 × 7 or larger) | Keep as a table, or small-multiple bars; highlight the column carrying the argument |

**Inline SVG beats a raster.** Set `viewBox="0 0 W H"` with W/H in mm and give the element
`width:Wmm`, so coordinates read as physical millimetres and font sizes are `mm × 2.835 = pt`.

Two SVG traps that cost real debugging time:

- **Paint order is document order.** A `<text>` written before a `<rect>` that overlaps it is
  hidden by the rect's fill. Draw containers first, labels after.
- **A reused paper PNG is usually too coarse.** Check with
  `pdfimages -list out.pdf` — anything under 150 dpi at final size will print soft. Re-export
  as vector, or regenerate at ≥ 300 dpi.

## Two flexbox traps that erase content without clipping

`check.py` catches a block whose content exceeds the block. It cannot catch either of these,
because in both cases the content still *fits* — it has just been silently destroyed or displaced
inside a fixed-height child. Both are worth knowing by shape:

- **A fixed-height item in an over-full column flexbox shrinks to nothing.** `height:3.6mm` on a
  bar or rule is only a *flex basis*; the default `flex-shrink:1` compresses it, while a sibling
  with `flex:1` and `min-height:auto` refuses to shrink below its text. The bar collapses to zero
  height and disappears while the card looks perfectly normal. Put `flex:none` on every fixed-size
  item in a column flexbox — bars, tracks, rules, badges, footers.
- **`overflow` is visible on a fixed-height child, so its overrun paints over the next element.**
  A card sized `height:100%` inside a fixed-height `.cards` row does not clip; it bleeds into
  whatever follows, and the parent block reports no overflow. Measure each child directly (compare
  its `scrollHeight` to its box) rather than trusting the block-level pass.

Symptom worth recognising: if one item in a row sits a millimetre or two lower than its
neighbours, that item is over-full and pushing past its padding box — not misaligned.

## Print and travel

- Export **PDF/X-1a** if the shop asks; otherwise plain PDF with fonts embedded is fine.
  Verify with `pdffonts out.pdf` — every face should say `yes` under `emb`.
- Keep content ≥ 25 mm from the trim edge; add 3 mm bleed only if the shop trims.
- **Matte fabric over paper** for air travel: it folds into a carry-on, where a tube gets
  gate-checked. Paper needs the tube.
- **Proof at 30–35 % on A3** and read it from 1 m. Anything you squint at is too small. Do this
  at least five days out.
- Bring pins **and** velcro **and** binder clips; supplied hardware runs out.
- RGB is normally fine — most shops convert. Ask whether they want CMYK.

````


### `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/layout.py`

```
#!/usr/bin/env python3
"""Solve poster grid geometry so the numbers in the CSS are exact.

Poster layouts fail in a specific, boring way: the row heights are eyeballed, they sum to
more than the available height, and the last row is quietly clipped by `overflow:hidden`.
This computes the arithmetic once so the CSS can be written with confidence, and reports
the residual so a mismatch is visible before anything is rendered.

Usage
  # size the sheet for a board slot, then solve the grid
  python layout.py --slot 1067x914 --inset 33

  # full solve: 12 columns, header/footer, four content rows
  python layout.py --page 1000x850 --margin 22 --gutter 10 --cols 12 \
                   --header 126 --footer 30 --rows 170,108,136,186

  # let it distribute the remaining height across 4 rows
  python layout.py --page 1000x850 --margin 22 --gutter 10 --cols 12 \
                   --header 126 --footer 30 --rows auto:4
"""
import argparse
import sys


def wh(s, what):
    try:
        w, h = (float(v) for v in s.lower().replace("mm", "").split("x"))
        return w, h
    except ValueError:
        sys.exit("Could not parse %s %r -- use WxH in mm, e.g. 1000x850" % (what, s))


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--slot", help="board space available, WxH mm -- suggests a sheet size")
    ap.add_argument("--inset", type=float, default=33,
                    help="clearance to leave on each side inside --slot (mm)")
    ap.add_argument("--page", help="sheet size WxH mm")
    ap.add_argument("--margin", type=float, default=22)
    ap.add_argument("--gutter", type=float, default=10)
    ap.add_argument("--cols", type=int, default=12)
    ap.add_argument("--header", type=float, default=0)
    ap.add_argument("--footer", type=float, default=0)
    ap.add_argument("--rows", help="comma-separated row heights in mm, or auto:N")
    ap.add_argument("--body-pt", type=float, default=19, help="body text size, for the measure table")
    a = ap.parse_args()

    if a.slot:
        sw, sh = wh(a.slot, "--slot")
        pw, ph = sw - 2 * a.inset, sh - 2 * a.inset
        # Round down to a tidy 10mm multiple: print shops quote round sizes, and it keeps
        # the grid arithmetic clean.
        rw, rh = int(pw // 10) * 10, int(ph // 10) * 10
        print("slot            %g x %g mm  (%.2f x %.2f in)" % (sw, sh, sw / 25.4, sh / 25.4))
        print("suggested sheet %g x %g mm  (%.2f x %.2f in)" % (rw, rh, rw / 25.4, rh / 25.4))
        print("clearance       %.1f mm each side, %.1f mm top and bottom"
              % ((sw - rw) / 2, (sh - rh) / 2))
        print("aspect          %.3f:1  (%s)"
              % (rw / rh, "landscape" if rw > rh else "portrait" if rh > rw else "square"))
        print("\nMount flush to the TOP of the slot, not centred: on a short stand-mounted")
        print("board the bottom edge sits below comfortable reading height.")
        if not a.page:
            print("\nRe-run with --page %gx%g to solve the grid." % (rw, rh))
            return
    if not a.page:
        sys.exit("Pass --page WxH (and optionally --slot to size it first).")

    pw, ph = wh(a.page, "--page")
    live_w = pw - 2 * a.margin
    live_h = ph - 2 * a.margin
    col = (live_w - (a.cols - 1) * a.gutter) / a.cols

    print("\npage            %g x %g mm" % (pw, ph))
    print("live area       %.1f x %.1f mm  (margin %g)" % (live_w, live_h, a.margin))
    print("column          %.2f mm   (%d cols, %g gutter)" % (col, a.cols, a.gutter))

    print("\nspan widths -- use these verbatim in the CSS")
    for n in range(1, a.cols + 1):
        w = n * col + (n - 1) * a.gutter
        note = ""
        if n == a.cols:
            note = "  full width"
        elif abs(w - live_w / 2) < 0.6:
            note = "  half"
        elif abs(w - live_w / 3) < 0.6:
            note = "  third"
        elif abs(w - live_w / 4) < 0.6:
            note = "  quarter"
        print("  %2d cols  %8.2f mm%s" % (n, w, note))

    # Average character advance for a humanist sans is ~0.5em, so mm/char = pt * 0.5 / 2.835.
    # 45-75 characters is the comfortable measure; past ~90 the eye loses the line return.
    adv = a.body_pt * 0.5 / 2.835
    print("\nreading measure at %gpt body (~%.2f mm/char, aim 45-75 chars)" % (a.body_pt, adv))
    for n in range(1, a.cols + 1):
        w = n * col + (n - 1) * a.gutter
        chars = w / adv
        if chars < 28:
            continue
        if chars < 45:
            verdict = "tight but readable"
        elif chars <= 75:
            verdict = "ideal -- prose belongs here"
        elif chars <= 95:
            verdict = "long -- keep to bullets"
        else:
            verdict = "too long -- tables/figures only, no running prose"
        print("  %2d cols  %8.2f mm  ~%3.0f chars  %s" % (n, w, chars, verdict))

    if not a.rows:
        print("\nAdd --rows to solve the vertical schedule.")
        return

    n_rows = None
    if a.rows.startswith("auto:"):
        n_rows = int(a.rows.split(":")[1])
        rows = None
    else:
        rows = [float(v) for v in a.rows.split(",")]
        n_rows = len(rows)

    blocks = [("header", a.header)] if a.header else []
    blocks += [("row %d" % (i + 1), None) for i in range(n_rows)]
    if a.footer:
        blocks += [("footer", a.footer)]
    n_gaps = len(blocks) - 1
    fixed = a.header + a.footer
    avail = live_h - fixed - n_gaps * a.gutter

    if rows is None:
        each = avail / n_rows
        rows = [round(each, 1)] * n_rows
        rows[-1] = round(avail - sum(rows[:-1]), 1)   # absorb rounding in the last row
        print("\ndistributing %.1f mm across %d rows" % (avail, n_rows))

    print("\nvertical schedule")
    total = a.margin
    print("  %-10s %7.1f mm" % ("top margin", a.margin))
    if a.header:
        print("  %-10s %7.1f mm" % ("header", a.header))
        total += a.header
    for i, r in enumerate(rows):
        total += a.gutter
        print("  %-10s %7.1f mm" % ("gap", a.gutter))
        print("  %-10s %7.1f mm" % ("row %d" % (i + 1), r))
        total += r
    if a.footer:
        total += a.gutter
        print("  %-10s %7.1f mm" % ("gap", a.gutter))
        print("  %-10s %7.1f mm" % ("footer", a.footer))
        total += a.footer
    total += a.margin
    print("  %-10s %7.1f mm" % ("bottom mgn", a.margin))
    print("  %s" % ("-" * 20))
    print("  %-10s %7.1f mm   (page is %g)" % ("TOTAL", total, ph))

    resid = ph - total
    print()
    if abs(resid) < 0.15:
        print("EXACT -- the schedule fills the page.")
    elif resid > 0:
        print("%.1f mm SLACK -- add it to the row carrying the most content." % resid)
    else:
        print("%.1f mm OVER -- the last row will be clipped. Reduce a row by this much."
              % -resid)
        sys.exit(1)


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()

```
