slide-review

SkillWeb & browsing

Audit a Quarto reveal.js deck that already exists: render it, screenshot every slide in a browser, and report overflow, unreadable type, low contrast, broken figures, failed math, and a weak argument. TRIGGER on "review my slides", "check my deck", "pre-talk check", "am I ready to present", "are my slides readable", "is anything cut off". Authoring a new deck is research-talk or teaching-lecture.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the slide-review skill

What this skill tells your AI

The instructions your AI receives, as published by ericluo04/claude-academic-workflow in skills/slide-review/SKILL.md and read by ahel’s review.

Render the deck, gate it on a headless-Chrome fit check, screenshot every slide, then hand those PNGs to reviewer subagents that judge from pixels. Nothing here infers layout from source: counting characters in a .qmd cannot see a slide whose last bullets sit below the bottom edge. A browser measures it and the reviewers look at it.

Same loop as compile-latex --figures: render, rasterize, let an agent with eyes judge the image, report exact fixes. This skill reports and does not edit, because a deck revision is the author's call.

Where things live

The stages stay here; the detail sits in files this skill reads on demand. Paths are relative to this skill's directory. $ASSETS is ~/.claude/assets/quarto-yale, and its README.md is the toolchain's own manual: gate internals, filter behaviour, theme classes, the offline recipe. Facts that README owns are cited below, never restated.

FileWhat it holds
scripts/capture.mjsStages 4 and 5 in one run: launches a headless Chrome of its own over CDP, opens the deck over file://, runs both probes, and writes the PNGs.
scripts/probe.jsThe stage 4 probe. capture.mjs reads it and runs it in the page.
scripts/figure-ground.jsThe stage 4 figure-ground pass. Same.
references/probe-reading.mdThe theme ink tables with measured ratios, the font-size and contrast thresholds, the four math-failure kinds, the dark-deck defect classes.
references/reviewer-prompts.mdThe four reviewer prompts, the settled-decisions paragraph reviewers receive, and the reply contract.
references/failure-taxonomy.mdThe full failure table. The eight commonest rows are inline below.
style/house.mdThis author's expectations: the closing-slide contact block, density calibration by deck type, settled design decisions, the author line. A public fork swaps this file.

Every browser here is headless and private to the run

The capture path never touches Claude in Chrome. The gate scripts and capture.mjs each launch their own headless Chrome over CDP, open the deck over file://, and exit, so runs cannot collide, no server is started, and no permission prompt fires. Claude in Chrome is the user's visible browser at whatever window size they have, with no headless mode, so it cannot deliver the fixed 2334x1556 viewport the screenshots and the reviewers' arithmetic depend on. Fan out reviewers only after the PNGs are on disk; they read image files and touch no browser.

The measurements are split by what each pass can see. deck-check.mjs owns geometry (stage 3). capture.mjs owns what needs a rendered page at review size: the deck's ground colour, contrast against composited backgrounds, the math-engine signals, and the screenshots.

Inputs

ArgumentDefaultMeaning
positionalrequiredA .qmd to render, or an already-rendered .html to review as-is.
--preflightoffStages 0 to 4 only (type, render, fit, probe, and the offline check if the deck asked for it), no reviewer fanout. Use in the hour before a talk.
--type=talk|lecturedetectedWhich lenses to run and which contrast expectations apply. Detection in stage 0.
--slides=1-12allCapture and review a range only. 1-based, matches the on-slide number.
--goal="..."noneWhat the deck should accomplish. Passed to the argument reviewer verbatim.

If the positional is a bare filename, Glob for it under the current project before asking. No config file is read.

Setup

export PATH="$HOME/.local/bin:/usr/local/bin:$PATH"
ASSETS="$HOME/.claude/assets/quarto-yale"
RUN="$HOME/.claude/state/slide-review/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$RUN"

Preflight quarto, python3, node (22 or later), and a Chrome binary (/Applications, or CHROME_BIN). Missing quarto or python3, surface SETUP_MISSING:<tool> and stop. node and Chrome are needed for the stage 3 gate and the stage 4 capture, neither of which is optional; a missing Chrome is SETUP_MISSING:chrome, never a reason to read the source, which is the failure this skill replaces.

There is no Homebrew on this machine, so no pdftoppm, no pdftotext, no ImageMagick. Do not reach for them. Every PDF goes through ~/.claude/assets/bin/pdfread.py (see stage 5), including any PDF you need to look at, because Read cannot open one here.

