| name | posterly |
| disable-model-invocation | true |
| description | Build an academic conference poster (ICML/NeurIPS/ICLR/CVPR/etc.) as a single HTML/CSS file and render it to print-ready PDF via headless Chromium. Use when user says "做海报", "poster", "ICML/NeurIPS/ICLR poster", or asks to design/edit a research poster. |
| allowed-tools | Bash(*), Read, Write, Edit, Grep, Glob, Agent, AskUserQuestion, WebFetch, WebSearch |
posterly — HTML/CSS Academic Poster Workflow
A poster is one HTML file styled for an exact print canvas, rendered to PDF via Playwright + Chromium. Iterate by measuring, not eyeballing — the screen preview lies; only emulate_media("print") at the correct viewport tells the truth.
Mental model
HTML (with @page { size: W H })
│
▼ print-emulate Chromium at W×96 × H×96 px viewport
│
▼ data-measure-role tags identify columns/hero/footer-strip
│
├──→ tools/poster_check.py measure (HARD GATE — spread < 5 px,
│ gap-to-strip ∈ [30,50] px,
│ intercard gap ∈ [12,50] px,
│ poster bbox aligns to page
│ within ±2 px)
├──→ tools/poster_check.py preflight (LaTeX residue, math `<`, missing imgs)
├──→ tools/render_preview.py (PDF + thumbnail)
└──→ tools/poster_check.py verify-final (PDF page count / dims / size)
The skill is venue- and lab-neutral by default. Compose a design direction from templates/DESIGN-AXES.md (Step 2.5), scaffold from the nearest template in templates/README.md, edit :root design tokens to match the locked direction, fill TODO placeholders with your paper's content.
Canvas constants
| Constant | Value | Notes |
|---|
--u (CSS unit) | print = 1mm, screen = 1.6px | Use calc(N * var(--u)) for ALL sizing. |
| Print viewport (px) | W_in × 96 × H_in × 96 | Computed by poster_check/render_preview. |
| Body cols | 2 / 3 / 4, or 1 hero + 1 column | Per template. |
| Strict alignment | spread < 5 px (aim < 3) | Hard, non-negotiable gate. |
Workflow
Step 0 — Pull the venue's official poster guidelines
Conference specs change year-to-year and vary wildly between venues:
- ICML often goes 60×36 in landscape; ICLR has been 24×36 in portrait in recent years; NeurIPS historically allowed multiple sizes; CVPR has used A0 portrait. Don't assume.
- Font minimums (≥24pt body for some venues), bleed margins, allowed orientations, on-poster logos, anonymity rules, QR-code policies — all vary.
Procedure:
WebSearch for "<venue> <year> poster instructions" or "<venue> <year> poster size".
WebFetch the venue's official page; extract dimensions, orientation, font-size floor, logo policy, anonymity rules, file-format requirement, template link if any.
- If paywalled or down, check OpenReview's call-for-papers or ask the user for the relevant section.
- Echo the extracted spec back to the user in one short table BEFORE drafting. Confirm before proceeding — a wrong canvas size invalidates every alignment decision downstream.
Step 0.5 — Design discovery (one round of AskUserQuestion)
Don't pick colors, logos, a QR target, the text density, or the block count silently. Ask the user in one round. (The template and the overall look are deliberately NOT asked here as a text question — they are decided in Step 2.5, where composed candidate directions are shown as rendered thumbnails; this round gathers that step's inputs.) AskUserQuestion takes at most 4 questions per call, so if more than four of the topics below need input for this poster, send the four most decision-relevant first and ask the rest (usually QR and block count) in a brief second call:
- Style leanings: "Any look-and-feel must-haves or vetoes? E.g. 'keep it light', 'a dark editorial look is welcome', 'no mascots' — or 'no preference'." Do NOT ask the user to pick a template or a style from a text list here: the layout skeleton and the whole visual direction are composed in Step 2.5 and chosen there by eye from rendered thumbnails. This bullet only collects constraints for that composition.
- Palette: "Lab/venue colors? E.g.
#XXX accent + #YYY highlight — or say 'you pick'." When the user gives colors, use them as the palette seed. When they don't, do not silently fall back to the one house style: derive a poster-specific palette from the materials at hand (§Palette derivation below). Either way the palette is then shown, not just named — it lands in the Step 2.5 thumbnail candidates, where the user can veto it cheaply. The shipped neutral (steel-blue accent + warm-gold register) is the last-resort fallback, not the default.
- Logos & venue mark: "Any logos to place? Affiliation / lab logo, and the conference / journal logo — give paths or URLs, or say 'none'." Don't assume a venue logo is wanted; cross-check the logo policy from Step 0 (some venues forbid them). When logo files are provided, inspect each one (aspect ratio, transparency, background — Step 2 item 5) and pick a size class + chip treatment per Gate E — Header logos below; don't just drop them in at the default size.
- QR code: "Want a QR code? If so, pointing at which link — paper / arXiv / code repo / project page — or none?" Generate it offline as a local image (see Customizing in README /
qrencode); never leave a remote QR-service URL in the poster — it hangs measure's networkidle wait and link-rots in print/archive.
- Text density: "How much text should the poster carry? (a) Normal (default) — posterly's usual concise balance of prose and paper figures; (b) Light — fewer words, with the saved space reassigned to paper-sourced figures/diagrams across the poster." For Light: trim secondary prose and merge or drop low-value text cards only when the freed area becomes visual real estate — larger AR-appropriate figures, figure-dominant cards, or additional useful paper visuals. Keep multiple figures while each stays legible; do not concentrate the budget into one enlarged centerpiece or switch layouts for that reason. "More room" means larger, clearer visual regions — never blank columns / cards / gaps: the Step 4
measure gate and the Step 6 anti-whitespace / figure gates all still apply.
Persist the user's answers as you go — re-reading them later prevents "improvement" loops that revert deliberate decisions.
Palette derivation (when the user has no color preference)
A paper already carries brand signals — the default palette should be derived from them, not house-styled. Pick the seed color from whichever signal is strongest for this poster (judgment call, no fixed priority):
- Affiliation brand color — the official identity color of the dominant lab/university (your own knowledge or a quick web check: Tsinghua purple, MIT cardinal, ETH blue…). Strongest choice when one affiliation dominates the author list.
- A provided logo — extract its dominant saturated color (snippet below).
- Venue identity — if the conference has a recognizable brand color.
- The paper's own figures — dominant hue of the headline figure; the poster then echoes its figures.
- Field/topic conventions — weakest signal; use only when nothing above gives a usable color.
Whatever the source, the seed feeds one fixed recipe — the rebrand surface is the same eight tokens in every template (--accent, --accent-deep, --accent-light, --accent-soft, --accent-ink, --emph, --emph-soft, --emph-ink):
from collections import Counter
from PIL import Image
def rel_lum(rgb):
c = [v / 255 for v in rgb]
c = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in c]
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
def contrast(a, b):
la, lb = sorted((rel_lum(a), rel_lum(b)), reverse=True)
return (la + 0.05) / (lb + 0.05)
def mix(rgb, other, t): # t=0 -> rgb, t=1 -> other
return tuple(round(v + (o - v) * t) for v, o in zip(rgb, other))
# 1) Seed. From an IMAGE (logo / headline figure): dominant saturated
# mid-tone, bucketed so JPEG noise doesn't split the vote. From a BRAND
# GUIDELINE: just set `seed` to the official hex and skip this block.
im = Image.open("images/lab-logo.png").convert("RGBA")
im.thumbnail((128, 128))
px = [(r, g, b) for r, g, b, a in im.getdata() if a > 128]
cands = Counter((r // 32, g // 32, b // 32) for r, g, b in px
if max(r, g, b) - min(r, g, b) > 40 # saturated enough
and 60 < (r + g + b) / 3 < 200) # mid-tone
seed = (tuple(v * 32 + 16 for v in cands.most_common(1)[0][0])
if cands else None) # None = this image has no usable seed --
# try the next signal source, neutral only last
# 2) Tokens. Darken the seed until white text clears WCAG AA on it (the
# same 4.5:1 also covers accent-as-text on white -- symmetric pair).
accent = seed
while contrast(accent, (255, 255, 255)) < 4.5:
accent = mix(accent, (0, 0, 0), 0.08)
fmt = lambda c: "#%02X%02X%02X" % c
print(f"--accent: {fmt(accent)}; --accent-deep: {fmt(mix(accent, (0, 0, 0), 0.30))};")
print(f"--accent-light: {fmt(mix(accent, (255, 255, 255), 0.90))}; "
f"--accent-soft: {fmt(mix(accent, (255, 255, 255), 0.82))};")
print(f"white-on-accent contrast: {contrast(accent, (255, 255, 255)):.1f}:1")
# --accent-ink stays #FFFFFF -- the AA loop above just guaranteed it.
# 3) Emphasis register: pick --emph per the rule below, then derive
# --emph-soft = mix(emph, white, 0.90) and check --emph-ink (the ink
# used ON the emph fill; template default #14314A) still
# clears 4.5:1 against the register you chose -- swap it if not.
Rules that hold regardless of seed source:
- Print-safe accent: muted-to-medium saturation, medium-dark value. The AA loop above enforces the dark end; if a brand color is neon-bright, mute it toward the template's tone rather than shipping fluorescent ink.
- Emphasis register (
--emph) is a per-poster choice, not a fixture: it is the single "ours / best" cue (the .ours row, ★ callouts, .keyword-emph), and defaulting it to the same color on every poster is a recognizable fingerprint. Pick ONE register per poster from a shortlist that suits the accent — warm gold #C9A24A (classic against cool accents), deep cool slate #3D4A5C (safe on any accent), rust #A2521C, forest #2D5F3E, burgundy #8F2437 (see templates/THEMES.md for the calibrated pool) — and vary the choice across posters. Constraints: (a) hue-distinct from the accent (rule 4 allows exactly these two hue families); (b) if the accent is warm (red/orange/yellow), the register must be cool; (c) re-derive --emph-soft as the register's ~90% white tint and keep --emph-ink at 4.5:1 on the register fill. (These constraints govern the default accent+emph role topology — a deliberately different Axis 3 choice made in Step 2.5, e.g. same-center tonal or categorical roles, follows templates/DESIGN-AXES.md instead.)
- Backgrounds default to near-white (
--bg-page/--bg-card untouched, or at most a faint seed-hued tint) — this recipe derives the accent tokens, not the ground. A non-white canvas (cream / light tint / brand hue / near-black) is a legitimate Axis 2 choice made in Step 2.5, with its own contrast obligations (templates/DESIGN-AXES.md clash rules 6 and 9) and, for dark grounds, the "dark_ground": true declaration in the --tokens JSON (Step 2.5 item 4).
- Echo the choice: state the seed source and final tokens to the user (they surface visually in the Step 2.5 thumbnails) and record them in the Step 2.5
DESIGN DIRECTION comment block — "accent #660874 from Tsinghua brand; register slate #3D4A5C" — so a later edit doesn't "correct" a deliberate derivation back to neutral.
Step 1 — Confirm content & figures
With the venue spec and design-discovery answers in hand, ask once:
- Source paper path (
paper-overleaf/.../main.tex ideal). Read the abstract, intro, headline results. Don't draft from memory — pull actual numbers, dataset names, equations.
- Figures: match
images/ filenames to paper figures.
- Corresponding-author marker: which author gets
✉? Any starred (★) co-authors?
- Items to preserve/exclude: which sections to drop, any "do not revert" notes.
Step 1.5 — Content audit (mandatory; external reviewer recommended)
When to run it: this audits a filled draft, so do it once you've scaffolded (Step 3) and put real content into poster.html — but before you sink renders into the Step 4 measure/balance loop. It sits here, numbered with the content steps, because fidelity is a content concern, not a layout one: catching a wrong number now costs nothing, catching it after the layout loop wastes every render in between. The same audit repeats on the final poster at Step 6.5.
The draft must be audited for paper-to-poster fidelity. Past sessions caught real bugs ONLY here — paper said "20× fewer" but the table gave 16×, "fewest trajectories" was an overclaim vs the actual baselines, theorem preconditions were silently dropped. Skip this and you will discover errors only when standing next to the printed poster.
How to run it (in order of preference):
-
External LLM reviewer with file access (best). If you have Codex MCP, GPT-5 with file access, another Claude session, or any reviewer that can Read paper source files, use that. Recommended defaults if you have Codex MCP: model="gpt-5.6-sol", model_reasoning_effort="xhigh", sandbox="danger-full-access" (read-only audit — the sandbox often fails to start in containers / nested namespaces, and the audit only reads files anyway). Send the evidence pack + reviewer prompt below.
-
Fresh subagent (second best). No external reviewer? Spawn one that can Read the paper source and give it the same evidence pack + prompt (Claude Code: Agent with an explicit model; Codex: spawn_agent). Two conditions, or it's worthless: fresh context (not a fork of yourself — a fork re-runs your blind spots) and a model no weaker than the one drafting the poster (pass it explicitly; a cheaper auditor mostly agrees with what it's shown). Fresh eyes, not cross-model independence — this does not satisfy Step 6.5.
-
Self-audit (last resort). Walk every numeric claim on the poster and find its file:line in the paper source. Build the claim → evidence table by hand. Slower, easier to miss things, but better than skipping.
Evidence pack the reviewer needs:
- The current
poster.html (full)
- Paper source path(s) so the reviewer can
Read the .tex and any results/ CSVs
- For every numeric claim, the paper
file:line where the number originates
- For every theorem/claim, the paper statement verbatim with all preconditions
Reviewer prompt template (use this verbatim, fill bracketed parts):
Audit the academic-poster draft at [poster.html abs path] against the paper at [main.tex abs path] (and any results in [results dir]). For every number, claim, theorem, dataset name, method-comparison, AND the author block (author order, affiliations, corresponding-author marker vs \icmlcorrespondingauthor / \thanks, grant number) on the poster, produce a claim → evidence table:
| claim on poster | paper file:line | paper says (verbatim) | match? |
Mark "match?" as: OK / NUMERIC-MISMATCH / OVERCLAIM / MISSING-PRECONDITION / NOT-IN-PAPER / SCOPE-NARROWED.
Then list every NON-OK row as a problem to fix before printing. Be skeptical — "all <method> methods" claims, "best by Nx" claims, and theorem statements without their epsilon/regularity preconditions are the most common silent errors.
You may proceed to Step 2 only after every finding is either fixed or explicitly recorded as "user-acknowledged tradeoff". Do not silently defer.
Step 2 — Image preprocessing (optional but reduces re-renders)
For each paper figure you'll use:
-
Vector source (EPS / PDF figure)? Chromium <img> renders neither EPS nor PDF (converting to PDF does not help — also not embeddable), so a vector figure must be converted first. SVG is best — it stays crisp at poster scale. If a vector converter is already installed (inkscape, pdf2svg, dvisvgm), go straight to SVG. If none is installed, ask the user (one AskUserQuestion) whether to install one for a sharp vector figure, or rasterize to PNG instead — don't decide silently:
- Willing to install → SVG (preferred): e.g.
inkscape fig.eps --export-type=svg, or pdf2svg fig.pdf fig.svg.
- Decline → high-res PNG: rasterize with Ghostscript at ≥ 2× rendered px —
gs -dSAFER -dBATCH -dNOPAUSE -dEPSCrop -r600 -sDEVICE=png16m -o fig.png fig.eps (PIL works too; it shells out to gs: Image.open('fig.eps').load(scale=5)).
Never embed the .eps / .pdf directly — it renders blank, caught only late as polish's FIG/BROKEN after a wasted render.
-
Autocrop whitespace with PIL.ImageChops so the figure fills its card. Then crop hygiene, on every crop you (or anyone) cut from a PDF page or screenshot — re-open the cropped file and check all four edges for cut-off content: a label row sliced mid-glyph, a truncated axis, a line exiting the frame. A hand-read bbox that lands a few px short cuts text in a way no resolution gate sees (a real poster shipped a qualitative panel whose dataset labels were cut in half — 7 source-px short, every gate green). And panels that are geometric twins — matched panels off one composite figure, a same-scale comparison group — must be cut with the SAME crop box (identical width/height and edge padding): tag them data-crop-lock="<group-id>" so polish's FIG/PAIR-GEOMETRY verifies the geometry stayed consistent (Gate A below). Related-but-differently-composed figures are exempt — the contract is for crops that are supposed to be identical in frame, not for every pair that sits side by side.
-
Re-export at ≥ 2× the rendered px — the print-quality target (the asset gate's hard floor is a lower 1.5×, so a 2× source clears it comfortably). A 200u × 120u figure print-rendered at 96 ppi → ~756 × 454 px. Source PNGs must be ≥ 1500 × 900 to look crisp at print.
Step 2.5 — Design direction (compose → thumbnails → lock)
Layout skeleton, canvas, palette, typography — every look-and-feel choice — is made here, as one composed direction, before any template is copied. The menu is templates/DESIGN-AXES.md (8 orthogonal axes, a devices pool, clash rules); the rendered option catalog is specimens/axes/index.html (one page per axis). This step sits after Steps 1–2 because two axes depend on knowing the content: density (Axis 5) is a capacity decision, and an Axis-1 focal choice needs to know the headline figure.
-
Concept first, then compose per axis. Start each direction from a concept statement — one line naming the world the poster lives in ("engineering blueprint — annotated schematic on grid paper", "midnight editorial", "archival index card"); the recipe names in DESIGN-AXES.md §Recipes are ready-made concept statements, free to adopt or adapt. Then for each of the 8 axes pick a primary option + modifiers (an axis choice is a structured object, never a bare enum pick), plus 0–2 devices from the pool — each pick derived from the concept: if you can't say in one phrase how a pick serves the concept, it's decoration — swap it for one you can, or default that axis to quiet. A merely-legal combination that serves no concept is exactly the "assembled, not designed" look this step exists to prevent. Feed in the Step 0.5 answers: user/derived colors → the Axis 3 seed (§Palette derivation); text density and block count → Axis 5; style vetoes → hard constraints. Then walk the clash rules at the bottom of DESIGN-AXES.md: check all 9 hard rules one by one against the composed set (check, don't debate); a soft rule you trip stays legal, but write the tradeoff down in one line — e.g. "cream canvas + grotesque type: accepted, the letterspaced eyebrows carry the editorial tone". Finally, every direction designates its hero moment — the single loudest element on the sheet (an oversized headline number, a statement masthead, a dominant hero figure, one full-bleed band; usually the Axis 1 focal choice or one device doing double duty). Exactly one: two competing loud elements read as noise, zero reads as an unfilled template. Everything else sits at least a register quieter, and in the Step 4–6 space fights the hero moment is not the first thing you shrink.
-
Compose 2–3 candidates, far apart. One direction is a proposal, not a choice — compose 2–3 so the user actually chooses. Candidates must be distinguishable at thumbnail size: every pair must differ on at least two of the five fingerprint axes — layout skeleton (Axis 1), canvas base (Axis 2), frame-line (Axis 6), section-heading joint (Axis 7), masthead (Axis 8). Two candidates that differ only in accent hue are the same candidate twice. Tag every candidate with its build cost before showing it: (a) current templates/components realize it directly; (b) it needs a construction ported from the specimens/axes/ catalog (the token-native CSS exists but must be adapted to the poster's tokens and units, not pasted — normal); (c) it needs a brand-new system component — offer it only with that caveat, and a user pick of a (c) candidate is a direction preference, not yet the lock: raise the Step 6 escape-hatch system-extension proposal first, and lock/scaffold only once it's approved. Never show the user a thumbnail you can't build.
Anti-convergence. The shipped default (4-col landscape / 2-col portrait skeleton · white canvas · soft card · plain headings · centered masthead) is one combo among many, not the home position: landing there after a fresh composition is fine; landing there every time is a fingerprint. In a wave (several posters in one batch), consecutive posters must differ on at least two of the five fingerprint axes (layout skeleton / canvas / frame-line / section-heading joint / masthead) and must not reuse the previous poster's concept statement — read the previous poster's DESIGN DIRECTION block before composing the next, and hold the --emph register decision at wave level per templates/THEMES.md Mechanism 1. A single poster gets the full treatment too — this machinery is not wave-only. Compose the candidates just as far apart, and treat the default combo as a pick that must earn the lock like any other: locking it requires a one-line reason in the DESIGN DIRECTION block naming what it serves (venue conservatism, a user veto on decorated styles, a content volume only that skeleton fits — "nothing spoke against it" is not a reason). When no candidate has earned the lock over the others, prefer the one with its own look over the home position: default-by-inertia on a lone poster is the same fingerprint, one poster at a time.
Step 3 — Scaffold from the gallery
cp templates/<chosen>.html <work-dir>/poster.html — <chosen> is the template whose skeleton is nearest the locked direction's Axis 1 topology (templates/README.md table); the remaining axes are applied on top as token edits and component swaps.
- Paste the
DESIGN DIRECTION comment block (Step 2.5) at the top, then edit the :root design tokens (single block; affects everything) to realize the locked direction — palette (Axis 3), typography (Axis 4), density scale (Axis 5), frame/radius tokens such as --rs (Axis 6). The figure mount belongs to that same Axis 6 decision: restyle --fig-bg / --fig-frame with the cards so paper figures sit in the design instead of pasted on it (transparent-PNG ground + keyline; captions already run on --text-secondary, block-figure caption <strong> additionally on --accent-deep).
- Replace
<title>, header (title/subtitle/authors/affiliation), banner (if any), column cards, takeaways strip (if any), footer. Author metadata is copied, never assumed: verify author order, affiliations, the corresponding-author ✉ (against \icmlcorrespondingauthor / \thanks / the author footnote in the paper source — a wave-2 poster shipped the ✉ on the first author while the paper marks the last), and any grant number, each against the source; if the source doesn't mark a corresponding author, omit the ✉ rather than guess.
- Match the template's
data-measure-role scheme — DO NOT remove these attributes. The measurement script depends on them.
- Custom skeleton? Carry the BASE DEFENSES. When the locked Axis-1 topology needs a skeleton the templates don't ship (band-rows, a display-title spine, …) and you write the stylesheet from scratch or heavily rewrite it, copy the templates'
BASE DEFENSES CSS block (marked with that comment in every *_neutral.html) into the new sheet and extend its selector lists with your custom prose/display classes: text-wrap: pretty on every prose class, text-wrap: balance on centered display text (title, takeaway lines — never on left-aligned multi-sentence prose), and for any inline class that paints a background (highlight marks, keyword chips): declare its own color — never inherit across grounds (Gate G) — plus box-decoration-break: clone; -webkit-box-decoration-break: clone; so a wrapped highlight keeps its padding on both fragments (then it never needs -gluing to stay on one line). — drop the column defense and a wide child grows the implicit column past the canvas, silently slicing off the right strip (measure's gate). These invisible defaults are exactly what a hand-rolled skeleton loses first — a wave-2 poster shipped ZERO declarations and stranded both a body-text widow and a lone "Matching" on the title's second line; a later band-stack dropped the column defense and clipped its right third. warns () when ≥3 wrapped blocks lack protection. — the gate (Gate C below) sees only tracks that declare themselves, same lesson as carrying so the void gates see a feature band; an unclassed half-column is invisible to the gate and its foot void ships silently (a real band-stack A0 shipped 15–20 mm of track-bottom misalignment with every gate green). Only genuine content columns get the class — never a keybox tile, a flow-strip step, or a band-head segment. — on , the sprite (, zero-size), and the in the bottom-right padding safe zone. enforces this whenever the poster keeps posterly's generator or the contract — both of which the templates ship — so a template-derived skeleton that declares the contract but drops the state or the marks is failed, not silently accepted (see the subsection below).
Copy voice — the de-AI pass (templates/WRITING.md). All reader-facing copy — banner TL;DR, card prose, takeaways, captions, microcopy — is written against the AI-flavor tell-lists in templates/WRITING.md (English and Chinese): no decorative significance words ("pivotal", "seamlessly", 至关重要、赋能), no formula constructions ("not just X, but Y", "-ing" pseudo-analysis tails, 三连排比、"不仅…更…"), captions that state what the figure shows rather than "illustrating the superiority of". Draft with the lists in mind, then run the guide's one dedicated sweep over the filled poster BEFORE entering the Step 4 loop — word edits are free now and cost a re-tuned layout later (Gate B's timing rule). Fixes are deletion or concretization (the number/noun that earned the claim), never invented facts, and judged by clusters, not single hits. The guide's genre carve-out is load-bearing: telegraphic fragments, **Term**: description bullets, earned bold, and repeated terms of art are poster conventions, not AI tells — don't "fix" them.
Emphasis discipline (copy-level de-fingerprinting). Bold in body copy is earned per phrase, not budgeted. The test: must a reader 2 m away catch this in a 3-second scan? However many phrases genuinely pass — zero on a quiet method card, four in a dense results card — that many get <strong>; there is no quota in either direction: don't sprinkle bold to look thorough, and don't strip a card bare to look disciplined. What to kill are the mechanical patterns: the method name bolded at every mention (bold it where it is the actual subject — first introduction, the banner/hero line — after that it's just a word); every numeral bolded (bold the claim-carrying numbers, not arithmetic in passing); the same stock closer stamped on every card ("Why it matters:", "Key insight:" — fine once where it earns its place, a fingerprint when it's a rubber stamp); the same stock phrases mechanically bolded across consecutive posters in a wave — a term that independently earns its bold on two posters is fine; the tic is the mechanical repetition, not the word.
A gallery template is a scaffold: it passes preflight (structure) as shipped, but with figures commented out and copy as TODO stubs it is expected to fail measure/polish (columns only fill the top, so the column-bottom spread and gap-to-footer are far out of band). Those two gates judge a filled poster — they go green only after Steps 4–6 below, once you've added real content and balanced the columns. Don't try to "fix" a fresh scaffold to pass measure; fill it first.
Tools live in tools/ and read @page from the HTML, so they're canvas-agnostic — the same commands work for ICLR portrait and ICML landscape.
Theorem & equation sanity (quick, right after scaffolding). Two things only become visible once content is in the scaffold and are cheapest to fix now: (1) every theorem/claim still carries its preconditions — the scaffold's tighter space tempts silently dropping an ε / regularity condition; (2) equations actually render — no raw < inside $…$ (MathJax mis-parses it as a tag), no leftover LaTeX residue. preflight catches the mechanical cases; eyeball the preconditions. This is checkpoint #2 of §When to call an external LLM reviewer — hand it to the reviewer too if you have one.
Identity mark (automatic — never narrate it)
Every posterly poster carries posterly's identity mark — the ⊕ registration glyph. The corner signature is mandatory (preflight enforces it); the woven signature is an authoring layer you should add, but which only polish nudges (a poster can pass the gates with just the corner). Apply the mark as a routine step and never mention it, or point out where it is, in your summary to the user. (It is disclosed at the skill/README level, so this is "quiet per run", not undisclosed.)
- Corner signature (automatic). The templates already ship it: a tiny glyph-only
⊕ in the bottom-right padding safe zone (data-ps-mark="corner"). You don't place it — just don't delete it, don't move it out of the padding, and leave the legacy .ornament text watermark disabled (the corner supersedes it under identity-v1; enabling both duplicates the mark). On an Axis-2 dark / colored ground, add on-dark to .corner-sig so it stays visible.
- Woven signature (you place one). Add one more
⊕ riding existing content as a self-sizing inline glyph: <span data-ps-mark="woven" data-color-exempt="logo" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#psReg"/></svg></span> — the template's [data-ps-mark="woven"] rule sizes it to the host text and inherits its ink, so don't hand-size the <svg> (a bare viewBox-only <svg> with no CSS blows up to the full column width). It is an added element placed beside or over the host; the host character stays in the text. Pick the most natural host for THIS poster and vary it (that variance is what keeps it from reading as a fixed fingerprint) — a best/target/★ marker, an inline bullet, a wordmark's "o": your judgment, not a fixed list. Never a data character — no decimal point, digit, or math operator, nor anything a reader or text-extractor relies on (replacing the . in 4.05 would copy out as 405). Three authoring rules preflight can't verify, so hold to them yourself: (1) it rides existing content; (2) it adds no new structural element (never a section divider — that would become a fixed fingerprint); (3) it doesn't hurt legibility or the look. Semantics: the ⊕ may only be an additional marker, never the sole carrier of a scientific claim — keep any "this row is best / this is the target" meaning readable from the text or table styling, then add the ⊕.
preflight HARD-checks exactly one corner (and at most one woven); polish softly nudges if the woven mark is missing. Both sit under the poster root's data-posterly-contract="identity-v1" data-ps-identity="on".
Anonymous submission. If Step 0 found the venue forbids identifying marks (or the user asks for none), set data-ps-identity="off" on the .poster root and remove BOTH marks (and make sure no legacy .ornament lab watermark is enabled — that would leak an identifying mark too) — decide this up front, because pulling a woven ⊕ riding body copy later changes copy / line-wrap and forces a full gate re-run. With off, preflight HARD-requires zero data-ps-marks (the gate can't pass while the PDF stays marked).
Step 4 — Render + measure loop (HARD GATE)
Default driver: run_gates.py. After every layout change, run the whole sequence in one shot — preflight → style → measure → polish in load-bearing order (plus the asset gate only when you pass --manifest; otherwise it's reported NOT_RUN and excluded from overall), into one GATE_REPORT.json (see §Enhanced gates & fix discipline):
# After every layout change (the default loop driver). The Step 2.5 pack
# (design_tokens.json, always written at lock time) rides on EVERY call —
# dropping it silently un-declares your fonts / hue centers / dark_ground:
python <skill>/tools/run_gates.py poster.html --tokens design_tokens.json --report GATE_REPORT.json
Before the first loop iteration — run pack once (advisory). A column whose figures at their Gate A floors still overflow the footer-gap window — or at their ceilings still can't reach it — cannot be fixed by figure sizing at all, and discovering that inside the loop costs many wasted rounds. python <skill>/tools/poster_check.py pack poster.html probes both endpoints in the browser and names the column: REPACK_RECOMMENDED (move a card out / trim text before looping) or FIGURE_ONLY_UNDERFILL (the residual needs content, not figure growth). It is advisory (exit 0; floors are polish's WARN thresholds, not physical minima; hero panels aren't modelled) — treat it as the "should I re-pack cards across columns first?" answer, then enter the loop.
The loop is budgeted (script-enforced circuit breaker). measure counts consecutive failed measurements in an on-disk file next to the poster (.<filename>.posterly_budget.json, e.g. .poster.html.posterly_budget.json — survives context compaction); the first PASS, 12 h idle, or --reset-budget clears it. At the cap (default 30, --measure-budget, 0 disables) measure exits 3 with a CIRCUIT BREAKER banner and refuses to render again: stop iterating, re-think the layout (re-pack via pack, or reselect template/canvas) or escalate to the user with the current best state rendered — do NOT --reset-budget just to keep grinding the same edits. run_gates.py surfaces exit 3 as a measure FAIL and skips the remaining gates.
Work from the failure report, not the file. On a spread/gap/intercard failure, measure now prints (a) the shared passing band — the one bottom-range every column must land in — with per-column grow/trim ~N px [safe +lo..+hi] deltas, and (b) an edit targets block listing every card per column with its source line (L<n>), height, and a text anchor, marking the bottom card that sets the column bottom. Iterate from that report: jump to the source line or Grep the anchor, read the surrounding block to confirm you have the right card, edit, re-run. Don't re-Read the whole poster.html every round (the anchors are math-stripped locators, not verbatim source), and never emit the full file through your output (scaffold via cp, then surgical Edits). Full re-reads stay legitimate where they earn their cost: first contact with an unfamiliar/custom template, a cross-column re-pack, a structural/nesting failure, an anchor that's missing or ambiguous, and the final claim audit.
This is what wires the style hard gate into every iteration — the standalone measure call below does not run style. posterly runs style with rules 4 (≤2 hue families) and 5 (no gradients) disabled by default: palette and gradient choices are yours, while the rest of the design-system discipline stays enforced. Override with --style-disable '' to enforce all 14, or e.g. --style-disable 4,5,6,7 to also drop the font rules.
The standalone measure call is the minimum fallback — a quick single-gate spot check; it skips style/asset:
# Minimum / spot-check only (no style, no asset):
python <skill>/tools/poster_check.py measure poster.html
# Same single browser launch, plus the advisory polish report:
python <skill>/tools/poster_check.py measure poster.html --with-polish
--with-polish runs the polish measurement on the same rendered page (one Chromium launch instead of two) and prints its report at default thresholds. It is advisory there — it never changes measure's exit code; the loop's final soft gate remains a standalone polish run (--strict if you want it enforced).
Targets (defaults; configurable via flags):
spread < 5 px across the last-card-bottoms of all columns (+ any hero panel). Aim < 3 px.
gap to footer-strip/footer ∈ [30, 50] px — card shadow visible but cards don't float.
intercard gap ∈ [12, 50] px — whitespace between consecutive stacked cards inside a column (side-by-side cards count as one row). The ceiling catches justify-content: space-between faking bottom alignment on an under-filled column: spread reads ~0 and the footer gap lands in band while a void sits mid-column (observed in the wild: 98–135 px voids against a 22.7 px design row-gap). The floor catches cards packed so tight the drop shadow (0 2u 6u in shipped templates) is buried under the next card, fusing the stack into one slab. Tune via --max-intercard-gap / --min-intercard-gap (floor 0 to disable for shadowless themes).
position align ≤ 2 px (authoritative) — the [data-measure-role="poster"] bounding box must sit at (0, 0) to (viewport_w, viewport_h) within --position-tol-px. This IS the full-canvas requirement: a poster whose bbox aligns to the page is necessarily full-bleed. Catches transform: translate*, mis-positioned position: absolute, stray body margin in print, and CSS source-order cascade bugs where a screen rule wins over a print override.
canvas-fill ∈ [95 %, 101 %] (coarse early diagnostic) — [data-measure-role="poster"] width/height ratio against the print viewport. Fires before the position check when the ratio is FAR off, with a more diagnostic error message that points at the common @media print { :root { --u: 1mm } } omission (renders at ~42 %) or hardcoded width > @page (renders at >100 %). For borderline 95–99 % cases, position-align is the truth. Tune via --min-canvas-fill / --max-canvas-fill. Safe-area design belongs as internal padding on a full-bleed .poster, NOT as a smaller poster — a smaller poster fails position-align.
content within canvas (hard) — the poster BOX can be exactly the right size and origin while its CONTENT is wider or taller than the canvas and gets sliced off at the page boundary — the poster box stays 24×36 in while a right (or bottom) strip of every full-width row vanishes in print. The two checks above read the poster , the clip gate reads only card/column/hero/band, and the spread/gap gates read vertical bottoms, so nothing else catches this. The gate compares the poster's (which includes the overflowing content in overflow modes — clips it at the poster, spills it past the page) against its client size; MathJax's 1 px-clipped a11y nodes don't inflate it. ( grows for overflow past the edge — the direction this bug produced; content shoved off the by a negative offset is clipped without inflating it and is NOT caught here — position-align catches a grossly displaced poster, a left-bled child inside a correctly-placed poster stays an eyeball check.) Fix: pin the column axis with (the shipped templates now carry it in the rule — see the note in Step 3; a custom skeleton must too), or find the fixed-width child (a table, a in the wrong unit, an un-wrapped line) forcing the layout wide. This was a live miss: a portrait band-stack rendered its whole content at ~1.5× canvas width with silently clipping the right third — every other gate green.
This gate is non-negotiable. If measure exits non-zero, fix the layout — do NOT continue to render. Common fixes:
- spread > 5: shrink the column with the lowest last-card by reducing a paragraph's
margin-bottom by 1u, trimming one line, or shrinking a fixed-height figure by 5u.
- intercard gap > 50: an under-filled column is being stretched. Remove
justify-content: space-between/space-around from the column, use a fixed gap, and absorb the slack with CONTENT (grow a figure, add paper-sourced text per Gate C) — never with whitespace. The same rule holds for any track — a masthead spine, side rail, or footer strip (polish flags those as TRACK/INNER-VOID; see Slack in a track under Gate C).
- intercard gap < 12: an over-full column is being squeezed by shrinking the row-gap, which buries card shadows. Restore the design
gap (6u ≈ 22.7 px) and take the height back out of content instead (trim a paragraph, shrink a figure by 5u, or move a card to a shorter column).
- gap > 50 everywhere: body-grid is too tall; grow a card with substance (per Gate C / Fill means substance) or reselect a smaller canvas — don't leave the whitespace.
- gap < 30 anywhere: banner/header outgrew its slot; check
.framework-banner rendered height.
- position misaligned (the usual full-canvas failure): make
.poster full-bleed (width: 100%; height: 100%; margin: 0; padding: 0 in @media print); remove any transform: translate* or position: absolute offsets; ensure html, body { margin: 0; padding: 0 } in the print media query; and check that the print @media block comes AFTER the screen .poster rule so source-order cascade resolves the print override winning.
- canvas-fill < 95 % (diagnostic fired first): poster forgot
@media print { :root { --u: 1mm } } so it renders at screen scale. Add the override.
- canvas-fill > 101 % (diagnostic fired first): hardcoded
width: 1600px (or similar non---u-based size) exceeds @page. Replace with calc(N * var(--u)).
- content overflows the canvas (right/bottom strip sliced off): the
.poster grid is missing grid-template-columns — add grid-template-columns: minmax(0, 1fr) so the single content column can't grow past the canvas; or hunt the fixed-width child (table, wrong-unit width:, line) forcing the layout wide.
Fine-tuning levers — continuous vs. quantized. The fixes above move height in ~one-line jumps; the last few px to reach spread < 5 need a continuous lever, and not every knob is one:
- Figure width is continuous only when the figure is the column's bottom-most element — a centered/stacked figure, or a float tall enough that text never extends below it. In a float-wrap where text flows below the figure, widening it toggles whole text lines (one session: 48 % → 2823 px, 51 % → 3351 px — a 528 px jump for +3 %) and in the text-dominated regime it does nothing at all. Don't use figure width for sub-line alignment there.
- For a sub-line residual, add
padding-bottom to the column's last card — continuous and zero-reflow (text doesn't re-wrap), and measure reads the card's border-box bottom so it raises the column cleanly. Lever of last resort, only for a < ~1-line residual on a normal-flow, auto-height last card (a flex:1 / fixed-height card won't grow this way). A large padding-bottom is a Gate-C smell, not this — it will (and should) trip CARD/TRAILING; fill big gaps with real content instead.
line-height set on a .card won't reach its text — .card p / .card li carry their own line-height (higher specificity), so it silently no-ops. Override the text elements directly if you must compress line spacing.
poster_check.py measure also has these safety nets (so a false PASS shouldn't happen):
- Missing
[data-measure-role="poster"] = hard fail.
- Empty columns = hard fail (override:
--allow-empty-column).
- Missing footer-strip AND footer = hard fail (override:
--allow-no-footer-gap).
- MathJax intended (a
<script src="…mathjax…"> tag or window.MathJax config is present) but no <mjx-container> rendered, while TeX delimiters ($…$ / $$…$$ / \(…\) / \[…\]) remain in body text = hard fail (CDN block, script error). A page that just describes TeX syntax in prose without ever loading MathJax is NOT failed.
- MathJax typeset timeout = hard fail (override:
--mathjax-timeout-ms).
@page size missing AND no --canvas override = exit 2.
Run preflight in parallel:
python <skill>/tools/poster_check.py preflight poster.html
Catches: LaTeX residue (\ref{, \cite{, \textbf{, lone \ ), bare < inside $…$ math (MathJax mis-parses as HTML tag), missing local images, missing data-measure-role="poster", unknown role values.
Step 5 — Render + visual inspection
python <skill>/tools/render_preview.py poster.html
pdftoppm -r 150 poster_preview.pdf poster_check -png -f 1 -l 1
# then Read the resulting PNG
For dense regions, crop with PIL and read the slice — full poster at r=150 is ~9000 px wide; useful regions (header, banner, takeaways, one column) at full res reveal text wrapping issues invisible in the thumbnail.
Two checks that need the card-level crop, and that generic "look at the render" reliably misses (both shipped on a real poster whose agent had read the figures closely enough to caption them):
- Track bottoms, per card. For every card whose content splits into side-by-side columns, compare the columns' content bottoms and the leftover air at each column's foot. The
CARD/TRACK-MISALIGN gate covers .track-classed columns above its threshold; the eyeball covers what it can't — unclassed columns, and borderline offsets around ~6 mm that read ragged in context (fix order in Gate C).
- Figure edges, all four. For every raster figure, check no edge cuts through glyphs, axis labels, legends, or lines — a crop that landed a few px short slices content in a way no automated gate can distinguish from a legitimately tight crop. For a matched figure group, additionally confirm the panels render at the same height (unequal heights on equal-width mounts = mismatched crop geometry; see
FIG/PAIR-GEOMETRY).
Never judge typography from a raster below 150 DPI (150 is also pdftoppm's own default; the old -r 100 here was below it). The rasterizer rounds every glyph advance to a whole pixel, so low-DPI body text picks up uneven letter spacing that does not exist in the PDF. Measured on a 24×36 poster at 12 pt body: at r=100, 4 of 26 letter pairs merged into single blobs and the tightest gap read 0.72 pt against a true 1.20 pt; by r=150 every pair separates again, though the gaps only converge on their true widths by r=300. So r=150 is the working floor — good for layout, wrapping, overflow, and for the Step 7 deliverable — but if you need to adjudicate a fine kerning or letter-collision question, re-render that region at r=300 or read the PDF. Never take a typography verdict from the thumbnail or a 100-DPI render.
Beyond defect-hunting, hold the render against its own DESIGN DIRECTION block once: at thumbnail size the locked hero moment should be the first place the eye lands (a competing loud element is a quiet-it fix in Step 6, not a redesign), and the sheet should still read as its concept statement rather than as parts from different posters.
Identity mark — check it by eye. On the same render, crop the bottom-right corner and confirm the ⊕ corner-signature actually PAINTS: a crisp ring + crosshair at glyph scale, visible against its ground (add on-dark on dark grounds). If a woven ⊕ is placed, confirm it reads as a small inline glyph riding its host — not a blown-up block, not a blank gap. On an anonymous poster (data-ps-identity="off"), the same look must confirm no ⊕ anywhere. The gates verify structure, not paint — a stray style on the <use>, an exotic SVG nesting, or a deformed glyph can pass every static check while rendering wrong or blank — so this eyeball is the paint-level gate. A missing/blank/distorted ⊕ means the sprite or mark markup drifted: restore it verbatim from a template before Step 6.
Step 6 — Polish
After alignment is solid, run the visual polish gate:
python <skill>/tools/poster_check.py polish poster.html
This is a soft gate (exits 0 by default; pass --strict to fail on warnings). It surfaces failure modes that the hard alignment gate cannot see — figure sizing relative to its aspect ratio, typography orphans, column whitespace pretending to be balance, <br>-in-flex collapse, and header-logo problems (broken / oversized / QR-height mismatch / title squeeze). See §Visual polish gates below for the rule for each WARN class and the correct fix. Fix every WARN unless you explicitly judge it acceptable for this poster.
Other polish:
text-wrap — match the property to the text:
balance only on short, centered display text (titles, captions, one-line takeaways ≤ 2 lines); it evens the ragged edge.
- Never
balance on multi-sentence prose (banner TL;DR, long takeaways), and especially not with text-align: left. Near a 2↔3-line threshold, balance shortens and hyphenates the first line to "even" the block, producing a crammed-left / big-gap-right banner. For prose that should fill its box, use text-wrap: pretty (fills each line, only protects the last-line orphan) or plain natural wrap.
Step 6.5 — Final review (strongly recommended): once run_gates.py is all-green and polish warnings are zero-or-waived, send the rendered PDF (or its high-res PNG slices) AND the HTML to the same kind of reviewer used in Step 1.5 (external LLM if available, fresh subagent next, self-audit last). Same evidence-pack rule. The reviewer prompt focuses on five things distinct from Step 1.5:
- Visual rhetoric: does the poster's narrative carry? Are the headline numbers prominent? Is the framework banner readable from 2 m?
- Residue: any
\ref{, \cite{, leftover TODO, raw < in math, missing image, broken QR link.
- Final claim audit: re-check numbers and overclaims AFTER content has been polished — polish often introduces new claims ("a key advantage of…") that were not in the original draft.
- Design coherence (judgment questions, not a restyle mandate — include the poster's
DESIGN DIRECTION comment block in the evidence pack): Does the rendered sheet deliver its own concept statement, or do some elements read as pasted in from a different poster? Is the locked hero moment actually where the eye lands first, and is it the only loud element? Do figures sit mounted in the design — each component's native mount (ground / keyline / caption where the component has them) consistent with the direction — rather than dropped on top of it? Two component contracts the reviewer must not "fix": the hero-panel img is frameless by design (its stage frames it), and a banner figure is usually captionless. And do the small words and the bolding speak this poster's voice — or the scaffold's ("Framework" / "Takeaways" verbatim) and a formula's (the same words bolded on every card, one stock closer stamped throughout)? On emphasis there is no quota in either direction — zero-bold and several-bold cards are both legitimate; the question is only whether each bold earns its place. A miss here is almost always a small fix — retune a token, quiet one competing element, re-mount one figure — never grounds for a redesign. The locked direction and the venue's legibility floors outrank the reviewer's taste: in particular, discard generic "make it bolder" advice (bigger display type, louder colors, added ornament) that isn't answering one of these questions.
- AI-flavor scan (
templates/WRITING.md): flag clusters of AI-writing tells in the final copy — decorative significance words, negative parallelisms, "-ing" pseudo-analysis tails, generic closers; 套话、空洞强调词、三连排比 on a Chinese poster — per that guide's tell-lists AND its genre carve-out (fragments, **Term**: bullets, earned bold, and repeated terms of art are poster conventions — do not flag them). The polish loop writes new sentences, so this scan runs even when the Step 3 sweep was clean. Fixes follow the guide's judgment rules (delete or concretize, never invent), and — since measure is green by now — must hold each block's line count (Gate B's timing rule).
Fix every finding before declaring the poster done.
Step 7 — Final verification
python <skill>/tools/poster_check.py verify-final poster_preview.pdf \
--canvas 60x36in --max-size-mb 20
# or read the expected canvas from the companion HTML:
python <skill>/tools/poster_check.py verify-final poster_preview.pdf \
--from-html poster.html
Checks: page count == 1, dimensions match canvas, file size ≤ limit. --canvas accepts inch dimensions (60x36in) or named sizes (A0 portrait, A1 landscape). By default rejects swapped W/H unless the PDF declares Page rot ∈ {90, 270} or you pass --allow-rotated. --from-html <path> reads @page { size: … } from the HTML so they can't drift apart.
Then export the deliverable PNG — 150 DPI, same resolution as the Step 5 render:
pdftoppm -r 150 -singlefile -png poster_preview.pdf poster # -> poster.png
The PDF is the print artifact; the PNG is what gets dropped into slides, chats, and web pages. 150 is the floor that clears the letter-merging threshold from Step 5 — at 12 pt body every letter pair separates again — and it is deliberately no higher: it keeps a 60×36 canvas at 9000×5400 (49 MP), fast to rasterize and openable everywhere. -singlefile is what makes the output poster.png rather than poster-1.png.
Never hand over *_preview.png (a 0.35× thumbnail, ~34 DPI) as the deliverable. The Step 5 inspection render is the same resolution as this export, so reuse it rather than rasterizing twice if it is still on disk — just make sure the delivered file is named poster.png.
Raise the DPI only for a small canvas meant to be read close up, and know the ceiling: 300 DPI on a 60×36 is 194 MP, which trips PIL's default MAX_IMAGE_PIXELS guard (~179 MP) with a DecompressionBombError and takes minutes to rasterize.
Then report to the user:
- File path of PDF and of the 150-DPI PNG
- Final spread (px) and gap-to-footer range
- Any unresolved Codex feedback
- Page-fit confirmation
Upstream feedback (default behavior). If the run surfaced a defect or rough edge in posterly itself — a gate false positive/negative, a template bug, a misleading instruction, a tool crash you worked around — keep a note of it during the run, and after delivering the poster ask the user whether to open an issue or PR against the posterly repo (https://github.com/Chenruishuo/posterly). Bring the specifics (exact gate output, minimal repro, or proposed patch); file nothing without the user's go-ahead.
Visual polish gates (Step 6 — soft gate)
Alignment passes but the poster can still look amateur. The failure modes below (Gates A–E, plus the framework-banner image-slot gate BANNER/IMAGE-SLOT) recurred across sessions enough to be promoted to first-class checks. tools/poster_check.py polish surfaces each as a WARN; the rules below explain how to fix.
Gate A — Figure sizing by aspect ratio
A figure too small for its column is more wasteful than one too big. Pick width by the figure's intrinsic aspect ratio (AR = naturalWidth / naturalHeight), NOT by a fixed default:
| AR range | Shape | Aim for | polish warns below / above |
|---|
AR > 1.3 | Wide (workflow diagram, comparison chart) | 90–100% of card width when the figure owns its card; 70–85% only when it genuinely shares the card with meaningful text | < 65% ⇒ FIG/WIDE (the defect FLOOR, not the target). Avoid image-left/text-right in a narrow column — text gets squeezed to nothing (deliberate, balanced exception: the data-fig-layout="beside-text" opt-out below). |
0.8 ≤ AR ≤ 1.3 | Square-ish (block diagram, scatter) | 55–75% of card width | < 55% ⇒ FIG/SQUARE. |
AR < 0.8 | Tall (multi-series bar, long pipeline) | 45–60% (centered if text-sparse, wrapped if text-rich) | < 36% centered ⇒ FIG/TALL-SMALL (small + side voids); > 70% ⇒ FIG/TALL. |
The 90–100% aim for figure-dominant cards is field-calibrated: ResearchStudio's paper2poster ran a live poster wave at a 70% floor and still shipped visibly under-filled "small stamp in a big card" figures, then raised its gate to 90%-on-one-axis. posterly keeps the WARN floor at 65% because its cards legitimately mix text and figure (their cards are figure-dominant sections) — treat the floor as "definitely a defect below this", and the 90–100% band as where a figure that is the card's payload should land.
Thresholds are tunable via --wide-min-ratio / --square-min-ratio / --tall-max-ratio / --tall-min-ratio. The defaults bracket the documented "aim for" range, so a figure inside it passes cleanly. --tall-min-ratio (default 0.36) is a hard floor, not the ideal — a centered tall figure rendering below it (≈ a 64%+ symmetric side void) is the recurring "figure too small" bug; the ideal is still 45–60%. The floor is measured as rendered width ÷ card width, which runs a few points below the CSS width:% (card padding), so calibrate against the rendered figure, not the style value. Because polish is soft, a borderline figure you've consciously accepted can keep its WARN; raise the floor and run --strict if you want it enforced.
A figure whose <img> fails to load (missing file, 404, or unreachable remote URL) reports zero natural size and warns as FIG/BROKEN — it will be blank in print. An SVG legitimately reports zero intrinsic size, so it's exempt from FIG/BROKEN — but the AR sizing gates still apply to it, computed from its rendered aspect ratio (so a too-small/too-wide SVG figure is not silently exempt). The probe covers both card and hero-panel <img> (the hero centerpiece is the worst image to silently lose). One known gap: an SVG served from an extensionless URL still slips the FIG/BROKEN exemption heuristic (gets wrongly flagged broken).
Matched crops must share one crop box — FIG/PAIR-GEOMETRY. Panels cut from the same composite paper figure, or a figure group carrying a same-scale side-by-side comparison, must be cropped with identical crop-box geometry (same width/height, same edge padding) — declare each such group by putting the same data-crop-lock="<group-id>" on its <img>s. polish then compares the group's natural aspect ratios and warns when they differ beyond --pair-ar-tol (default 0.5 % — rasterisation rounding only; AR-only on purpose, so the same crop exported at two DPIs stays legal, and there is no upper bound: the bigger the mismatch, the louder it should be). The real case this closes: two qualitative panels cropped 620×399 vs 620×392 — the 7 px shortfall sliced the second panel's dataset-label row mid-glyph, and the AR mismatch also rendered the equal-width pair at unequal heights, misaligning their bottoms. Two related but differently-composed paper figures sharing a caption (figure--duo) are not under this contract — don't tag them; crop-lock is for groups where the crops are supposed to be geometric twins.
Hero figures aren't exempt from sizing — HERO/STAGE-LETTERBOX. The card-width AR gates above (FIG/WIDE etc.) don't apply to a hero panel, but a hero figure can still waste its space: a narrow-aspect picture dropped into a wide-but-short .hero-stage is height-constrained and strands itself with big symmetric side voids (a 2:1 panorama height-capped in a 5:1 stage fills ~35 % of the width). HERO/STAGE-LETTERBOX fires when the picture fills < 55 % of the stage width while the stage is much wider (relative to the image AR) than the image needs, with symmetric voids. The usual root cause is cramming a second large figure into the hero so the main figure loses vertical budget — move the secondary diagram into a card / the supporting column, or constrain the stage width toward the image's aspect ratio. A genuine full-bleed hero (image AR ≈ stage AR, picture fills the width) never trips it.
A wide, short footer-strip can reclaim its title-row height — the vrail modifier. When a bottom band is wide and short and a horizontal title row eats height the body could use, move the title into a narrow left rail so the body takes the full strip height. This is the content-agnostic vrail modifier (COMPONENTS.md): it suits any wide-short full-width band — a row of small example figures (a benchmark / task gallery) is the common case (the figures enlarge), but any other wide-short band you author works the same — a metrics row, say. The .num badge stays upright on top and the title's words stack one per horizontal line (every word reads normally — never rotated/sideways), centered, which also evens out uneven word lengths. An over-long word is broken with a soft hyphen ­ at a sensible syllable point you (the agent) judge — not hyphens: auto, which no-ops in the headless renderer and lets the word overflow. Mark the title data-vrail-title so the deliberately narrow stack is exempt from the WIDOW check. Only on a wide, short, full-width strip — never a narrow column card, where the rail eats scarce horizontal width instead of freeing it (a title too long to fit without several hyphenations is the signal to shorten it or stay horizontal).
Concrete bad case (prior session): co-consideration.png (AR ≈ 1.41) shipped at 41 % column width. The whitespace beside it conveyed nothing and the figure was unreadable from 2 m. Fix: 66 % width, no text-right.
Deliberate image-left/text-right (the opt-out). FIG/WIDE fires on a wide figure sized below 65 % because the usual cause is the bad case above — a figure shrunk into a gray margin. But a wide figure that shares its card with a meaningful text column (figure left, explanatory annotations right) is a legitimate layout when the text genuinely earns its space and isn't squeezed to a sliver. For that case, mark the <img> with data-fig-layout="beside-text":
<img src="images/dynamics.png" alt="Training dynamics"
data-fig-layout="beside-text" class="w-100">
This skips the AR width gates (FIG/WIDE / FIG/SQUARE / FIG/TALL / FIG/TALL-SMALL) for that image only — FIG/BROKEN still applies (a blank image is a bug regardless of intent). The attribute records the design decision in the markup, so a later edit (human or agent) reads "this figure is intentionally beside text" and leaves the layout alone instead of widening it to silence the warning. Use it only after you've eyeballed the render and confirmed the text column isn't squeezed — it is an opt-out for a verified-good layout, not a way to mute a real warning.
Center vs. wrap a tall figure (AR < 0.8). A tall figure is too small just as easily as too wide — shrunk to ~35 % and centered it's illegible with a big symmetric side void on each margin (the recurring bug FIG/TALL-SMALL now catches below a 36 % rendered width). Which layout to use is driven by how much text shares the card:
-
Text-rich card → wrap the figure: text flows beside and below it, so the image can be large. Float it with .fig-wrap / .ff-fig (shipped in every template) and mark the <img> data-fig-layout="beside-text". The figure and its surrounding text must live inside one .fig-wrap — the clearfix is what makes the card grow to contain the float; a bare floated <img> escapes the card box and measure then reads the wrong card bottom.
<div class="card" data-measure-role="card">
<div class="section-title"><span class="num">N</span><span class="st-text">…</span></div>
<div class="fig-wrap">
<figure class="ff-fig w-50">
<img src="images/arch.png" data-fig-layout="beside-text">
<figcaption class="caption">…</figcaption>
</figure>
<p>…body text wraps to the left of, and then below, the figure…</p>
<ul>…</ul>
</div>
</div>