Stage 0: deck type and ground

Settle this before running anything, because the whole review branches on it. Deck type picks the lens list (a lecture gets the pedagogy reviewer, a talk does not), the text-density standard (deliberately opposite targets per type; style/house.md), and the closing-slide expectation (stage 3c). The ground branches the contrast arithmetic: the shipped starter theme is dark ink on a paper ground, and a deck on a designed dark theme flips the ground every contrast number is taken against, so ink that is correct on one is invisible on the other.

Read the theme and the other front matter facts in one pass. In a Quarto website the format block usually sits in a _metadata.yml beside the .qmd, so search both:

grep -nE '^\s*format:|^\s*theme:|html-math-method|embed-resources|self-contained-math|highlight-style|stage-slide' \
  deck.qmd _metadata.yml "$(dirname deck.qmd)/_metadata.yml" 2>/dev/null

For a rendered .html with no source, fingerprint the class names, since the two deck types use mostly distinct vocabularies:

grep -coE 'class="[^"]*\b(assumption|proposition|lemma|takeaway|result|thanks-slide|ymid)\b' deck.html   # talk
grep -coE 'class="[^"]*\b(keyidea|definition|question|prompt|agenda|steps|demo-tag|hero|ypale)\b' deck.html # lecture

Whichever count is higher wins. A tie, or zero both ways, means ask. The root font size is a second signal when the themes in play differ, since a lecture theme typically runs a larger root than a talk theme (the starter theme serves both types at 30px). The stage 4 probe reports the root size and the deck's ground colour, so confirm the answer there before writing the report. A deck reporting pure white when its theme sets a tinted or dark ground means the theme did not load, which is CRITICAL on its own.

What the deck's ink should measure, hex by hex, comes from the theme's own palette table; references/probe-reading.md opens with the starter theme's. The class vocabulary is the README's Theme classes section; a cheap single fingerprint is that talk sources use .ymid where lecture sources use .ypale (the starter theme styles both).

.section-break styling is per theme. The rule, from the README's Things that will silently break the deck section, is no background-color attribute on a section divider, whatever the theme. On a dark deck the attribute is also a defect the probe can see, because reveal's has-dark-background then forces that slide's body text to pure white (verification in references/probe-reading.md, dark defects).

Stage 1: render

cd "$(dirname deck.qmd)" && quarto render deck.qmd --to revealjs 2>&1 | tee "$RUN/render.log"

On non-zero exit, extract the error from the log, surface RENDER_FAILED:<message>, and stop. Never review a stale HTML from a previous render.

Then mine the log, because pandoc reports two classes of defect that no amount of looking at pixels will reveal:

grep -nE '^\[WARNING\]|^ERROR|Could not fetch|not found' "$RUN/render.log"

Verified output on a deck citing a key absent from the .bib and embedding a missing figure:

[WARNING] Citeproc: citation ghostcite2019 not found
[WARNING] Could not fetch resource figures/does-not-exist.png

Both are CRITICAL. An unresolved citation prints the raw bibtex key on the wall.

There is no post-processing step. A correctly configured deck comes out of quarto render ready to review, and any offline claim gets confirmed in stage 2. Skip stage 1 entirely when handed an .html.

Stage 2: offline check, only when the deck asked for it

Decks default to MathJax loaded from a CDN, so an external MathJax reference is the expected state of a correct deck, and reporting it as a defect sends the author after a bug that is not there. Offline is an opt-in variant, declared in front matter as embed-resources: true together with html-math-method: katex. Run the check only in that case, using the facts stage 0 already read:

python3 "$ASSETS/check-offline.py" deck.html; echo "exit=$?"    # opt-in decks only

Say which branch ran, in the report, on its own line. A default deck gets Offline-safe: not checked (CDN MathJax variant, offline not requested). An opt-in deck gets the output quoted verbatim near the top, because a deck that loses its math on a conference room projector with no wifi is the worst outcome this skill exists to prevent. A pass lists external hosts and both runtime loader fields as none and ends OFFLINE-SAFE : YES (the field is padded, so grep the label and read the verdict, per the README).

On an opt-in deck, exit 1 with OFFLINE-SAFE : NO is CRITICAL, and the report names the specific cause. Two causes account for nearly all of them, and the README's Things that will silently break the deck section owns the mechanics of both:

  • Any cdn.jsdelivr hit or leftover katex runtime loader means html-math-method was written in the object form, which Quarto's katexPostProcessor silently ignores. The fix is the README's offline recipe: the bare string katex plus self-contained-math: true under embed-resources: true. With that, Quarto embeds KaTeX and its fonts itself; verified at 4.95 MB with no external hosts, no runtime loaders, and no post-processing of any kind.
  • The base theme was swapped off default onto one of the eight built-in reveal themes that @import Google Fonts, which no Quarto setting can make offline-safe; the README names the eight and the four clean ones. CRITICAL, with the fix being a return to [default, <your-theme>.scss] (the starter extension layers starter-theme.scss on default).

Run this grep on every deck, offline variant or not, because a swapped base theme also changes the variables the deck's own SCSS layers onto, and it can quietly undo the ground the rest of this review measures against:

grep -nE '^\s*theme:|fonts\.googleapis|fonts\.gstatic' deck.qmd deck.html

Ignore the unconverted math count. Quarto's katex mode leaves <span class="math"> in the file on purpose and converts it in the browser, so a non-zero count there is normal and not a finding.

Stage 3: fit gate

Run this on every deck, before touching the browser. It is the one mandatory gate, whatever the theme and whatever the math engine. It owns every geometric finding in the report.

node "$ASSETS/deck-check.mjs" fit deck.html; echo "exit=$?"
node "$ASSETS/deck-check.mjs" fit deck.html --json > "$RUN/fit.json"
node "$ASSETS/stage-check.mjs" deck.html; echo "exit=$?"

The second gate is the step-0 check: every content slide must open as its heading alone, and every press must change visible ink. A clean deck prints STEP-0-CLEAN: YES and exits 0. It fails with LEAKS AT STEP 0 on content visible before the first press, and with DEAD STEP: press 2 changes nothing or DEAD STEPS: presses 2, 4 change nothing on a press the room cannot see, presses counted from 1. Both are CRITICAL. Name every slide it lists, and quote the press numbers: the fix is per press, not per slide. Nothing else in this review can find either, and the walk adds about two seconds. Mechanics, slide-kind classification, and what produces a dead step are the README's staging section, under Checking it.

deck-check.mjs fit prints the canvas, one line per slide, then a verdict, and exits 1 whenever the verdict is NO, so the gate composes: node "$ASSETS/deck-check.mjs" fit deck.html || <report>. Verified output on a deck whose third slide is overstuffed, where quarto render had exited 0 in silence:

canvas 1050x700, 5 slides
   1  ok   Deliberately Broken Test Deck
   2  ok   Motivation
   3  OVERFLOW +547px   Results
   4  ok   A formal result
   5  ok   Conclusion

DECK-FITS: NO

The status tokens, and what each one is worth in the report:

TokenMeaningSeverity
okSlide fits.none
OVERFLOW +NpxContent past the bottom edge. Gone from the screen.CRITICAL
TOO WIDE +NpxContent past the right edge.CRITICAL
N MISSING IMGAn <img> that resolved to nothing.CRITICAL
N CRUSHED FIGA figure scaled down far enough to be unreadable.MAJOR
FIG SHRUNK TO NpxA figure authored at 1200px or wider that auto-stretch scaled under 600px.MAJOR
N UNRENDERED MATHA span.math the engine never touched. Raw TeX on the wall.CRITICAL
NEARLY EMPTYEffectively blank slide. Prints "nearly empty (warning, not a failure)" and does not fail the gate.MAJOR
DANGLING TARGET #idA .jump button whose target id resolves to no slide. Reveal lands on the title slide instead, silently. Fails the gate.CRITICAL
UNPAGINATED BIBLIOGRAPHYdiv#refs holds entries, which means the deck set a bibliography: and forgot citeproc: false, so pandoc rendered a second copy of the whole reference list onto one slide. Fails the gate.CRITICAL
no [.jump-back] on #idA jump target with no way back. Warning; leaves the exit code alone.MAJOR

The exact measurement condition behind each token is the README's fit-gate table. UNRENDERED MATH works under either engine and is what catches broken math delimiters, which under MathJax leave the literal \(...\) in the paragraph and produce no container at all for the stage 4 probe to find. The tokens are separate and a slide can carry more than one (4 1 MISSING IMG and 5 TOO WIDE +308px on the same run, verified). Carry each token and its number into the report verbatim; never re-derive either.

NEARLY EMPTY is the one token that leaves the exit code at 0. A blank slide is still a defect the audience sees, so report it, but do not describe the gate as failing on it. The common cause is a bare macro block before the first ##; the fix is ## Notation {visibility="hidden"}, per the README's Things that will silently break the deck.

deck-check.mjs drives its own headless Chrome over CDP on node 22, needs no npm install, honours CHROME_BIN, and navigates slide by slide with fragments forced visible, which is the only way the numbers come out right (README, fit-gate section). Do not write your own overflow probe: the obvious one-pass snippet reads scrollHeight 0 off every non-present slide and reports a broken deck as clean, as the README explains. deck-check.mjs handout deck.html out.pdf exports a PDF; offer it when the user wants a handout, and use it as the capture fallback in stage 4.

Stage 3b: jump buttons and the progress bar

The staging filter gives every deck two behavioural features no static screenshot can check, and both fail silently. Run this only on a deck that has an appendix or .jump spans. Stage 3 covers the static half from its own headless Chrome; the interactive half runs in a browser you can script, below:

grep -c 'class="jump-btn' deck.html          # 0 means skip the jump half
grep -c 'appendix-break\|section .*appendix' deck.html

What has to be true, and what each failure means:

CheckFailure means
.progress carries metered when the deck has an appendixThe filter's script did not run, so the appendix is back in the denominator. MAJOR.
.progress carries segmented when the deck has two or more .section-break dividersSame cause. MINOR on its own.
--section-cut-N values equal 100 * i / (main - 1) for each divider at flattened index i, where main is the index of the first appendix slideA cut in the wrong place, which is worse than no cuts. MAJOR.
The fill span's scaleX is 1 on the last main slide and on every appendix and references slideThe exclusion is not working. MAJOR, and the symptom the feature exists to prevent.
Every a.jump-btn[data-jump] target resolves to a slideA dangling target lands the presenter on the title slide mid-question. CRITICAL.
Every appendix slide reachable by a jump carries a .jump-backA one-way trip. MAJOR.
A jump then a back returns to the origin's {indexh, indexv, indexf} exactlyThe handler is not capturing the origin before reveal navigates. CRITICAL.

Stage 3 already ran the dangling-target check: deck-check.mjs fit fails on DANGLING TARGET #id "label" and warns on a jump target with no .jump-back, so read its jump-button block before doing this by hand, and carry its label and id into the report verbatim. The equivalent by hand, on an HTML you were handed: open the deck in Claude in Chrome (file:// is fine there) and run this with its JavaScript tool:

() => [...document.querySelectorAll('a.jump-btn[data-jump]')].map(a => {
  const id = a.getAttribute('data-jump'), el = document.getElementById(id);
  const sec = el && el.closest('.slides section');
  return { label: a.textContent.trim(), target: id,
           resolves: !!sec, isAppendix: !!(sec && sec.classList.contains('appendix')),
           hasBack: !!(sec && sec.querySelector('a.jump-btn.jump-back')) };
})

Report resolves: false as CRITICAL with the label and the target id. hasBack: false is MAJOR: the presenter can get there and not back. isAppendix: false is not a finding; a jump to a main slide is legitimate.

The round-trip check needs interaction, so it belongs in one script that drives reveal itself, run the same way: put the origin slide at a known fragment step, read Reveal.getState(), dispatch a bubbling click on the button, dispatch one on the .jump-back it lands next to, and compare Reveal.getState() to what you stored. A synthetic bubbling click is the right tool here: the handler is delegated, and a real mouse click cannot reach a button whose fragment is still hidden.

Four behaviours are correct and must not be reported as defects. A jump lands the target with all its fragments visible, on purpose. The bar stays full while you are inside the appendix or the references, also on purpose, because the main deck really is over. A references slide has zero fragments, because a reference list is not an argument being built. And an empty <div id="refs"> on the references divider is the marker that keeps citation links alive, not leftover markup: what fails the gate is a #refs with entries in it.

Stage 3c: the closing slide

How a deck ends is settled per type, so this check branches the way stage 0 does. Run it on every deck. A research talk ends on a closing slide carrying the contact block; a teaching lecture ends on content, with no closing slide. The expected contents, their position relative to the appendix and references dividers, and the concrete presence greps are in style/house.md; state each finding against that file's values. Handed a rendered .html with no source, take the ordered title list from fit.json and the per-slide classes from probe.json after stage 4 has run, and read the same positions off those.

Deck typeStateSeverity
talkNo closing slide anywhereMAJOR. The talk stops instead of closing, and the room leaves with no address to write to.
talkClosing slide sits inside the appendix, or after it, or after the referencesMAJOR. The audience meets the appendix before the invitation. The fix is to move it ahead of the .appendix-break divider.
talkClosing slide with no contact line and no invitation to get in touchMINOR. Name the line that is missing.
talkNo QR slot on the closing slideMINOR.
talkQR slot present, image resolves to nothingCRITICAL, and stage 3 has already printed MISSING IMG for that slide. Carry its token.
lectureA thank-you slide is presentMINOR. It belongs on a talk; the lecture's own closing slide is the one idea to leave with.

The bar reads full on the closing slide, since it is the last main-body slide. That is stage 3b's fill check and not a second finding here.

Stage 4: probe and capture

The rendered page supplies what stage 3 cannot: the deck's real ground colour, contrast against composited backgrounds, the math-engine failure signals, unresolved citations, and the PNGs the reviewers look at. Geometry is already settled by stage 3, so the probe does not measure it.

One command does the probe and the screenshots. It launches its own headless Chrome, opens the deck over file://, sizes the viewport to 2334x1556, waits for Reveal, the math engine, and the fonts, switches fragments and transitions off, runs probe.js and figure-ground.js, and shoots every slide:

SKILL="$HOME/.claude/skills/slide-review"
node "$SKILL/scripts/capture.mjs" deck.html "$RUN"                          # probe.json + shots/
node "$SKILL/scripts/capture.mjs" deck.html "$RUN" --slides=1,3-5 --no-probe  # a subset, shots only

Verified output on the public example talk deck, 26 slides in six seconds:

canvas 1050x700, scale 2.000, 26 slides
probe: $RUN/probe.json (26 slides, 2 figures)
shots: 26 -> $RUN/shots/slide-01.png .. slide-26.png

Reveal computes scale = min(0.9 * innerW / 1050, 0.9 * innerH / 700) at the Quarto default margin of 0.1, so 2334x1556 gives scale 2.000 against the 1050x700 logical canvas. Verified. One screenshot pixel is half a deck pixel, and the reviewers are told so. The script prints a WARNING when the scale comes out anything else, which happens on a deck that overrides width, height, or margin; carry the printed scale into the reviewer prompts in that case.

The script sets fragments: false, transition: 'none', and autoAnimate: false before it measures or shoots anything, so one capture per slide is the whole slide.

fragments: false is what makes staged content measurable, and it has to stay. Decks run stage-slide.lua, which wraps nearly every top-level block on a content slide in a .fragment so the slide opens as its heading alone. A staged block is present in the DOM from the start and only hidden, so it is content, not missing content. Never report a slide as empty or a point as absent because it arrives on a later keypress, and tell the reviewers the same, since the screenshots are taken with fragments off and show the fully revealed slide.

The probe (scripts/probe.js, run in the page by capture.mjs, its JSON at $RUN/probe.json) forces each section visible in turn and restores the inline style afterwards, because Reveal sets display: none on off-screen sections and a hidden element reports no computed geometry and no client rects. It measures font size and contrast against the composited background, reads the deck's own ground colour instead of assuming white, and re-bases per slide on a data-background-color, because reveal paints that backdrop on a separate .slide-background element that is nowhere in the text's ancestor chain; without the re-base, the probe measures a divider slide's text against the deck ground while the audience sees it on the attribute's colour.

Images cannot be measured in that pass: reveal lazy-loads them from data-src, so off-screen <img> elements report naturalWidth 0 and a zero-size box, and forcing the section visible does not change that (verification in references/probe-reading.md). So capture.mjs runs scripts/figure-ground.js as a second pass: it loads each image into a detached Image and samples the border band, and the result sits in $RUN/probe.json as figures. Under file:// that read needs Chrome's --allow-file-access-from-files, which the script passes; without it every figure comes back unmeasurable.

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
23
Forks
3
Last commit
Sep 2026

ahel review

  • K6low
    bundled executables the agent is told to run

Automated review, not a security audit. Ruleset v1+k2.

Advanced
Catalog kind
skill
Gateway key
slide-review
Source
github.com/ericluo04/claude-academic-workflow