Skip to content

Instantly share code, notes, and snippets.

@iremlopsum
Last active September 11, 2026 19:18
Show Gist options
  • Select an option

  • Save iremlopsum/142e2e6113f87e8f7023e61112d3cc2e to your computer and use it in GitHub Desktop.

Select an option

Save iremlopsum/142e2e6113f87e8f7023e61112d3cc2e to your computer and use it in GitHub Desktop.
/explain-pr — a Claude Code skill that turns one or two GitHub PRs into a self-contained dark-mode HTML explainer (and its PDF). README.md explains what it does, how it is used, and the on-disk file structure.

/explain-pr — a Claude Code skill that explains a PR as a visual page

A Claude Code skill that turns one or two GitHub pull requests into a single self-contained dark-mode HTML page — and, on request, the PDF of that page. Not a summary: a document written for three readers at once.

  • someone who wants to know what shipped (plain language, no file names)
  • someone about to review or merge it (contract diff, blast radius, test coverage)
  • someone who wants to actually understand the system (background, intuition, code walkthrough)

Adapted from Geoffrey Litt's explain-diff, with panels specific to how the project it was written for ships: two repos per feature, no staging environment, deploy:prod on push to master.

Gist files are flat. The skill on disk has a references/ subdirectory — see File structure for where each file below belongs.


Usage

/explain-pr main#484 admin-app#96     # a full-stack pair, told as ONE story
/explain-pr admin-app#96              # a single PR
/explain-pr https://github.com/uptip-inc/main/pull/484
/explain-pr admin-app#99 --pdf        # also render the PDF beside it
/explain-pr main#489 --share          # also publish as a private Claude Artifact

Refs — one or two, as <repo>#<number> or a full GitHub URL. With no refs the skill asks rather than guessing from the current branch. Two refs are read together and told as one feature story, not as two summaries.

Flags

Flag Effect
(none) Creates explain-pr/YYYY-MM-DD-<slug>/, writes index.html inside it, opens it, prints the path. Nothing leaves the machine.
--pdf Also renders explainer.pdf into the same folder, via render-pdf.mjs. Still local — a file on disk, not a publish.
--share Also publishes the page as a private Claude Artifact and returns the URL. Opt-in on purpose: the page carries production SQL, schema details and proprietary code.

--pdf and --share combine freely, and neither replaces the local file.


What it produces

Eight sections, each a <section class="sec" id="…" data-layer="product|reviewer|deep">. The table of contents and the layer badges build themselves from those two attributes.

# Section Layer Content
1 what-shipped product What a user can now do that they couldn't. No file names, no jargon.
2 how-it-looks product Only if the PR carries a mockup. Before/after stills of the real UI, wiped between, with a coloured ring and a chip on every changed region.
3 contract reviewer GraphQL schema diff, which client operations consume it, a data-flow diagram carrying real example values. Flags client fields the schema no longer backs, and new non-nullable fields.
4 blast-radius reviewer What goes live the moment it merges. Affected lambdas/screens, whether a migration's down actually reverses it, the rollback path, a danger callout if it touches money.
5 test-map reviewer Every changed source file: test added / updated / covered / nothing covers it, led by a warning stat counting that last bucket.
6 background deep How the existing system works — a skippable beginner tier, then the narrow background that bears on the change.
7 intuition deep The essence, with one toy example carried all the way through. Diagrams over prose.
8 code deep Walkthrough grouped by idea, not by file order. Short captioned excerpts explaining why.

Sections that would be empty are dropped rather than padded.

The design is not negotiable by the model. The shell encodes a design system (dark column): every colour is a token, no raw hex, no fading a status colour with opacity (it drops below WCAG AA), syntax highlighting done by the shell's own tokenizer rather than by hand. Writing style is aimed at Kleppmann-ish clarity — one long scrolling page, no tabs.

The mockup panel

The strongest and most fragile part. If the PR carries a self-contained HTML mockup, the skill drives a browser to bake the mockup into two stills (before / after), measures a rectangle per change against the stage box, and hands the shell two images plus that geometry. Three rules exist because breaking them fails silently:

  • The panel is images, never an iframe — WebP stills cost less than the mockup, drag at full frame rate, and keep someone else's JavaScript out of the page.
  • Bake both states identically — same hidden chrome, same width, measured only after the mockup's own CSS transitions settle. Rects taken under different conditions line up in one state and drift in the other.
  • A missing anchor is a caption, never a guess.

Two fallbacks: a cropped before/after strip per change when the changes span multiple screens, and a static "ballot" when the mockup compares candidate designs rather than showing one.


The files

Gist file Role
SKILL.md The skill itself — frontmatter (name + description that triggers it), argument parsing, the gather procedure, the section table, design rules, output paths, and a pre-flight checklist. This is the only file Claude loads up front.
mockup-panel.md Read on demand when a PR carries a mockup. The full recipe: finding the mockup, choosing the "before" state (four sources, in preference order), baking the stills, measuring the rects, and the two fallbacks.
shell.html The page template. Copy it, replace {{TITLE}} / {{SECTIONS}} / other {{PLACEHOLDERS}}. Carries the whole design system as tokens, the markup vocabulary (documented in a comment above <div class="wrap">), the contents/layer-badge builder, the mockup panel runtime, and a small hand-written syntax highlighter (ts, sql, graphql) — a tokenizer rather than a CDN library, so the page stays self-contained and offline-safe.
render-pdf.mjs node render-pdf.mjs <in.html> <out.pdf>. Zero dependencies — drives Chrome over the DevTools protocol directly (Node 20+ has global WebSocket/fetch; puppeteer isn't worth adding for one printToPDF). Captures each panel's JSON as the document parses (the shell's script deletes it by load), injects print.css, rebuilds interactive panels as static stacked stills with a numbered legend, and prints with zero side margins so the dark canvas bleeds to the paper edge. Free: cover page, section-per-page, footer with page / total.
print.css Every print rule, injected at render time rather than written into the page. That's the point: an explainer generated months before a CSS fix still gets the current stylesheet on re-render. Handles the dark-canvas-on-paper problem, break-inside rules, and dropping --shadow (Chrome can't put a blurred box-shadow in a PDF — it rasterises the box with the backdrop composited in, leaving a faint seam).
pdf-pages.mjs node pdf-pages.mjs <file.pdf> <out-dir> 1,4,9 [scale]. Rasterises chosen pages so they can be looked at. Exists because sips only ever renders page 1 and there's no pdftoppm/poppler/ImageMagick on the machine — so it serves the PDF over localhost and screenshots Chrome's own PDF viewer. Scale defaults to 1.5; raise it to 3x, because some artefacts are invisible at 100%.

File structure

On disk, references/ holds everything except SKILL.md:

~/Work/uptip/                          # monorepo root — three separate git repos
├── .claude/
│   └── skills/
│       └── explain-pr/                # ← the skill (these gist files)
│           ├── SKILL.md
│           └── references/
│               ├── mockup-panel.md
│               ├── shell.html
│               ├── print.css
│               ├── render-pdf.mjs
│               └── pdf-pages.mjs
├── explain-pr/                        # OUTPUT — one folder per explainer, NOT loaded by the skill
│   ├── README.md
│   ├── _tools/
│   │   ├── _measure.mjs               # heights of every card under PRINT layout
│   │   └── _sample.mjs                # colour runs along a scanline of two PNGs
│   ├── 2026-09-09-main484-adminapp96-manager-reporting/
│   │   └── index.html
│   └── 2026-09-11-main489-brex-vendor-payment-accounts/
│       ├── index.html                 # the explainer — open this
│       ├── explainer.pdf              # --pdf only
│       ├── sections.html              # the {{SECTIONS}} block, for re-assembly
│       ├── how-it-looks.html          # the mockup panel, when there is one
│       └── shots/                     # rasterised pages from verification. Disposable.
├── main/                              # backend (GraphQL API, lambdas, Terraform)
├── admin-app/                         # manager dashboard (React + Vite)
└── receiver-app/                      # worker mobile app (Expo)

Restoring from this gist:

mkdir -p ~/.claude/skills/explain-pr/references   # or <project>/.claude/skills/…
mv SKILL.md ~/.claude/skills/explain-pr/
mv mockup-panel.md shell.html print.css render-pdf.mjs pdf-pages.mjs \
   ~/.claude/skills/explain-pr/references/

Three directory choices that are deliberate:

  • Output lives outside every repo. explain-pr/ sits at the monorepo root, which is not itself a git repository, and is outside main/, admin-app/ and receiver-app/ — so a generated page containing production schema and SQL cannot be accidentally committed.
  • One folder per explainer, date-prefixed. The directory sorts by time, and everything a run produces — page, PDF, re-assembly parts, verification shots — stays together instead of interleaving with every other PR's files.
  • The re-assembly parts are kept, not discarded. sections.html holds the prose and how-it-looks.html the baked mockup stills, so when shell.html changes, an old explainer is rebuilt against the new shell without re-running the analysis — which costs minutes and returns a different answer. The three fixture folders exist for exactly that loop.

Requirements

  • Claude Code, with the skill directory discoverable (~/.claude/skills/ for global, or <project>/.claude/skills/ for one project).
  • gh CLI, authenticated with repo access — the skill calls gh pr view, gh pr diff and gh api …/pulls/<n>/comments. Each repo is a separate checkout, so gh runs inside the right one (or takes --repo <org>/<repo>).
  • Node 20+ for --pdf (global WebSocket and fetch; no npm install, no node_modules).
  • Google Chrome installed — both .mjs scripts drive it over the DevTools protocol.
  • A browser automation MCP (Playwright or chrome-devtools) only for baking mockup stills. Playwright blocks file:, so the mockup is served over python3 -m http.server first.
  • Network for the Google Fonts stylesheet (Hanken Grotesk). Everything else in the page is inline: CSS, JS, and the mockup stills as data: URIs — which is also the only way an image survives publishing as a Claude Artifact.

Porting it to another project

The skeleton is generic; these are the project-specific parts to rewrite:

  1. SKILL.md frontmatter description — this is what makes Claude reach for the skill. It names the project and the phrasings that should trigger it.
  2. Repo names and pathsmain / admin-app / receiver-app under /Users/<you>/Work/uptip/<repo>, org uptip-inc.
  3. Section 3 (contract) assumes a GraphQL schema in schema.ts consumed by a client queries.ts. Swap for whatever the API contract is (OpenAPI, protobuf, a typed client).
  4. Section 4 (blast-radius) encodes the deploy model: no staging, deploy:prod on push to master with no gate, Sequelize migrations, an S3/CloudFront rollback doc. This is the section that pays for itself and also the one most specific to a given setup.
  5. Design tokens in shell.html are copied from the app's own index.css so explainers look like the product. Replace the :root block.
  6. Mockup conventionsdocs/mockups/*.html, plan/spec docs under docs/superpowers/plans/ and docs/superpowers/specs/.

Failure modes worth knowing

Learned the hard way; all of them look fine until you actually look:

  • A ring measured under the wrong conditions lands plausibly but wrong — it lines up in one state and drifts in the other. Only the rendered page at real width shows it.
  • A fragment-only reload doesn't re-read the file from disk, so you can screenshot a stale copy and believe you verified the new one. Reload ignoring cache.
  • A broken <script> fails silently and costs the contents list, the layer badges and every code block's highlighting — the page still "looks fine".
  • A mockup still sliced mid-UI across a page break, or a page four-fifths empty because something taller than the remaining space refused to break.
  • Code or a table running past the right paper edge — the lost text leaves no mark.
  • A faint rectangle cutting across a card is a blurred box-shadow rasterised by Chrome with the backdrop composited in. Invisible at 100%; look at 3x.

Baking a mockup into the explainer

How to turn a mockup a PR carries into the how-it-looks panel: two stills of the same screen, wiped between, with a ring on every region the PR changed.

The shell renders the panel; this file is how you produce what it renders. The markup contract is documented in shell.html, in the vocabulary comment under MOCKUP.

1. Find the mockup

uptip mockups are self-contained HTML files under <repo>/docs/mockups/, and a PR reaches one in two ways:

# added or edited by the PR
gh pr view <n> --json files --jq '.files[].path' | grep 'docs/mockups/'

# named in the PR body, or in the plan/spec it references
gh pr view <n> --json body --jq '.body' | grep -o 'docs/mockups/[a-z0-9-]*\.html'

Read the mockup. Most of them state, in their own words, what changes and where — reports-feedback.html carries a "What changes, and where" section that is the change list already written. Use it; don't re-derive it from the diff.

Some mockups are not one design but two or three candidates built side by side (bulk-assign-controls.html, team-breadcrumb-variants.html, report-entry-card-variants.html). Those get the ballot instead — §7.

2. Decide the two states

The wipe needs a before. Four sources, in order of preference:

  1. The mockup's own state control. Several have one — reports-feedback.html has #btnToday / #btnProp. Best case: one file, two states, identical layout engine.
  2. The previous version of the same file: git show <sha>^:docs/mockups/x.html.
  3. The mockup the earlier PR shippedreports.html is the before for reports-feedback.html.
  4. Nothing. Then there is no wipe. Bake one still, ring the changed regions on it, and say in the lede that this is the proposed state only.

3. Bake the two stills

Use the Playwright or chrome-devtools MCP. Serve the mockup over http (Playwright blocks file:): cd <dir> && python3 -m http.server 8899, then navigate to it.

Prepare the page — every step matters, and the same preparation has to be used for both states:

() => {
  document.body.classList.add('dark');             // match the explainer
  const root = document.querySelector('.stage');   // the element that IS the screen
  let el = root;                                   // hide everything around it
  while (el && el !== document.body) {
    const p = el.parentNode;
    for (const k of p.children) if (k !== el) k.style.display = 'none';
    el = p;
  }
  // The mockup's own comparison chrome is chrome, not product UI — the
  // explainer supplies the before/after control itself.
  const lbl = document.querySelector('.stage-lbl');
  if (lbl) lbl.style.display = 'none';
  const page = document.querySelector('.page');
  page.style.maxWidth = 'none';
  page.style.padding = '0';
  page.style.width = '1180px';                     // same width for both states
  document.body.style.margin = '0';
  document.documentElement.style.overflowY = 'scroll';
  return document.querySelector('.stage').getBoundingClientRect();
}

Then, per state: click the state control, wait ~900ms for the mockup's own CSS transitions to settle, screenshot the stage element, and measure the anchors.

async () => {
  const wait = ms => new Promise(r => setTimeout(r, ms));
  const SEL = {                     // one entry per change, both states
    teams: { before: '#teamBtn', after: '#teamBtn' },
    status: { before: '#statusField', after: '.composer .fields' },   // a removal
  };
  const grab = (state) => {
    const stage = document.querySelector('.stage').getBoundingClientRect();
    const out = {};
    for (const k in SEL) {
      const el = document.querySelector(SEL[k][state]);
      if (!el) continue;
      const r = el.getBoundingClientRect();
      if (r.width < 2 || r.height < 2) continue;   // collapsed, not present
      out[k] = { x: +(r.left - stage.left).toFixed(1), y: +(r.top - stage.top).toFixed(1),
                 w: +r.width.toFixed(1), h: +r.height.toFixed(1) };
    }
    return { w: +stage.width.toFixed(1), h: +stage.height.toFixed(1), rects: out };
  };
  document.getElementById('btnToday').click(); await wait(900);
  const before = grab('before');
  document.getElementById('btnProp').click(); await wait(900);
  const after = grab('after');
  return { before, after };
}

Take each screenshot with the element target (.stage), not a full-page one: a full-page shot includes a scrollbar, and the two states will come out different widths.

Encode both to WebP and embed as data URIs:

cwebp -q 82 before.png -o before.webp     # ~45 KB for a 1180×1000 dark screen
base64 -i before.webp                     # paste after "data:image/webp;base64,"

WebP at q82 is roughly a third of the PNG and holds up on flat dark UI. If cwebp isn't there, sips -s format jpeg -s formatOptions 82 or the raw PNG both work; the shell doesn't care which.

4. Traps that cost real time

  • An element screenshot excludes the element's own margin. Anything that re-renders the mockup live (a variant crop, a debug pass) must zero root.style.margin or its geometry will be off by that margin.
  • Measure after the transitions. reports-feedback.html animates the Status cell's width to zero. Measured immediately after the click it reads w: 0, and the ring silently disappears.
  • Both stills need the same width. Fix .page width; don't rely on the viewport.
  • Never poll. No requestAnimationFrame loop, no interval re-measuring anything. The shell recomputes on resize and on a mode change, and that is enough. A polling loop per panel is what made the first version of this unusable.
  • Different heights are fine and often the point. In admin-app#96 the proposed screen is 111px shorter because two failed rows drop out. The wipe shows that; a cropped pair cannot.

5. The change list

One entry per change the PR makes to this screen:

{ "id": "guest", "n": "04", "kind": "renamed",
  "label": "Guest receiver",
  "title": "“Shared with guest” becomes “Guest receiver”, answered on every row",
  "why": "“Guest” at a hotel means the person in room 412. And a blank cell could not be told apart from “this column does not apply to me”.",
  "files": ["report-table.tsx", "report-row.tsx", "utils/reports.ts"] }
  • kindremoved | changed | renamed | added | nonvisual. It picks the ring colour, so the shape of the PR is readable before a word is.
  • label is the chip; title and why fill the detail panel. why is one sentence, and it is the reason, not the mechanics.
  • files are the files in the diff that implement it — the bridge from the picture to the code walkthrough further down the page.
  • A removal has no "after" element. Anchor its after selector on the container it left and set "ghost": true; the shell draws that ring dashed. An addition is the same trick with the states swapped.
  • A change with no anchor at all (a route guard, a gate coming off) is "kind": "nonvisual". It keeps its chip and its files and says it has no surface. Do not hunt for something to ring.

Order the list the way the PR body does, not by position on screen.

6. When to use the strip instead

data-shape="strip" renders the same data as one cropped before/after pair per change, out of the same two stills. Reach for it when:

  • the PR's changes span more than one screen, so no single stage holds them;
  • or there are enough of them that the reader will skim for one.

The wipe is the default: it is the only shape that shows a change in the shape of a screen. A PR can carry both — the wipe for the screen, the strip for the detail — at no extra bytes, since they share the stills.

7. Variant mockups: the ballot

When the mockup compares candidate designs, the interesting thing is not what changed but which one shipped and why the others didn't. Bake one still per variant section (focusRoot = #v-a, #v-b, …, same preparation as §3) and write the static .ballot markup from the shell vocabulary. The mockup's own trade-off list is usually right there in the file; the PR body says which variant was taken.

8. Checks before you call the panel done

  • Every ring sits on its element, in both states — open the page and look.
  • Every change either has a ring or says it has no surface.
  • The panel is images only. document.querySelectorAll('iframe').length === 0.
  • The stills are under ~60 KB each; the whole page is still one file with no network dependency beyond the Google font.
/**
* pdf-pages.mjs — rasterise pages of a PDF so they can actually be looked at.
*
* node pdf-pages.mjs <file.pdf> <out-dir> <pages> [scale] # pages: 1,4,9 or 1-6
*
* `scale` defaults to 1.5. Raise it to inspect rendering artefacts — some only
* appear above 1:1, because they come from how Chrome rasterises an effect into
* the PDF rather than from the layout.
*
* There is no poppler, ImageMagick or pdftoppm on this machine, and `sips` only
* ever renders page 1. So the PDF is served over localhost and screenshotted
* through Chrome's own PDF viewer, which honours a `#page=` fragment. The
* viewer will not load a `file:` URL, hence the one-file http server.
*
* A bare fragment change does not reload the document, so each page navigates
* with a cache-busting query as well — screenshotting a stale page and
* believing it is the new one is the failure this avoids.
*/
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
const [, , pdfArg, outArg, pagesArg = '1', scaleArg] = process.argv
if (!pdfArg || !outArg) {
console.error('usage: node pdf-pages.mjs <file.pdf> <out-dir> <pages> [scale]')
process.exit(1)
}
const bytes = readFileSync(resolve(pdfArg))
const outDir = resolve(outArg)
mkdirSync(outDir, { recursive: true })
const pages = pagesArg.split(',').flatMap(part => {
const m = part.match(/^(\d+)-(\d+)$/)
if (!m) return [Number(part)]
const [, a, b] = m.map(Number)
return Array.from({ length: b - a + 1 }, (_, i) => a + i)
})
const server = createServer((req, res) => {
res.writeHead(200, { 'content-type': 'application/pdf', 'cache-control': 'no-store' })
res.end(bytes)
}).listen(0)
await new Promise(r => server.once('listening', r))
const url = `http://127.0.0.1:${server.address().port}/doc.pdf`
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const PORT = 9700 + Math.floor(Math.random() * 400)
const SCALE = Number(scaleArg) || 1.5 // Letter at 96dpi, up for legibility
const W = Math.round(816 * SCALE), H = Math.round(1056 * SCALE)
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, '--disable-gpu', '--no-first-run',
`--window-size=${W},${H}`, `--user-data-dir=${mkdtempSync(join(tmpdir(), 'pdf-pages-'))}`, 'about:blank',
], { stdio: 'ignore' })
const sleep = ms => new Promise(r => setTimeout(r, ms))
let version
for (let i = 0; i < 80; i++) {
try { version = await (await fetch(`http://127.0.0.1:${PORT}/json/version`)).json(); break } catch { await sleep(150) }
}
const ws = new WebSocket(version.webSocketDebuggerUrl)
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej })
let id = 0
const pending = new Map()
ws.onmessage = e => {
const m = JSON.parse(e.data)
if (m.id && pending.has(m.id)) {
const { res, rej } = pending.get(m.id)
pending.delete(m.id)
m.error ? rej(new Error(m.error.message)) : res(m.result)
}
}
const send = (method, params = {}, sessionId) => new Promise((res, rej) => {
const i = ++id
pending.set(i, { res, rej })
ws.send(JSON.stringify({ id: i, method, params, sessionId }))
})
const { targetId } = await send('Target.createTarget', { url: 'about:blank' })
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true })
await send('Page.enable', {}, sessionId)
await send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 1, mobile: false }, sessionId)
for (const [i, p] of pages.entries()) {
await send('Page.navigate', { url: `${url}?v=${p}#page=${p}&zoom=page-fit&toolbar=0` }, sessionId)
await sleep(i === 0 ? 3000 : 1200)
const { data } = await send('Page.captureScreenshot', { format: 'png' }, sessionId)
const file = join(outDir, `page-${String(p).padStart(2, '0')}.png`)
writeFileSync(file, Buffer.from(data, 'base64'))
console.log('captured', file)
}
ws.close()
chrome.kill()
server.close()
process.exit(0)
/* ==========================================================================
print.css — the paper column of the explainer shell.
`render-pdf.mjs` injects this file into the page immediately before it calls
Page.printToPDF, so it also reaches explainers generated before this file
existed. It styles only. The structural work — turning the drag-to-compare
wipe into two stacked stills, and the hover-to-reveal chip panel into a
printed legend — is a DOM pass in that script, because the change text lives
in the panel's JSON rather than anywhere in the DOM.
The palette does not change. These pages are read on a screen and forwarded,
not pinned to a wall, so the PDF is the page: same canvas, same tokens, same
measured contrast. Backgrounds reach the paper edge because the PDF is
printed with zero side margins and the top and bottom bands are painted by
Chrome's header/footer templates — see the comment in render-pdf.mjs.
NEVER add an `@page { margin }` rule here. A CSS page margin silently
overrides the margins printToPDF passes, collapsing the top band and putting
the first line of every page against the paper edge.
========================================================================== */
@media print {
/* ---------- paint ------------------------------------------------------ */
/* Chrome drops every background colour when printing unless each element
opts back in. Without this the whole page prints as black text on white
with the dark-mode tokens still in place, which is unreadable rather than
merely plain. */
*, *::before, *::after {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* Every blurred drop shadow is removed. Chrome cannot express one in PDF, so it
rasterises the box into an image tile and composites the backdrop into it —
which on a near-black canvas lands as a faintly lighter RECTANGLE around the
card, with hard edges that cut across it. At 100% it hides; at any real zoom
it reads as a card whose background did not finish filling. The inset
hairline every card already carries defines it perfectly well without one.
This is a print-only override of the token, so nothing on screen changes. */
:root { --shadow: none; }
.mockup__box, .stat, .card, .vb--chosen, .wipe__knob, .sc__lbl { box-shadow: inset 0 0 0 1px var(--hair); }
.vb--chosen { box-shadow: inset 0 0 0 1.5px var(--c-accent); }
/* The body gradient restarts on every sheet, so it prints as a visible band
at each page break. Flat canvas instead — and it has to be the same flat
colour the header/footer templates paint, or the seam shows. */
html { background: var(--c-canvas); }
/* `color` is set here and not left to the base rule on purpose: an explainer
generated before this file existed carries its own `@media print { body {
background:#fff; color:#000 } }`, and this stylesheet is injected after it.
Anything that stated a token (a lede, a pill, a caption) survived that rule;
everything inheriting from body printed near-black on near-black. */
body { background: var(--c-canvas); background-image: none; color: var(--c-fg); }
/* ---------- page box --------------------------------------------------- */
/* The side inset lives here rather than in printToPDF's margins, because a
real page margin would leave the canvas short of the paper edge. Body
padding repeats on every page and the background flows under it. */
.wrap { max-width: none; margin: 0; padding: 0 13mm 6mm; }
.layout { display: block; }
main { display: block; min-width: 0; }
/* The hero is the cover. Every section starts a page (below), so it has page 1
to itself either way — filling the page and sitting slightly above centre
makes that a title page rather than a page that ran out of content. */
.hero {
display: flex; flex-direction: column; justify-content: center;
min-height: 228mm; padding: 0 0 12mm; box-sizing: border-box;
}
/* ---------- things that needed a pointer ------------------------------- */
.toc { display: none !important; } /* a sidebar of anchors is not navigation on paper */
.mockup .seg { display: none !important; } /* Wipe / Flip / Side by side */
.wipe__grip { display: none !important; }
.chips { display: none !important; } /* replaced by the printed legend */
.detail { display: none !important; } /* held one change at a time, on hover */
/* A closed <details> hides its body entirely, and nothing on paper can open
it. render-pdf.mjs opens every one; this drops the affordance that would
promise it still folds. */
details.more > summary { cursor: default; }
details.more > summary::before { display: none; }
/* ---------- reflow: nothing may sit in a scroll container -------------- */
/* On screen these scroll sideways. On paper the overflow is not clipped in a
way the reader can recover — it is simply absent, and a half-printed line
of code reads as if that is all the code there is. */
pre.code {
white-space: pre-wrap;
overflow: visible;
overflow-wrap: anywhere;
font-size: 11.4px;
line-height: 1.6;
}
/* A wrapped continuation is indented past the line it belongs to, so a long
line still reads as one line rather than as two statements. */
pre.code .ln, pre.code .add, pre.code .del, pre.code .hl {
padding-left: 2.6em;
text-indent: -2.6em;
}
/* .add/.del/.hl bleed their tint into the block's own padding with a negative
margin; the extra left padding above has to be added back on top of it. */
pre.code .add, pre.code .del, pre.code .hl {
padding-left: calc(20px + 2.6em);
padding-right: 20px;
}
.table-scroll { overflow: visible; }
table.data { min-width: 0; font-size: 12.4px; }
table.data th { padding: 0 10px 8px; white-space: normal; }
table.data td { padding: 9px 10px; }
table.data td.mono, table.data td .mono { font-size: 11.4px; overflow-wrap: anywhere; }
/* `nowrap` exists so a status label never breaks mid-phrase on screen, where
the row can always get wider. Here it cannot, and a clipped verdict is
worse than a wrapped one. */
table.data td.nowrap { white-space: normal; }
table.data tr:hover td { background: none; }
.node__data, .filechip, .mockup__src, code:not(pre code) { overflow-wrap: anywhere; }
.flow { justify-content: flex-start; }
/* ---------- pagination -------------------------------------------------- */
/* One section per page. The hero is then alone on page 1 — that is the cover,
and the reason there is no separate cover markup to keep in sync. */
.sec { break-before: page; padding-top: 0; border-top: 0; }
/* A heading at the foot of a page is a lie about what follows it. */
.sec h2, .sec h3, .sec h4, .code__cap, .card__title, .callout__title,
.pmock__lbl { break-after: avoid; }
.sec__lede { break-before: avoid; }
/* Anything with a box around it reads as one object; sliced across a page
break it reads as two broken ones. An element taller than a page breaks
anyway — Chrome ignores `avoid` it cannot honour — so this costs nothing
on the few long code blocks.
CONTAINERS ARE NOT ON THIS LIST, and that is the point. `.mockup` wraps two
full-page stills and a legend; asking Chrome to keep that together makes it
move the whole panel to a fresh page first and break it there anyway,
leaving the page before it four fifths empty. The atomic objects are the
individual still and the individual legend row, which is where those rules
live (see .pmock__shot and .plegend li). */
.card, .callout, .stat, .diagram, .ui, .kv, .sc, .vb,
pre.code, table.data tr { break-inside: avoid; }
/* When a box IS taller than a page, it breaks whatever the rule above says.
Without this it breaks open: the background and the rounded corners belong
to the box as a whole, so the fragment on the first page ends in a straight
cut and the fragment on the second starts in one, which reads as a card that
failed to draw rather than as a card continued.
`clone` re-applies the border, padding, radius and background to EVERY
fragment, so the card closes off rounded at the foot of one page and opens
rounded at the head of the next. */
.card, .callout, .diagram, .ui, details.more, pre.code, .pmock__shot {
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
/* Set by render-pdf.mjs on any box it measures at over half a page. `avoid` on
such a box is a false economy: it can rarely be kept whole, and the hole it
leaves when Chrome shunts it to the next page is worse than the break —
which, with the rule above, is now a clean one. `auto` only PERMITS a break:
a tall box that does fit the space left still prints whole.
Must come after the avoid rule; same specificity, later wins. */
.is-tall { break-inside: auto; }
/* Two lines stranded at the top or bottom of a page. */
p, li, .sec__lede { orphans: 3; widows: 3; }
/* ---------- motion ------------------------------------------------------ */
/* .rise animates from opacity 0. If the print snapshot is taken before the
animation settles, the card prints blank — the failure looks like missing
content rather than like a timing bug, so it is removed rather than waited
out. */
.rise { animation: none !important; opacity: 1 !important; transform: none !important; }
/* ==========================================================================
The printed mockup panel, built by render-pdf.mjs out of the panel's JSON.
Two stills stacked at full content width rather than the on-screen wipe:
side by side would put a 1180px screenshot of a dashboard into a 350px
column, where none of the UI text it exists to show survives.
========================================================================== */
.pmock { margin: 14px 0 0; }
.pmock__lbl {
display: flex; align-items: baseline; gap: 8px; margin: 16px 0 7px;
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
color: var(--c-dim);
}
.pmock__lbl b { color: var(--c-accent); font-weight: 700; }
/* --pmock-w is set per panel by render-pdf.mjs, and only when the taller of
the two stills would not otherwise fit on one page. Both states get the
same width from it, because two screenshots at different scales stop being
a before and an after. */
.pmock__shot {
position: relative; border-radius: 12px; overflow: hidden;
max-width: var(--pmock-w, 100%);
background: var(--c-canvas); box-shadow: inset 0 0 0 1px var(--hair);
break-inside: avoid;
}
.pmock__shot img { display: block; width: 100%; height: auto; }
/* Rings are positioned in percentages of the still, not in pixels off a
measured box width: the paper width is not known to the page, and a rect
scaled against the wrong width lands plausibly and wrongly. */
.pmock .ring { position: absolute; }
.plegend { list-style: none; margin: 16px 0 0; padding: 0; }
.plegend li {
display: grid; grid-template-columns: 22px 1fr; gap: 10px;
padding: 9px 0; border-top: 1px solid var(--hair-soft);
break-inside: avoid;
}
.plegend li:first-child { border-top: 0; }
.plegend__n {
width: 22px; height: 22px; border-radius: 9999px; display: grid; place-items: center;
font-size: 11px; font-weight: 800;
background: var(--c-accent); color: var(--c-accent-ink);
}
.plegend li[data-kind=removed] .plegend__n { background: var(--danger); color: #fff; }
.plegend li[data-kind=renamed] .plegend__n { background: var(--info); color: #fff; }
.plegend li[data-kind=added] .plegend__n { background: var(--ok); color: #08290f; }
.plegend li[data-kind=nonvisual] .plegend__n {
background: transparent; color: var(--c-dim); box-shadow: inset 0 0 0 1.5px var(--hair);
}
.plegend__title { font-weight: 700; font-size: 13.5px; }
.plegend__why { color: var(--c-muted); font-size: 12.8px; margin: 3px 0 0; max-width: none; }
.plegend__files { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
}
/**
* render-pdf.mjs — turn an explainer page into the PDF of itself.
*
* node render-pdf.mjs <input.html> <output.pdf>
*
* No dependencies: Node 20+ has a global WebSocket and fetch, and Chrome is
* driven over the DevTools protocol directly. Puppeteer is not installed on
* this machine and is not worth adding for one printToPDF call.
*
* Three things happen to the page before it is printed, in this order:
*
* 1. Every panel's JSON is captured as the document parses. The shell's own
* script reads that JSON and then REMOVES it from the DOM, so by load it
* is gone — and the change descriptions only ever existed there. A
* MutationObserver installed before any page script runs (via
* Page.addScriptToEvaluateOnNewDocument) sees each block as it is parsed.
*
* 2. references/print.css is injected. It is not read from the page: an
* explainer generated before that file existed gets the current stylesheet
* anyway, which is what makes re-rendering an old page worth doing.
*
* 3. The interactive panels are rebuilt as static ones — see printifyMockups.
*
* Then Page.printToPDF with zero side margins, so the canvas bleeds to the
* paper edge, and header/footer templates painting the top and bottom bands.
* CSS cannot paint the page margin area: Chrome paints the canvas only across
* the page box and clips fixed elements to it, so a background on html, on
* body, or on a fixed bleed layer all stop at the same edge. The templates are
* the only thing that renders out there.
*/
import { spawn } from 'node:child_process'
import { writeFileSync, mkdtempSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const [, , inPath, outPath] = process.argv
if (!inPath || !outPath) {
console.error('usage: node render-pdf.mjs <input.html> <output.pdf>')
process.exit(1)
}
const HTML = resolve(inPath)
const PDF = resolve(outPath)
const PRINT_CSS = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'print.css'), 'utf8')
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const PORT = 9223 + Math.floor(Math.random() * 400)
/**
* Page geometry, in millimetres, in one place because three things have to
* agree on it: printToPDF's margins, the header/footer templates that paint
* those margin bands, and `.wrap`'s side padding in print.css.
*
* Left and right margins are ZERO — the inset is body padding instead, so the
* canvas reaches the paper edge and repeats on every page. Change SIDE here and
* change `.wrap` in print.css with it.
*/
const PAPER_W = 215.9, PAPER_H = 279.4 // US Letter
const SIDE = 13 // must equal .wrap's horizontal padding
const MT = 14, MB = 16 // the bands the templates paint
const PAGE = {
contentMm: PAPER_W - SIDE * 2,
/**
* The tallest a still may print. Deliberately well under the content height
* (249mm) rather than just inside it: a figure that needs almost a whole page
* cannot share one with the two paragraphs that introduce it, so it moves to
* the next page and leaves four fifths of the previous one empty — which
* reads as a broken layout, not as a full-page plate.
*
* 178mm is the content height (249mm) less room for a section heading, its
* lede, a paragraph of lead-in and the still's own label — so the first still
* of a panel lands on the same page as the text that introduces it.
*/
usableMm: 178,
}
/* ---------------------------------------------------------------- page code */
/**
* Captures every `<script type="application/json">` as it is parsed. Runs
* before the page's own script, when document.documentElement may not exist
* yet — hence observing `document` rather than the element.
*
* The NODE is kept, never its text: an element is appended when its start tag
* is parsed and filled afterwards, so a large block's textContent can still be
* empty when the observer fires. A node keeps its content after the shell
* removes it from the document, so reading it later is both safe and complete.
*/
const CAPTURE = `
window.__panelJSON = [];
new MutationObserver(function (muts) {
muts.forEach(function (m) {
Array.prototype.forEach.call(m.addedNodes, function (n) {
if (n.nodeType === 1 && n.tagName === 'SCRIPT' && n.type === 'application/json') {
window.__panelJSON.push(n);
}
});
});
}).observe(document, { childList: true, subtree: true });
`
/**
* Rebuild each drag-to-compare panel as two stacked stills plus a numbered
* legend. Runs in the page.
*
* Stacked rather than side by side: the stills are screenshots of a 1180px
* dashboard, and half a printed column is 350px — at that size none of the UI
* text the panel exists to show survives. Full width is 0.66 scale, which
* does.
*
* Rings are placed in percentages of the still rather than in pixels off a
* measured box, which is what the on-screen panel does. The page cannot know
* the paper width, and a rect scaled against the wrong width lands plausibly
* and wrongly — the exact failure the mockup reference warns about.
*/
const PRINTIFY = `
(function (PAGE) {
var RING_PAD = 5; /* matches the on-screen panel's pad, in still pixels */
/**
* Both stills get ONE width, chosen so the taller of the two fits on a single
* page. A full-screen still is often taller than the page box, and
* break-inside:avoid cannot save an element that does not fit anywhere:
* Chrome slices it, so the screenshot continues on the next page with a UI
* cut through the middle. Capping by height alone would give the two states
* different widths, which is not a before/after any more.
*/
function panelWidthMm(data) {
var w = (data.before || {}).w || (data.after || {}).w;
var tallest = Math.max((data.before || {}).h || 0, (data.after || {}).h || 0);
if (!w || !tallest) return null;
var atFullWidth = PAGE.contentMm * tallest / w;
if (atFullWidth <= PAGE.usableMm) return null; /* fits already */
return PAGE.contentMm * (PAGE.usableMm / atFullWidth);
}
function el(tag, cls, html) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (html != null) n.innerHTML = html;
return n;
}
function shot(data, state, src, alt, changes) {
var meta = data[state] || {};
var box = el('div', 'pmock__shot');
var img = el('img');
img.src = src;
img.alt = alt || state;
box.appendChild(img);
if (meta.w && meta.h) {
var rings = el('div', 'rings');
changes.forEach(function (c) {
var r = (meta.rects || {})[c.id];
if (!r) return; /* no rect in this state = no ring, never a guess */
var ghost = !!c.ghost && state === 'after';
var d = el('div', 'ring' + (ghost ? ' ring--ghost' : ''));
d.setAttribute('data-kind', c.kind);
d.style.left = ((r.x - RING_PAD) / meta.w * 100) + '%';
d.style.top = ((r.y - RING_PAD) / meta.h * 100) + '%';
d.style.width = ((r.w + RING_PAD * 2) / meta.w * 100) + '%';
d.style.height = ((r.h + RING_PAD * 2) / meta.h * 100) + '%';
rings.appendChild(d);
});
box.appendChild(rings);
}
return box;
}
function legend(changes) {
var ol = el('ol', 'plegend');
changes.forEach(function (c, i) {
var li = el('li');
li.setAttribute('data-kind', c.kind || 'changed');
li.appendChild(el('span', 'plegend__n', String(c.n != null ? c.n : i + 1)));
var body = el('div');
body.appendChild(el('div', 'plegend__title', c.title || c.label || ''));
if (c.why) body.appendChild(el('p', 'plegend__why', c.why));
if (c.kind === 'nonvisual') {
body.appendChild(el('p', 'plegend__why',
'<b>No surface in this mockup</b> — nothing to ring.'));
}
if ((c.files || []).length) {
var files = el('div', 'plegend__files');
c.files.forEach(function (f) { files.appendChild(el('span', 'filechip', f)); });
body.appendChild(files);
}
li.appendChild(body);
ol.appendChild(li);
});
return ol;
}
var raw = window.__panelJSON || [];
var used = 0;
var done = 0;
Array.prototype.forEach.call(document.querySelectorAll('.mockup'), function (root) {
/* Only panels the shell actually built consumed a JSON block. */
var wipe = root.querySelector('.wipe');
if (!wipe && !root.querySelector('.strip')) return;
var node = raw[used++];
if (!wipe) return; /* a strip panel is already static */
var data;
try { data = JSON.parse(node.textContent); } catch (e) { return; }
var before = root.querySelector('.wipe__side--before img');
var after = root.querySelector('.wipe__side--after img');
if (!data || !before || !after) return;
var changes = data.changes || [];
var fig = el('figure', 'pmock');
var capMm = panelWidthMm(data);
if (capMm) fig.style.setProperty('--pmock-w', capMm.toFixed(1) + 'mm');
[['before', before], ['after', after]].forEach(function (pair) {
var state = pair[0];
var lbl = data[state + 'Label'] || (state === 'before' ? 'Before' : 'After');
fig.appendChild(el('figcaption', 'pmock__lbl',
(state === 'after' ? '<b>' + lbl + '</b>' : lbl)));
fig.appendChild(shot(data, state, pair[1].getAttribute('src'),
pair[1].getAttribute('alt'), changes));
});
if (changes.length) fig.appendChild(legend(changes));
/* Keep the bar (it carries the mockup's path); drop everything the pointer
drove. */
['.mockup__sides', '.mockup__box', '.chips', '.detail'].forEach(function (sel) {
var n = root.querySelector(sel);
if (n) n.remove();
});
root.appendChild(fig);
done++;
});
/* Nothing on paper can open a disclosure. */
Array.prototype.forEach.call(document.querySelectorAll('details'), function (d) { d.open = true; });
return {
panels: done,
wipes: document.querySelectorAll('.mockup .wipe').length,
mockups: document.querySelectorAll('.mockup').length,
captured: raw.length,
iframes: document.querySelectorAll('iframe').length,
};
})(${JSON.stringify(PAGE)})
`
/* ------------------------------------------------------------------- driver */
const sleep = ms => new Promise(r => setTimeout(r, ms))
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, '--disable-gpu', '--no-first-run',
`--user-data-dir=${mkdtempSync(join(tmpdir(), 'explain-pr-pdf-'))}`, 'about:blank',
], { stdio: 'ignore' })
let version
for (let i = 0; i < 80; i++) {
try { version = await (await fetch(`http://127.0.0.1:${PORT}/json/version`)).json(); break } catch { await sleep(150) }
}
if (!version) throw new Error('Chrome did not expose the DevTools endpoint')
const ws = new WebSocket(version.webSocketDebuggerUrl)
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej })
let id = 0
const pending = new Map()
const seen = new Set()
ws.onmessage = e => {
const m = JSON.parse(e.data)
if (m.id && pending.has(m.id)) {
const { res, rej } = pending.get(m.id)
pending.delete(m.id)
m.error ? rej(new Error(m.error.message)) : res(m.result)
} else if (m.method) seen.add(m.method)
}
const send = (method, params = {}, sessionId) => new Promise((res, rej) => {
const i = ++id
pending.set(i, { res, rej })
ws.send(JSON.stringify({ id: i, method, params, sessionId }))
})
const { targetId } = await send('Target.createTarget', { url: 'about:blank' })
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true })
await send('Page.enable', {}, sessionId)
await send('Page.addScriptToEvaluateOnNewDocument', { source: CAPTURE }, sessionId)
await send('Page.navigate', { url: `file://${HTML}` }, sessionId)
for (let i = 0; i < 150 && !seen.has('Page.loadEventFired'); i++) await sleep(100)
const evaluate = expression =>
send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId)
// Webfonts and the base64 stills both land after load; measuring or printing
// before they do gives a page laid out against fallback metrics.
await evaluate('document.fonts.ready.then(() => 1)')
await evaluate('Promise.all(Array.from(document.images, i => i.decode().catch(() => 0)))')
await evaluate(`(function () {
var s = document.createElement('style');
s.id = 'explain-pr-print';
s.textContent = ${JSON.stringify(PRINT_CSS)};
document.head.appendChild(s);
})()`)
const { result } = await evaluate(PRINTIFY)
// Measure under the layout the PDF will actually use — print media, paper width
// — and let anything over half a page break rather than jump. Chrome moves a box
// it cannot keep whole to the next page and breaks it there anyway, so `avoid`
// on a tall box buys a hole and nothing else. Done here rather than in CSS
// because CSS cannot ask how tall something is.
await send('Emulation.setEmulatedMedia', { media: 'print' }, sessionId)
await send('Emulation.setDeviceMetricsOverride',
{ width: Math.round(PAPER_W / 25.4 * 96), height: Math.round(PAPER_H / 25.4 * 96), deviceScaleFactor: 1, mobile: false },
sessionId)
const pageBoxPx = (PAPER_H - MT - MB) / 25.4 * 96
const { result: tallResult } = await evaluate(`(function () {
var limit = ${pageBoxPx / 2};
var n = 0;
/* Images are excluded on purpose: a split screenshot is a cut through a UI,
which is why the stills are width-capped to a page instead. */
document.querySelectorAll('.card, .callout, .diagram, .ui, .kv, .sc, .vb, pre.code, details.more')
.forEach(function (el) {
if (el.getBoundingClientRect().height > limit) { el.classList.add('is-tall'); n++; }
});
return n;
})()`)
await send('Emulation.clearDeviceMetricsOverride', {}, sessionId)
const stats = result.value || {}
// Bands are painted with the shell's own canvas token, so a palette change in
// the shell cannot leave a seam at the page edge here.
const tint = (await evaluate(
`getComputedStyle(document.documentElement).getPropertyValue('--c-canvas').trim() || '#0d0a08'`
)).result.value
const title = (await evaluate('document.title || ""')).result.value
const exact = '-webkit-print-color-adjust:exact;print-color-adjust:exact;'
const band = (edge, h) => `position:fixed;${edge}:0;left:0;right:0;height:${h}mm;background:${tint};${exact}`
// Three traps live in these templates, each failing differently:
// 1. the template is its own document with a default body margin, so a normal
// block leaves a white sliver at the paper edge — position:fixed escapes it;
// 2. it has no sized parent, so height:100% collapses to zero and the band
// renders white;
// 3. a fixed element is laid out against the whole sheet rather than its band,
// so inset:0 paints over the content — each band is pinned to its own edge
// with an explicit height.
const head = `<div style="${band('top', MT)}"></div>`
const foot = `<div style="${band('bottom', MB)}box-sizing:border-box;`
+ `padding:4.5mm 13mm 0;font-family:'Hanken Grotesk',-apple-system,system-ui,sans-serif;`
+ `font-size:7.5pt;color:#988b7f;display:flex;justify-content:space-between;align-items:flex-start;">`
+ `<span>${title.replace(/[<&]/g, c => (c === '<' ? '&lt;' : '&amp;'))}</span>`
+ `<span><span class="pageNumber"></span> / <span class="totalPages"></span></span></div>`
const IN = mm => mm / 25.4
const { data } = await send('Page.printToPDF', {
printBackground: true,
preferCSSPageSize: false,
paperWidth: 8.5,
paperHeight: 11,
marginTop: IN(MT),
marginBottom: IN(MB),
marginLeft: 0,
marginRight: 0,
displayHeaderFooter: true,
headerTemplate: head,
footerTemplate: foot,
}, sessionId)
writeFileSync(PDF, Buffer.from(data, 'base64'))
console.log(`wrote ${PDF} (${(Buffer.from(data, 'base64').length / 1e6).toFixed(2)} MB)`)
console.log(` mockup panels: ${stats.panels ?? 0} printed of ${stats.mockups ?? 0} `
+ `(${stats.captured ?? 0} JSON blocks captured) · iframes: ${stats.iframes ?? 0}`
+ ` · ${tallResult.value ?? 0} box(es) over half a page, allowed to split`)
ws.close()
chrome.kill()
process.exit(0)
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{TITLE}}</title>
<!-- Inline favicon. Without one the browser requests /favicon.ico, which a file://
document cannot load — it logs "Unsafe attempt to load URL … unique security
origins" on every open. An amber tile also makes the tab findable. -->
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23fbbf24'/%3E%3C/svg%3E">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
/* ============================================================
Uptip PR explainer — dark shell
Mirrors admin-app/design-system.md ("Warm Dashboard"), .dark column.
Token values are copied from admin-app/src/index.css — if that file
changes, change these.
============================================================ */
:root {
--c-canvas: #0d0a08;
--c-surface: #1a1613;
--c-surface2: #221d18;
--c-fg: #f7f4f1;
--c-muted: #b3a79d;
--c-dim: #988b7f;
--c-accent: #fbbf24;
--c-accent-ink: #1c1917;
--bg-grad: linear-gradient(180deg, #1a1613, #0d0a08);
/* opacity-modifier idiom from the design system */
--hair: rgba(247,244,241,0.10);
--hair-soft: rgba(247,244,241,0.08);
--hover: rgba(247,244,241,0.05);
--accent-soft: rgba(251,191,36,0.15);
--shadow: 0 22px 48px -26px rgba(0,0,0,0.45);
/* Status colors. Dark-side steps only — per design-system.md these are
the steps that clear WCAG AA against #1a1613 (green-500 8.11:1,
red-500 4.72:1). Never fade them with opacity; use --c-muted instead. */
--ok: #22c55e;
--warn: #f59e0b;
--danger:#ef4444;
--info: #3b82f6;
--ease: cubic-bezier(0.16, 1, 0.3, 1);
}
* { box-sizing: border-box; scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--c-fg) 22%, transparent) transparent; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--c-fg) 18%, transparent);
border-radius: 9999px; border: 3px solid transparent; background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--c-fg) 32%, transparent); }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--c-canvas) var(--bg-grad) no-repeat fixed;
color: var(--c-fg);
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
font-size: 15px; line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
::selection { background: var(--accent-soft); }
/* ---------- layout ---------- */
.wrap { max-width: 1180px; margin: 0 auto; padding: 0 20px 96px; }
.layout { display: block; }
@media (min-width: 1080px) {
.layout { display: grid; grid-template-columns: 232px minmax(0, 1fr); gap: 44px; align-items: start; }
}
main { min-width: 0; }
/* ---------- hero ---------- */
.hero { padding: 56px 0 30px; }
.hero__eyebrow { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; }
.hero h1 {
font-size: clamp(28px, 4.4vw, 42px); font-weight: 800; letter-spacing: -0.02em;
line-height: 1.12; margin: 0 0 14px;
}
.hero__sub { color: var(--c-muted); font-size: 16px; max-width: 68ch; margin: 0 0 22px; }
.hero__meta { display: flex; flex-wrap: wrap; gap: 10px 26px; font-size: 13px; color: var(--c-dim); }
.hero__meta b { color: var(--c-fg); font-weight: 700; }
/* ---------- table of contents ---------- */
.toc { display: none; }
@media (min-width: 1080px) {
.toc { display: block; position: sticky; top: 28px; padding-top: 8px; }
}
.toc__title {
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--c-dim); margin: 0 0 12px; padding-left: 12px;
}
.toc a {
display: block; padding: 6px 12px; margin-bottom: 2px; border-radius: 12px;
color: var(--c-muted); text-decoration: none; font-size: 13.5px; line-height: 1.4;
border-left: 2px solid transparent; transition: color .18s, background .18s;
}
.toc a:hover { color: var(--c-fg); background: var(--hover); }
.toc a.is-active { color: var(--c-accent); background: var(--accent-soft); font-weight: 600; }
.toc__layer {
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--c-dim); padding: 14px 12px 6px; opacity: .75;
}
/* ---------- sections ---------- */
.sec { padding: 40px 0 8px; scroll-margin-top: 24px; }
.sec + .sec { border-top: 1px solid var(--hair-soft); }
.sec__head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 6px; }
.sec h2 { font-size: 26px; font-weight: 800; letter-spacing: -0.015em; margin: 0; }
.sec h3 { font-size: 18px; font-weight: 700; margin: 34px 0 10px; }
.sec h4 { font-size: 14px; font-weight: 700; margin: 22px 0 8px; color: var(--c-muted); }
.sec__lede { color: var(--c-muted); font-size: 16px; max-width: 72ch; margin: 0 0 18px; }
p { max-width: 72ch; }
a { color: var(--c-accent); text-decoration-color: rgba(251,191,36,.4); text-underline-offset: 2px; }
/* ---------- pills ---------- */
.pill {
display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 9999px;
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
background: var(--hover); color: var(--c-muted); white-space: nowrap;
}
.pill--accent { background: var(--accent-soft); color: var(--c-accent); }
.pill--ok { background: rgba(34,197,94,.15); color: var(--ok); }
.pill--warn { background: rgba(245,158,11,.15); color: var(--warn); }
.pill--danger { background: rgba(239,68,68,.15); color: var(--danger); }
.pill--info { background: rgba(59,130,246,.15); color: var(--info); }
.pill--mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
text-transform: none; letter-spacing: 0; font-weight: 600; font-size: 11.5px;
}
/* ---------- cards ---------- */
.card {
background: var(--c-surface2); border-radius: 22px; padding: 24px 26px;
box-shadow: inset 0 0 0 1px var(--hair), var(--shadow);
margin: 18px 0;
}
.card__title { font-size: 15px; font-weight: 700; margin: 0 0 4px; }
.card__note { font-size: 13px; color: var(--c-dim); margin: 0; }
.card > :first-child { margin-top: 0; }
.card > :last-child { margin-bottom: 0; }
.grid2 { display: grid; gap: 16px; }
.grid3 { display: grid; gap: 16px; }
@media (min-width: 760px) {
.grid2 { grid-template-columns: repeat(2, minmax(0,1fr)); }
.grid3 { grid-template-columns: repeat(3, minmax(0,1fr)); }
}
/* stat tile */
.stat { background: var(--c-surface2); border-radius: 22px; padding: 20px 22px;
box-shadow: inset 0 0 0 1px var(--hair), var(--shadow); }
.stat__num { font-size: 30px; font-weight: 800; letter-spacing: -0.02em; line-height: 1.1; }
.stat__lbl { font-size: 12px; color: var(--c-dim); margin-top: 4px; }
.stat--ok .stat__num { color: var(--ok); }
.stat--warn .stat__num { color: var(--warn); }
.stat--danger .stat__num { color: var(--danger); }
.stat--accent .stat__num { color: var(--c-accent); }
/* ---------- callouts ---------- */
.callout {
display: flex; gap: 14px; padding: 16px 20px; border-radius: 18px; margin: 18px 0;
background: var(--c-surface); box-shadow: inset 0 0 0 1px var(--hair);
}
.callout__icon {
flex: none; width: 30px; height: 30px; border-radius: 14px; display: grid; place-items: center;
background: var(--accent-soft); color: var(--c-accent); font-size: 15px; font-weight: 800;
}
.callout__body { min-width: 0; }
.callout__body > :first-child { margin-top: 0; }
.callout__body > :last-child { margin-bottom: 0; }
.callout__title { display: block; font-weight: 700; margin-bottom: 2px; }
.callout p { font-size: 14.5px; color: var(--c-muted); }
.callout--warn .callout__icon { background: rgba(245,158,11,.15); color: var(--warn); }
.callout--danger .callout__icon { background: rgba(239,68,68,.15); color: var(--danger); }
.callout--info .callout__icon { background: rgba(59,130,246,.15); color: var(--info); }
.callout--ok .callout__icon { background: rgba(34,197,94,.15); color: var(--ok); }
/* ---------- code ---------- */
.code__cap {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
color: var(--c-dim); margin: 18px 0 -8px; display: block;
}
pre.code {
/* white-space MUST stay pre/pre-wrap or the browser eats the newlines */
white-space: pre; overflow-x: auto; -webkit-overflow-scrolling: touch;
background: var(--c-surface); border-radius: 18px; padding: 18px 20px; margin: 18px 0;
box-shadow: inset 0 0 0 1px var(--hair);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.8px; line-height: 1.65; color: var(--c-fg); tab-size: 2;
}
/* One line per block span. Mixing block spans with raw newlines inside a `pre`
yields a blank line between every pair, so wrap EVERY line — context included —
in .ln/.add/.del/.hl and leave NO newline between them. */
pre.code .ln { display: block; }
/* A deliberately blank line — <span class="ln"></span> — would otherwise collapse
to zero height and lose the separation it was put there for. */
pre.code .ln:empty::after { content: '\200b'; }
/* Added/removed lines carry their tint on the BACKGROUND only, with the +/- marker
coloured, so syntax highlighting shows through in true colours the way a diff
viewer does. Do not put a `color` back on these. */
pre.code .add { display: block; background: rgba(34,197,94,.10); margin: 0 -20px; padding: 0 20px; }
pre.code .del { display: block; background: rgba(239,68,68,.10); margin: 0 -20px; padding: 0 20px; }
pre.code .hl { display: block; background: var(--accent-soft); margin: 0 -20px; padding: 0 20px; }
pre.code .cmt { color: var(--c-dim); } /* inline — nest inside .ln */
/* Syntax tokens, applied by the highlighter in the script below. The palette is
the design system's own — accent amber for keywords, the sanctioned green and
blue status steps for literals, --c-dim for comments — so a code block reads as
part of this app rather than as a vendor theme dropped into it. Measured as bare
text on the code surface #1a1613: accent 10.9:1, green-500 8.1:1, blue-500
4.9:1, dim 5.6:1 — all clear AA for normal text. Never soften one of these with
opacity; that is what drops a semantic colour under AA. */
pre.code .t-k { color: var(--c-accent); } /* keyword */
pre.code .t-s { color: var(--ok); } /* string */
pre.code .t-n { color: var(--info); } /* number, literal */
pre.code .t-c { color: var(--c-dim); } /* comment */
pre.code .t-p { color: var(--c-muted); } /* punctuation */
pre.code .t-f { color: var(--c-fg); font-weight: 600; } /* callee / type name */
pre.code .t-d { font-weight: 700; } /* the +/- marker */
pre.code .add .t-d { color: var(--ok); }
pre.code .del .t-d { color: var(--danger); }
code:not(pre code) {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.88em;
background: var(--hover); padding: 1.5px 6px; border-radius: 7px; color: var(--c-fg);
}
/* ---------- tables ---------- */
.table-scroll { overflow-x: auto; margin: 18px 0; }
table.data { width: 100%; border-collapse: collapse; font-size: 14px; min-width: 520px; }
table.data th {
text-align: left; font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--c-dim); padding: 0 14px 10px; white-space: nowrap;
}
table.data td { padding: 11px 14px; border-top: 1px solid var(--hair-soft); vertical-align: top; }
table.data tr:hover td { background: var(--hover); }
table.data td.mono, table.data td .mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px;
}
/* For a short status/verdict cell that should never wrap mid-label. */
table.data td.nowrap { white-space: nowrap; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 9999px; margin-right: 8px; vertical-align: 1px; }
.dot--ok { background: var(--ok); } .dot--warn { background: var(--warn); }
.dot--none { background: var(--danger); } .dot--dim { background: var(--c-dim); }
/* ---------- key/value list ---------- */
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 8px 20px; margin: 16px 0; font-size: 14px; }
.kv dt { color: var(--c-dim); }
.kv dd { margin: 0; }
/* ---------- diagrams ---------- */
.diagram {
background: var(--c-surface); border-radius: 22px; padding: 26px 24px; margin: 22px 0;
box-shadow: inset 0 0 0 1px var(--hair);
}
.diagram__cap { font-size: 12.5px; color: var(--c-dim); margin-top: 16px; text-align: center; }
.flow { display: flex; align-items: stretch; gap: 12px; flex-wrap: wrap; justify-content: center; }
.flow--v { flex-direction: column; align-items: center; }
.node {
flex: 1 1 170px; min-width: 150px; max-width: 260px;
background: var(--c-surface2); border-radius: 14px; padding: 14px 16px;
box-shadow: inset 0 0 0 1px var(--hair);
}
.node--accent { box-shadow: inset 0 0 0 1px rgba(251,191,36,.4); }
.node__label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--c-dim); }
.node__title { font-weight: 700; font-size: 14.5px; margin-top: 3px; }
.node__data {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px;
color: var(--c-muted); margin-top: 8px; white-space: pre-wrap; word-break: break-word;
}
.arrow { flex: none; align-self: center; color: var(--c-dim); font-size: 18px; padding: 0 2px; }
.arrow__lbl { display: block; font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em; text-align: center; }
/* simplified UI mock */
.ui { background: var(--c-surface2); border-radius: 18px; overflow: hidden;
box-shadow: inset 0 0 0 1px var(--hair); max-width: 460px; }
.ui__bar { background: var(--hover); padding: 9px 14px; font-size: 12px; font-weight: 700; color: var(--c-muted);
border-bottom: 1px solid var(--hair-soft); }
.ui__body { padding: 14px; display: grid; gap: 8px; }
.ui__row { display: flex; justify-content: space-between; gap: 12px; font-size: 13px;
padding: 9px 12px; border-radius: 12px; background: var(--hover); }
.ui__row--new { background: var(--accent-soft); color: var(--c-accent); font-weight: 700; }
.ui__ghost { height: 9px; border-radius: 9999px; background: var(--hover); }
/* ---------- layer badges ---------- */
.layer {
font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
padding: 3px 9px; border-radius: 9999px; background: var(--hover); color: var(--c-dim);
}
.layer--product { background: rgba(59,130,246,.15); color: var(--info); }
.layer--reviewer { background: var(--accent-soft); color: var(--c-accent); }
.layer--deep { background: var(--hover); color: var(--c-muted); }
/* ---------- collapsible ---------- */
details.more {
background: var(--c-surface); border-radius: 18px; margin: 18px 0; overflow: hidden;
box-shadow: inset 0 0 0 1px var(--hair);
}
details.more > summary {
cursor: pointer; padding: 15px 20px; font-weight: 700; font-size: 14.5px; list-style: none;
display: flex; align-items: center; gap: 10px;
}
details.more > summary::-webkit-details-marker { display: none; }
details.more > summary::before {
content: '▸'; color: var(--c-accent); font-size: 12px; transition: transform .2s var(--ease);
}
details.more[open] > summary::before { transform: rotate(90deg); }
details.more > summary:hover { background: var(--hover); }
details.more .more__body { padding: 0 20px 18px; }
details.more .more__body > :first-child { margin-top: 0; }
/* ---------- mockup panel: before/after wipe, and the strip fallback ----------
Both are driven by the JSON inside .mockup; see references/mockup-panel.md.
Everything here is images and one clip-path — no iframe, no mockup JS. */
.mockup { margin: 18px 0 4px; }
.mockup__bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.mockup__src { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
font-size: 12px; color: var(--c-dim); }
.mockup__sides { display: flex; justify-content: space-between; margin: 0 4px 8px; }
.mockup__box { background: var(--c-surface); border-radius: 18px; padding: 10px;
box-shadow: inset 0 0 0 1px var(--hair), var(--shadow); }
.wipe { position: relative; overflow: hidden; border-radius: 11px;
background: var(--c-canvas); touch-action: none; user-select: none; }
.wipe__side { position: absolute; top: 0; left: 0; width: 100%; height: 100%;
transform: translateZ(0); backface-visibility: hidden; }
.wipe__side img { display: block; width: 100%; height: auto; }
.wipe__side--after { will-change: clip-path; }
.wipe__grip { position: absolute; top: 0; bottom: 0; left: 0; width: 2px; z-index: 9;
background: var(--c-accent); cursor: col-resize; will-change: transform;
box-shadow: 0 0 0 1px rgba(0,0,0,0.45); }
.wipe__knob { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
width: 36px; height: 36px; border-radius: 9999px; display: grid; place-items: center;
background: var(--c-accent); color: var(--c-accent-ink);
font-weight: 800; font-size: 14px; box-shadow: 0 6px 18px rgba(0,0,0,0.45); }
.wipe--sbs { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; height: auto !important; }
.wipe--sbs .wipe__side { position: relative; width: auto; height: auto; clip-path: none !important; }
.wipe--sbs .wipe__grip { display: none; }
/* A ring marks one changed region. Never faded to "soften" it — see the design
system; a ghost (the container a removal left) is dashed instead. */
.rings { position: absolute; inset: 0; pointer-events: none; z-index: 6; }
.ring { position: absolute; border-radius: 9px; transition: background .2s var(--ease); }
.ring[data-kind=removed] { box-shadow: 0 0 0 2px var(--danger); }
.ring[data-kind=changed] { box-shadow: 0 0 0 2px var(--c-accent); }
.ring[data-kind=renamed] { box-shadow: 0 0 0 2px var(--info); }
.ring[data-kind=added] { box-shadow: 0 0 0 2px var(--ok); }
.ring--ghost { box-shadow: none !important; outline: 2px dashed var(--danger); border-radius: 11px; }
.ring.is-hot { background: rgba(251,191,36,0.14); }
.ring.is-hot[data-kind=removed] { background: rgba(239,68,68,0.16); }
.ring.is-hot[data-kind=renamed] { background: rgba(59,130,246,0.16); }
.ring.is-hot[data-kind=added] { background: rgba(34,197,94,0.16); }
.chips { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 14px; }
.chg { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px 6px 8px;
border-radius: 9999px; background: var(--c-surface); cursor: pointer; font: inherit;
font-size: 12.5px; color: var(--c-muted); border: 0;
box-shadow: inset 0 0 0 1px var(--hair);
transition: background .16s var(--ease), box-shadow .16s var(--ease); }
.chg:hover, .chg.is-on { background: var(--c-surface2); color: var(--c-fg);
box-shadow: inset 0 0 0 1.5px var(--c-accent); }
.chg i { width: 8px; height: 8px; border-radius: 3px; display: inline-block; }
.chg[data-kind=removed] i { background: var(--danger); }
.chg[data-kind=changed] i { background: var(--c-accent); }
.chg[data-kind=renamed] i { background: var(--info); }
.chg[data-kind=added] i { background: var(--ok); }
.chg[data-kind=nonvisual] i { background: transparent; box-shadow: inset 0 0 0 1.5px var(--c-dim); }
.detail { margin-top: 14px; background: var(--c-surface); border-radius: 16px;
padding: 16px 18px; box-shadow: inset 0 0 0 1px var(--hair); min-height: 92px; }
.detail__title { font-weight: 700; font-size: 15px; }
.detail__why { color: var(--c-muted); font-size: 13.6px; margin: 6px 0 0; }
.detail__files { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; }
.filechip { font-family: ui-monospace, Menlo, monospace; font-size: 10.5px; color: var(--c-dim);
background: var(--c-surface2); padding: 2px 7px; border-radius: 6px;
box-shadow: inset 0 0 0 1px var(--hair-soft); }
/* Strip fallback: one cropped before/after pair per change, out of the same stills. */
.strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 16px; }
@media (max-width: 900px) { .strip { grid-template-columns: 1fr; } }
.sc { background: var(--c-surface); border-radius: 18px; overflow: hidden;
box-shadow: inset 0 0 0 1px var(--hair); }
.sc__head { padding: 15px 18px 12px; display: grid; grid-template-columns: 26px 1fr; gap: 11px; }
.sc__n { width: 26px; height: 26px; border-radius: 9999px; display: grid; place-items: center;
font-size: 12px; font-weight: 800; background: var(--c-accent); color: var(--c-accent-ink); }
.sc[data-kind=removed] .sc__n { background: var(--danger); color: #fff; }
.sc[data-kind=renamed] .sc__n { background: var(--info); color: #fff; }
.sc[data-kind=added] .sc__n { background: var(--ok); color: #08290f; }
.sc[data-kind=nonvisual] .sc__n { background: transparent; color: var(--c-dim);
box-shadow: inset 0 0 0 1.5px var(--hair); }
.sc__body { padding: 0 18px 14px 55px; }
.sc__why { color: var(--c-muted); font-size: 13px; margin: 0; }
.sc__pair { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--hair-soft); }
.sc__half { position: relative; overflow: hidden; background: var(--c-canvas); }
.sc__lbl { position: absolute; top: 8px; left: 10px; z-index: 4; font-size: 9.5px; font-weight: 800;
letter-spacing: .09em; text-transform: uppercase; color: var(--c-dim);
background: rgba(13,10,8,0.85); padding: 3px 7px; border-radius: 6px; }
.crop { position: relative; overflow: hidden; height: 186px; background: var(--c-canvas); }
.crop img { position: absolute; top: 0; left: 0; max-width: none; }
.sc__none { padding: 18px 18px 20px 55px; color: var(--c-dim); font-size: 13px; }
/* Ballot: candidate designs from a comparison mockup. Static markup, no script. */
.ballot { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 14px; }
.vb { display: flex; flex-direction: column; background: var(--c-surface); border-radius: 18px;
overflow: hidden; box-shadow: inset 0 0 0 1px var(--hair); }
.vb--chosen { box-shadow: inset 0 0 0 1.5px var(--c-accent), var(--shadow); }
.vb__head { padding: 14px 16px 10px; display: flex; align-items: center; gap: 9px;
justify-content: space-between; }
.vb__head h3 { margin: 0; font-size: 14.5px; font-weight: 700; }
.vb img { display: block; width: 100%; height: auto;
border-top: 1px solid var(--hair-soft); border-bottom: 1px solid var(--hair-soft); }
.vb__body { padding: 12px 16px 16px; }
.vb ul { margin: 0; padding: 0; list-style: none; font-size: 12.7px; color: var(--c-muted); }
.vb li { display: grid; grid-template-columns: 14px 1fr; gap: 7px; margin-top: 6px; line-height: 1.45; }
.vb li b { font-weight: 800; }
.vb .plus b { color: var(--ok); }
.vb .minus b { color: var(--danger); }
/* ---------- footer ---------- */
.foot { margin-top: 60px; padding-top: 24px; border-top: 1px solid var(--hair-soft);
font-size: 12.5px; color: var(--c-dim); display: flex; flex-wrap: wrap; gap: 8px 22px; }
/* ---------- Soft-Dock entrance ---------- */
@keyframes rise { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } }
.rise { animation: rise .62s var(--ease) both; animation-delay: calc(var(--i, 0) * 60ms); }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
@keyframes rise { from { opacity: 0; } to { opacity: 1; } }
.rise { animation-duration: .3s; }
}
/* ---------- print ----------
Enough that Cmd-P in a browser gives something honest: the page keeps its
own colours, the sidebar goes, and nothing sits in a sideways scroller where
the overflow would simply be absent on paper.
This is deliberately the short version. `/explain-pr --pdf` does not rely on
it — references/render-pdf.mjs injects references/print.css, which is the
full treatment (cover page, per-section page breaks, mockup panels rebuilt
as static stills with a printed legend) and reaches explainers generated
before any of it existed. Add rules THERE, not here. */
@media print {
*, *::before, *::after { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { background: var(--c-canvas); background-image: none; color: var(--c-fg); }
.toc { display: none; }
.wrap { max-width: none; padding: 0 12mm 8mm; }
.layout { display: block; }
pre.code { white-space: pre-wrap; overflow: visible; overflow-wrap: anywhere; }
.table-scroll { overflow: visible; }
table.data { min-width: 0; }
.card, .callout, .diagram, .mockup, pre.code, table.data tr { break-inside: avoid; }
.rise { animation: none !important; opacity: 1 !important; transform: none !important; }
}
</style>
</head>
<body>
<!--
============================================================================
MARKUP VOCABULARY — everything below is already styled. Compose from these;
do not invent new classes or inline colors, and never use a raw hex.
SECTION <section class="sec" id="slug" data-layer="product|reviewer|deep">
<div class="sec__head"><h2>Title</h2></div> <- layer badge auto-injected
<p class="sec__lede">One-line framing.</p>
PILL <span class="pill pill--ok|warn|danger|info|accent|mono">text</span>
CARD <div class="card"><p class="card__title">..</p>..</div>
GRID <div class="grid2|grid3">..cards/stats..</div>
STAT <div class="stat stat--warn"><div class="stat__num">7</div>
<div class="stat__lbl">files with no test</div></div>
CALLOUT <div class="callout callout--warn"><div class="callout__icon">!</div>
<div class="callout__body"><strong class="callout__title">..</strong>
<p>..</p></div></div>
CODE <span class="code__cap">path/to/file.ts</span>
<pre class="code" data-lang="ts"><span class="ln">context line</span><span
class="del">- old line</span><span class="add">+ new line</span></pre>
EVERY line gets .ln / .add / .del, and there is NO newline between the
spans — a newline between two block spans renders as a blank line.
Escape &lt; &gt; &amp; inside code.
data-lang="ts" (default) | "sql" | "graphql" — syntax highlighting is
applied by the script; do NOT hand-colour tokens. A GraphQL SDL block
and a block of SQL lifted out of a .sql.ts file each need their own
data-lang, whatever the enclosing file's extension is.
TABLE <div class="table-scroll"><table class="data"><thead>..<tbody>..</table></div>
status cell: <td class="nowrap"><span class="dot dot--ok"></span>label</td>
(.nowrap keeps a short verdict label off two lines; .mono for paths)
KV <dl class="kv"><dt>Key</dt><dd>Value</dd></dl>
DIAGRAM <div class="diagram"><div class="flow">
<div class="node"><div class="node__label">Client</div>
<div class="node__title">reports.tsx</div>
<div class="node__data">branchId: 42</div></div>
<div class="arrow">→<span class="arrow__lbl">query</span></div>
..</div><p class="diagram__cap">Fig 1. ..</p></div>
(.flow--v stacks vertically; .node--accent highlights the changed one)
UI MOCK <div class="ui"><div class="ui__bar">Reports</div><div class="ui__body">
<div class="ui__row">Row <span>$12.00</span></div>
<div class="ui__row ui__row--new">New row <span>$4.00</span></div></div></div>
MOCKUP For a PR that carries a real mockup. Two baked stills of the same
screen, wiped between, with a ring on every region the PR changed.
<div class="mockup" data-shape="wipe"> <- or "strip"
<img data-state="before" alt=".." src="data:image/webp;base64,..">
<img data-state="after" alt=".." src="data:image/webp;base64,..">
<script type="application/json">{
"source": "docs/mockups/x.html",
"beforeLabel": "Today", "afterLabel": "Proposed",
"before": { "w": 1180, "h": 1009, "rects": { "teams": {"x":336,"y":48,"w":260,"h":34} } },
"after": { "w": 1180, "h": 898, "rects": { "teams": {"x":365,"y":48,"w":286,"h":34} } },
"changes": [{ "id":"teams", "n":"03", "kind":"changed",
"label":"Teams multi-select", "title":"..", "why":"..",
"files":["report-composer.tsx"] }]
}<\/script>
</div>
kind: removed | changed | renamed | added | nonvisual.
"ghost": true on a removal draws its "after" ring dashed, on the
container the thing left. A change with no rect in a state simply
gets no ring there. HOW TO BAKE THE STILLS AND MEASURE THE RECTS:
references/mockup-panel.md — do not eyeball coordinates.
BALLOT Static markup, for a mockup that compares candidate designs.
<div class="ballot"><div class="vb vb--chosen">
<div class="vb__head"><h3>A · ..</h3><span class="pill pill--accent">shipped</span></div>
<img alt=".." src="data:image/webp;base64,..">
<div class="vb__body"><ul>
<li class="plus"><b>+</b><span>..</span></li>
<li class="minus"><b>&minus;</b><span>..</span></li>
</ul></div></div>..</div>
COLLAPSE <details class="more"><summary>Skip unless..</summary>
<div class="more__body">..</div></details>
ANIMATE add class="rise" and style="--i:2" to stagger a group's entrance
============================================================================
-->
<div class="wrap">
<header class="hero rise">
<div class="hero__eyebrow">
<!-- FILL: one pill per PR ref, plus state -->
{{REF_PILLS}}
</div>
<h1>{{TITLE}}</h1>
<p class="hero__sub">{{ONE_LINE_SUMMARY}}</p>
<div class="hero__meta">{{META}}</div>
</header>
<div class="layout">
<nav class="toc" aria-label="Contents">
<p class="toc__title">Contents</p>
<div id="toc-links"><!-- auto-generated from sections --></div>
</nav>
<main>
<!-- FILL: sections, in this order.
1 what-shipped data-layer="product"
2 contract data-layer="reviewer"
3 blast-radius data-layer="reviewer"
4 test-map data-layer="reviewer"
5 background data-layer="deep"
6 intuition data-layer="deep"
7 code data-layer="deep"
-->
{{SECTIONS}}
<footer class="foot">
<span>Generated {{GENERATED_AT}}</span>
<span>{{SOURCE_REFS}}</span>
<span>Uptip PR explainer</span>
</footer>
</main>
</div>
</div>
<script>
(function () {
'use strict';
/* ======================================================================
Syntax highlighting.
A small tokenizer rather than a CDN library, for two reasons: these pages
are opened from disk and must not need the network, and the colours have to
be the design system's rather than a vendor theme's.
Languages are the three that turn up in uptip diffs. `data-lang` on the
<pre> picks one ('ts' | 'sql' | 'graphql'); 'ts' is the default. Rules are
tried in order at the current position, so comments must precede strings and
keywords must precede the catch-all identifier — which exists to consume a
whole word, guaranteeing the scanner only ever sits at a token boundary and
can never match a keyword inside an identifier.
====================================================================== */
var PLAIN = null;
var IDENT = [/^[A-Za-z_$][\w$]*/, PLAIN];
var WS = [/^\s+/, PLAIN];
var RULES = {
ts: [
WS,
[/^\/\/.*/, 't-c'],
[/^\/\*.*?\*\//, 't-c'],
[/^\/\*.*/, 't-c'],
[/^`(?:\\.|[^`\\])*`/, 't-s'],
[/^'(?:\\.|[^'\\])*'/, 't-s'],
[/^"(?:\\.|[^"\\])*"/, 't-s'],
[/^\/(?:\\.|\[(?:\\.|[^\]\\])*\]|[^/\\])+\/[gimsuy]*/, 't-s'],
[/^\d[\d_]*(?:\.\d+)?\b/, 't-n'],
[/^(?:abstract|as|async|await|boolean|break|case|catch|class|const|constructor|continue|declare|default|delete|do|else|enum|export|extends|false|finally|for|from|function|get|if|implements|import|in|instanceof|interface|is|keyof|let|new|null|number|of|private|protected|public|readonly|return|set|static|string|super|switch|this|throw|true|try|type|typeof|undefined|var|void|while|yield)\b/, 't-k'],
[/^[A-Za-z_$][\w$]*(?=\s*\()/, 't-f'],
IDENT,
[/^[{}()[\].,;:?!<>=+\-*/%&|^~]+/, 't-p'],
],
sql: [
WS,
[/^--.*/, 't-c'],
[/^'(?:''|[^'])*'/, 't-s'],
[/^\d+(?:\.\d+)?\b/, 't-n'],
[/^(?:ADD|ALL|ALTER|AND|AS|ASC|BINARY|BY|CASE|CAST|CHAR|COALESCE|CONCAT|COUNT|CREATE|DESC|DISTINCT|ELSE|END|EXISTS|FROM|GROUP|GROUP_CONCAT|HAVING|IF|IN|INDEX|INNER|IS|JOIN|LEADING|LEFT|LIKE|LIMIT|NOT|NULL|ON|OR|ORDER|OUTER|REPLACE|SELECT|SEPARATOR|SET|THEN|TRIM|UNIQUE|UPDATE|WHEN|WHERE|WITH)\b/i, 't-k'],
[/^[A-Za-z_][\w]*(?=\s*\()/, 't-f'],
[/^[A-Za-z_][\w]*/, PLAIN],
[/^[{}()[\].,;:?!<>=+\-*/%&|^~]+/, 't-p'],
],
graphql: [
WS,
[/^"""[\s\S]*?"""/, 't-s'],
[/^""".*/, 't-s'],
[/^#.*/, 't-c'],
[/^"(?:\\.|[^"\\])*"/, 't-s'],
[/^"[^"]*$/, 't-s'],
[/^@[A-Za-z_]\w*/, 't-k'],
[/^\d+(?:\.\d+)?\b/, 't-n'],
[/^(?:type|input|enum|interface|union|scalar|schema|query|mutation|subscription|fragment|on|implements|extend|directive|String|Int|Float|Boolean|ID|true|false|null)\b/, 't-k'],
IDENT,
[/^[{}()[\].,;:?!<>=+\-*/%&|^~]+/, 't-p'],
],
};
function esc(s) {
return s.replace(/[&<>]/g, function (c) {
return c === '&' ? '&amp;' : c === '<' ? '&lt;' : '&gt;';
});
}
function highlightLine(text, rules) {
var out = '';
var rest = text;
// The leading +/- of a diff line is a marker, not code — pull it off first so
// the tokenizer never reads it as an operator.
var mark = rest.match(/^([+-] ?)/);
if (mark) {
out += '<span class="t-d">' + esc(mark[1]) + '</span>';
rest = rest.slice(mark[1].length);
}
while (rest.length) {
var matched = false;
for (var i = 0; i < rules.length; i++) {
var hit = rules[i][0].exec(rest);
if (!hit || !hit[0].length) continue;
var cls = rules[i][1];
out += cls ? '<span class="' + cls + '">' + esc(hit[0]) + '</span>' : esc(hit[0]);
rest = rest.slice(hit[0].length);
matched = true;
break;
}
if (!matched) { out += esc(rest[0]); rest = rest.slice(1); }
}
return out;
}
try {
Array.prototype.forEach.call(document.querySelectorAll('pre.code'), function (pre) {
var rules = RULES[pre.getAttribute('data-lang') || 'ts'] || RULES.ts;
Array.prototype.forEach.call(pre.children, function (line) {
if (!/\b(?:ln|add|del|hl)\b/.test(line.className)) return;
line.innerHTML = highlightLine(line.textContent, rules);
});
});
} catch (e) {
/* A highlighting failure must never cost the page its contents list. */
}
/* ======================================================================
Mockup panel.
Markup contract, per panel (see references/mockup-panel.md for how to
produce the stills and the geometry):
<div class="mockup" data-shape="wipe|strip">
<img data-state="before" alt=".." src="data:image/webp;base64,..">
<img data-state="after" alt=".." src="data:image/webp;base64,..">
<script type="application/json">{ "before": {..}, "after": {..},
"changes": [..] }<\/script>
</div>
`before`/`after` are { w, h, rects: { <changeId>: {x,y,w,h} } } measured
against the stage box at the width the still was taken. Rects are scaled
to whatever width the panel renders at, so the page stays responsive.
A change with no rect in a state gets no ring in that state. That is the
rule, not a fallback: a ring whose element could not be found would be a
guess, and the caption says "no surface in this mockup" instead.
====================================================================== */
function buildMockups() {
Array.prototype.forEach.call(document.querySelectorAll('.mockup'), function (root) {
var json = root.querySelector('script[type="application/json"]');
var imgs = { before: root.querySelector('img[data-state=before]'),
after: root.querySelector('img[data-state=after]') };
if (!json || !imgs.before || !imgs.after) return;
var data;
try { data = JSON.parse(json.textContent); } catch (e) { return; }
var src = { before: imgs.before.getAttribute('src'), after: imgs.after.getAttribute('src') };
var alt = { before: imgs.before.getAttribute('alt') || 'before',
after: imgs.after.getAttribute('alt') || 'after' };
imgs.before.remove();
imgs.after.remove();
json.remove();
if (root.getAttribute('data-shape') === 'strip') buildStrip(root, data, src, alt);
else buildWipe(root, data, src, alt);
});
}
function ringEl(change, rect, ghost) {
var d = document.createElement('div');
d.className = 'ring' + (ghost ? ' ring--ghost' : '');
d.setAttribute('data-kind', change.kind);
d.setAttribute('data-id', change.id);
d.rect = rect;
return d;
}
function ringLayer(data, state, changes) {
var host = document.createElement('div');
host.className = 'rings';
changes.forEach(function (c) {
var rect = (data[state].rects || {})[c.id];
if (!rect) return;
host.appendChild(ringEl(c, rect, !!c.ghost && state === 'after'));
});
return host;
}
function buildWipe(root, data, src, alt) {
var changes = data.changes || [];
var maxH = Math.max(data.before.h, data.after.h);
var split = 0.5, mode = 'wipe', pinned = null, boxW = 0, scale = 1;
var bar = document.createElement('div');
bar.className = 'mockup__bar';
bar.innerHTML =
'<div class="seg"><button data-mode="wipe" class="is-on">Wipe</button>' +
'<button data-mode="flip">Flip</button>' +
'<button data-mode="sbs">Side by side</button></div>' +
(data.source ? '<span class="mockup__src">' + data.source + '</span>' : '');
var sides = document.createElement('div');
sides.className = 'mockup__sides';
sides.innerHTML = '<span class="pill">&#9664;&nbsp; ' + (data.beforeLabel || 'Before') + '</span>' +
'<span class="pill pill--accent">' + (data.afterLabel || 'After') + ' &nbsp;&#9654;</span>';
var box = document.createElement('div');
box.className = 'mockup__box';
var wipe = document.createElement('div');
wipe.className = 'wipe';
box.appendChild(wipe);
var layers = {};
['before', 'after'].forEach(function (state) {
var side = document.createElement('div');
side.className = 'wipe__side wipe__side--' + state;
var im = document.createElement('img');
im.src = src[state];
im.alt = alt[state];
side.appendChild(im);
side.appendChild(ringLayer(data, state, changes));
wipe.appendChild(side);
layers[state] = side;
});
var grip = document.createElement('div');
grip.className = 'wipe__grip';
grip.innerHTML = '<div class="wipe__knob">&#8596;</div>';
wipe.appendChild(grip);
var chips = document.createElement('div');
chips.className = 'chips';
var detail = document.createElement('div');
detail.className = 'detail';
root.appendChild(bar);
root.appendChild(sides);
root.appendChild(box);
root.appendChild(chips);
root.appendChild(detail);
function layout() {
boxW = wipe.clientWidth;
if (!boxW) return;
if (mode === 'sbs') { scale = ((boxW - 10) / 2) / data.before.w; wipe.style.height = ''; }
else { scale = boxW / data.before.w; wipe.style.height = Math.round(maxH * scale) + 'px'; }
var pad = 5 * scale;
['before', 'after'].forEach(function (state) {
Array.prototype.forEach.call(layers[state].querySelectorAll('.ring'), function (r) {
r.style.left = (r.rect.x * scale - pad) + 'px';
r.style.top = (r.rect.y * scale - pad) + 'px';
r.style.width = (r.rect.w * scale + pad * 2) + 'px';
r.style.height = (r.rect.h * scale + pad * 2) + 'px';
});
});
applySplit();
}
function applySplit() {
if (mode !== 'wipe') { layers.after.style.clipPath = 'none'; return; }
var px = Math.round(boxW * split);
layers.after.style.clipPath = 'inset(0 0 0 ' + px + 'px)';
grip.style.transform = 'translateX(' + px + 'px)';
}
/* Dragging is coalesced into one animation frame. Nothing polls: the only
other things that move the rings are a resize and a mode change. */
var down = false, pendingX = null, raf = null;
function commit() {
raf = null;
if (pendingX == null) return;
var r = wipe.getBoundingClientRect();
split = Math.max(0.01, Math.min(0.99, (pendingX - r.left) / r.width));
pendingX = null;
applySplit();
}
function move(ev) {
if (!down) return;
pendingX = ev.touches ? ev.touches[0].clientX : ev.clientX;
if (!raf) raf = requestAnimationFrame(commit);
if (ev.cancelable) ev.preventDefault();
}
grip.addEventListener('mousedown', function (e) { if (mode === 'wipe') { down = true; move(e); e.preventDefault(); } });
grip.addEventListener('touchstart', function (e) { if (mode === 'wipe') { down = true; move(e); } }, { passive: false });
window.addEventListener('mousemove', move);
window.addEventListener('touchmove', move, { passive: false });
window.addEventListener('mouseup', function () { down = false; });
window.addEventListener('touchend', function () { down = false; });
var flipTimer = null, flipOn = true;
bar.querySelector('.seg').addEventListener('click', function (ev) {
var b = ev.target.closest('button');
if (!b) return;
Array.prototype.forEach.call(this.children, function (x) { x.classList.toggle('is-on', x === b); });
mode = b.getAttribute('data-mode');
wipe.classList.toggle('wipe--sbs', mode === 'sbs');
if (flipTimer) { clearInterval(flipTimer); flipTimer = null; }
layers.after.style.display = '';
if (mode === 'flip') {
flipTimer = setInterval(function () {
flipOn = !flipOn;
layers.after.style.display = flipOn ? '' : 'none';
}, 1500);
}
layout();
});
function light(id) {
Array.prototype.forEach.call(wipe.querySelectorAll('.ring'), function (r) {
r.classList.toggle('is-hot', r.getAttribute('data-id') === id);
});
Array.prototype.forEach.call(chips.children, function (b) {
b.classList.toggle('is-on', b.getAttribute('data-id') === id);
});
var c = null;
changes.forEach(function (x) { if (x.id === id) c = x; });
detail.innerHTML = c
? '<div class="detail__title">' + (c.n ? c.n + ' &middot; ' : '') + c.title + '</div>' +
'<p class="detail__why">' + c.why + '</p>' +
(c.kind === 'nonvisual'
? '<p class="detail__why" style="color:var(--c-dim)"><b>No surface in this mockup</b> — nothing to ring.</p>'
: '') +
'<div class="detail__files">' + (c.files || []).map(function (f) {
return '<span class="filechip">' + f + '</span>';
}).join('') + '</div>'
: (data.detailDefault || '');
}
changes.forEach(function (c) {
var b = document.createElement('button');
b.className = 'chg';
b.setAttribute('data-kind', c.kind);
b.setAttribute('data-id', c.id);
b.innerHTML = '<i></i>' + (c.label || c.title);
b.addEventListener('mouseenter', function () { light(c.id); });
b.addEventListener('mouseleave', function () { light(pinned); });
b.addEventListener('click', function () {
pinned = (pinned === c.id) ? null : c.id;
light(pinned || c.id);
});
chips.appendChild(b);
});
light(null);
var rz = null;
window.addEventListener('resize', function () {
if (rz) return;
rz = requestAnimationFrame(function () { rz = null; layout(); });
});
layout();
/* Fonts and image decode can both land after the first layout. */
window.addEventListener('load', layout);
}
/* The strip is the same two stills, cropped per change. Both halves share
one scale — set by the smaller anchor, normally the "before" one — or a
removal's container anchor zooms the "after" half out to nothing. */
function buildStrip(root, data, src, alt) {
var host = document.createElement('div');
host.className = 'strip';
root.appendChild(host);
(data.changes || []).forEach(function (c) {
var card = document.createElement('div');
card.className = 'sc';
card.setAttribute('data-kind', c.kind);
card.innerHTML =
'<div class="sc__head"><div class="sc__n">' + (c.kind === 'nonvisual' ? '&#183;' : (c.n || '')) + '</div>' +
'<div><h3>' + c.title + '</h3></div></div>' +
'<div class="sc__body"><p class="sc__why">' + c.why + '</p>' +
'<div class="detail__files">' + (c.files || []).map(function (f) {
return '<span class="filechip">' + f + '</span>';
}).join('') + '</div></div>';
host.appendChild(card);
if (c.kind === 'nonvisual') {
var none = document.createElement('div');
none.className = 'sc__none';
none.textContent = c.noSurface || 'No visual surface — this change has nothing to point at.';
card.appendChild(none);
return;
}
var pair = document.createElement('div');
pair.className = 'sc__pair';
card.appendChild(pair);
['before', 'after'].forEach(function (state) {
var half = document.createElement('div');
half.className = 'sc__half';
half.innerHTML = '<span class="sc__lbl">' +
(state === 'before' ? (data.beforeLabel || 'before') : (data.afterLabel || 'after')) + '</span>';
var crop = document.createElement('div');
crop.className = 'crop';
half.appendChild(crop);
pair.appendChild(half);
var rect = (data[state].rects || {})[c.id];
if (!rect) {
crop.innerHTML = '<div class="sc__none" style="padding:16px">not present in this state</div>';
return;
}
requestAnimationFrame(function () {
var bw = crop.clientWidth, bh = crop.clientHeight;
var ref = (data.before.rects || {})[c.id] || rect;
var pad = c.pad || 26;
var k = Math.max(0.3, Math.min(1.6, bw / (ref.w + pad * 2)));
var tx = bw / 2 - (rect.x + rect.w / 2) * k;
var ty = bh / 2 - (rect.y + rect.h / 2) * k;
tx = Math.min(0, Math.max(tx, bw - data[state].w * k));
ty = Math.min(0, Math.max(ty, bh - data[state].h * k));
var im = document.createElement('img');
im.src = src[state];
im.alt = c.title + ' — ' + alt[state];
im.style.width = (data[state].w * k) + 'px';
im.style.transform = 'translate(' + tx + 'px,' + ty + 'px)';
crop.appendChild(im);
var r = ringEl(c, rect, !!c.ghost && state === 'after');
r.style.left = (tx + rect.x * k - 4) + 'px';
r.style.top = (ty + rect.y * k - 4) + 'px';
r.style.width = (rect.w * k + 8) + 'px';
r.style.height = (rect.h * k + 8) + 'px';
crop.appendChild(r);
});
});
});
}
try { buildMockups(); } catch (e) {
/* A malformed panel must not cost the page its contents list. */
}
var LAYER_NAMES = { product: 'Product', reviewer: 'Reviewer', deep: 'Deep dive' };
var sections = Array.prototype.slice.call(document.querySelectorAll('section.sec'));
// Layer badges + table of contents, both derived from the sections themselves.
var tocBox = document.getElementById('toc-links');
var lastLayer = null;
sections.forEach(function (sec) {
var layer = sec.getAttribute('data-layer');
var head = sec.querySelector('.sec__head');
if (layer && head && !head.querySelector('.layer')) {
var badge = document.createElement('span');
badge.className = 'layer layer--' + layer;
badge.textContent = LAYER_NAMES[layer] || layer;
head.appendChild(badge);
}
if (!tocBox) return;
if (layer && layer !== lastLayer) {
var grp = document.createElement('div');
grp.className = 'toc__layer';
grp.textContent = LAYER_NAMES[layer] || layer;
tocBox.appendChild(grp);
lastLayer = layer;
}
var h2 = sec.querySelector('h2');
if (!h2 || !sec.id) return;
var a = document.createElement('a');
a.href = '#' + sec.id;
a.textContent = h2.textContent;
a.setAttribute('data-for', sec.id);
tocBox.appendChild(a);
});
// Scrollspy.
var links = {};
Array.prototype.forEach.call(document.querySelectorAll('#toc-links a'), function (a) {
links[a.getAttribute('data-for')] = a;
});
if ('IntersectionObserver' in window && sections.length) {
var visible = {};
var obs = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { visible[e.target.id] = e.isIntersecting; });
var current = null;
sections.forEach(function (s) { if (visible[s.id] && !current) current = s.id; });
Object.keys(links).forEach(function (id) {
links[id].classList.toggle('is-active', id === current);
});
}, { rootMargin: '-10% 0px -70% 0px', threshold: 0 });
sections.forEach(function (s) { obs.observe(s); });
}
})();
</script>
</body>
</html>
name explain-pr
description Use when the user wants a rich visual explanation of an uptip PR or code change - "explain PR 484", "walk me through admin-app#96", "what does this PR do". Builds a self-contained dark-mode HTML dashboard (product summary, before/after of any mockup the PR carries, GraphQL contract diff, deploy blast radius, test map, deep-dive walkthrough) from one or two PR refs.

Explain PR

Build a single self-contained HTML page that explains an uptip code change to three readers at once: someone who wants to know what shipped, someone about to review or merge it, and someone who wants to actually understand the system.

Adapted from Geoffrey Litt's explain-diff, with panels specific to how uptip ships: two repos per feature, no staging, deploy:prod on push to master.

Arguments

/explain-pr main#484 admin-app#96        # a full-stack pair, told as ONE story
/explain-pr admin-app#96                 # single PR
/explain-pr https://github.com/uptip-inc/main/pull/484 --share
/explain-pr admin-app#99 --pdf           # also write the PDF of the page
  • One or two refs. Accept <repo>#<number> or a full GitHub URL; <repo> is one of main, admin-app, receiver-app (org is always uptip-inc).
  • --share additionally publishes the page as a private Claude Artifact and returns the URL. Without it nothing leaves the machine — do not publish unasked; the page contains production SQL, schema details and proprietary code.
  • --pdf additionally renders the page to a PDF beside it (section 4). Local only — it is a file on disk, not a publish. Combine freely with --share.
  • No refs given: ask which PR. Don't guess from the current branch.

Each repo is a separate git checkout under /Users/kristjanvool/Work/uptip/<repo>. gh needs to run inside the right one (or take --repo uptip-inc/<repo>).

1. Gather

Per ref:

cd /Users/kristjanvool/Work/uptip/<repo>
gh pr view <n> --json title,body,state,author,createdAt,mergedAt,baseRefName,headRefName,additions,deletions,changedFiles,files,url
gh pr diff <n>
gh api repos/uptip-inc/<repo>/pulls/<n>/comments --jq '.[] | {path, line, body, user: .user.login}'

Then:

  • Read the surrounding code, not just the diff. The Background section is worthless without it. Open the files the diff touches and the callers around them.

  • Follow the plan doc. Most uptip PRs reference a plan or spec under docs/superpowers/plans/ or docs/superpowers/specs/. Read it — it usually states the intent better than the PR body.

  • Large diffs: gh pr diff on a 4000-line PR is not worth reading whole. Take --name-only plus the diff of the files that carry the logic; skip generated files, lockfiles, snapshots and pure-formatting churn. Say in the Code section what you skipped.

  • Pairs: read both PRs before writing anything. The story is the feature, not either half of it.

  • Look for a mockup. Most admin-app UI PRs carry one — a self-contained HTML prototype under docs/mockups/, either added by the PR or named in its body:

    gh pr view <n> --json files --jq '.files[].path' | grep 'docs/mockups/'
    gh pr view <n> --json body --jq '.body' | grep -o 'docs/mockups/[a-z0-9-]*\.html'

    If there is one, read it — several state what changed and where better than the PR body does — and build the how-it-looks panel from it (section 2 below).

2. Sections

In this order. Each is a <section class="sec" id="..." data-layer="...">; the layer badge and the table of contents build themselves from those attributes.

# id layer content
1 what-shipped product What a manager or receiver can now do that they couldn't. Plain language, no file names, no jargon — define giver/receiver/branch/team inline the first time each appears. A .ui mock of the before/after beats a paragraph.
2 how-it-looks product Only when the PR carries a mockup. The mockup baked to two stills — before and after — wiped between, with a coloured ring on every region the PR changed and a chip per change. Full recipe in references/mockup-panel.md; do not improvise it.
3 contract reviewer Only for backend PRs or pairs. What changed in schema.ts / types.ts; which operations in the client's queries.ts consume it; a .flow diagram carrying real example data end to end. Flag any client field the schema no longer backs, and any new non-nullable field — that combination has broken this app before.
4 blast-radius reviewer What goes live the moment this merges: deploy:prod runs on push to master with no gate and there is no staging. List the affected lambdas or screens. Check main/packages/service-api/sequelize/migrations — is there a migration, and does its down actually reverse it? For admin-app, link the S3/CloudFront revert in admin-app/ROLLBACK.md. If the change touches money, payouts or Square/Stripe/Brex, say so in a .callout--danger.
5 test-map reviewer A table.data of every changed source file: test added / test updated / covered by an existing suite / nothing covers it. Lead with a .stat--warn counting that last bucket. Exclude docs and config from the count.
6 background deep How the existing system works. Two tiers: a beginner tier inside a <details class="more"> marked skippable, then the narrow background that bears directly on the change.
7 intuition deep The essence, with a concrete toy example carried all the way through. Diagrams, not prose, wherever a diagram fits.
8 code deep High-level walkthrough grouped by idea, not by file order. Short pre.code excerpts, each captioned with its path. Explain why, not what the diff already shows.

Drop section 3 for a client-only PR with no contract change, drop section 2 when there is no mockup, and drop a section entirely rather than filling it with padding. Say nothing rather than something empty.

The mockup panel (section 2)

Read references/mockup-panel.md before building it. In outline: find the mockup, decide what the "before" state is, drive a browser to bake one still per state, measure a rect per change against the stage box, and hand the shell two images plus that geometry. The shell does the rest.

Three rules are worth repeating here because getting them wrong is silent:

  • The panel is images. No iframe of the mockup, ever. Two WebP stills of a screen cost less than the mockup they replace, they drag at full frame rate, and they keep someone else's JavaScript out of the page.
  • Bake both states identically — same hidden chrome, same fixed width, stage margin zeroed, measured only after the mockup's own CSS transitions have settled. Rects taken under different conditions line up in one state and drift in the other.
  • A missing anchor is a caption, never a guess. A removal rings the container it left, dashed. A change with no visual surface at all keeps its chip and says so.

Two fallbacks, both documented in the same reference: data-shape="strip" (a cropped before/after pair per change, from the same stills) when the changes span more than one screen, and the static .ballot markup when the mockup compares candidate designs rather than showing one — bulk-assign-controls.html, team-breadcrumb-variants.html.

3. Write it

Start from references/shell.html — copy it, then replace the {{PLACEHOLDERS}} and the {{SECTIONS}} block. The markup vocabulary is documented in a comment inside that file, right above <div class="wrap">; compose from those classes.

Design rules (the shell encodes admin-app/design-system.md, dark column — you only have to not break it):

  • Never write a raw hex or invent a class. Every color is a token or a status var.
  • Never fade a status color with opacity to make it "softer" — that drops it under WCAG AA. Use --c-muted / --c-dim instead.
  • Headings 800, values 700, pills 11px uppercase with .06em tracking.
  • Add class="rise" (with style="--i:1", --i:2… to stagger) to a group of cards for the Soft-Dock entrance. Reduced motion is already handled.

Writing: clarity and flow in the style of Martin Kleppmann — engaging, classic style, smooth transitions between sections. One long scrolling page; no tabs at the top level.

Diagrams: pick two or three families and reuse them — a .flow for data moving between components, a .ui for what the manager sees. Always include example data in the nodes; an abstract box labelled "Resolver" teaches nothing. No ASCII diagrams, ever.

Code blocks: always <pre class="code">. Anything with white-space: normal collapses every newline into one line — the shell's pre.code already sets pre, so just use it. Tag each block with data-langts (the default), sql or graphql — and the shell highlights it for you; never colour a token by hand. Judge the language by the block's contents, not the file's extension: the SQL lifted out of a .sql.ts file is sql, and an SDL block out of schema.ts is graphql.

4. Output

One folder per explainer. Create ~/Work/uptip/explain-pr/YYYY-MM-DD-<slug>/ and write the page to index.html inside it, where the slug names the refs and the feature, e.g. 2026-09-11-main489-brex-vendor-payment-accounts/index.html. Date-prefixed so the directory sorts by time.

Everything this run produces goes in that one folder and nowhere else:

2026-09-11-main489-brex-vendor-payment-accounts/
├── index.html        the explainer
├── explainer.pdf     --pdf only
├── sections.html     the {{SECTIONS}} block, so the page can be re-assembled
│                     against a changed shell without rewriting a word
├── how-it-looks.html the mockup panel, when there is one (same reason)
└── shots/            pages rasterised while verifying. Disposable.

Write sections.html (and how-it-looks.html) as you go, not afterwards — they are the parts you already composed, and keeping them costs nothing at the time and saves the whole analysis when shell.html next changes.

explain-pr/ sits at the uptip root, which is not a git repository, and it is outside main/, admin-app/ and receiver-app/ — so a page carrying production SQL and schema details cannot be committed from there. Never write an explainer inside one of the three repos.

Then open the index.html in the browser and give the user the path.

With --pdf: also render explainer.pdf into the same folder.

node .claude/skills/explain-pr/references/render-pdf.mjs <folder>/index.html <folder>/explainer.pdf

That script is the whole PDF path. It drives Chrome over the DevTools protocol (no puppeteer on this machine), injects references/print.css, rebuilds each mockup panel as two stacked stills with a numbered legend, and prints with zero side margins so the dark canvas bleeds to the paper edge. Do not add print rules to the generated page — they belong in print.css, which is injected at render time and therefore also fixes explainers generated before it existed. Three things it is worth knowing you get for free: the hero becomes a cover page, every section starts a new page, and the footer carries the title and page / total.

The page needs no special markup for any of this, so a PDF can be re-rendered from an explainer that already exists — including one written months ago.

With --share: also publish it with the Artifact tool and return the URL. A published artifact must not carry its own wrapper — the tool supplies <!doctype>, <html>, <head> and <body> itself, so publish a second file holding only <title>, the font <link>s, <style>, the page content and <script>, with those wrapper tags and the <meta>/favicon lines stripped. Publishing the standalone file double-wraps it. Keep the <title> as the feature name and pass a one-sentence description; the Google-Fonts stylesheet is allowed by the artifact CSP, so the type survives. A mockup panel survives too — its stills are data: URIs, which is the only way an image reaches a published artifact. The standalone local file is still written and opened as usual — --share adds a copy, it does not replace one.

5. Before you call it done

  • Every <section> has an id and a data-layer, or it vanishes from the contents.

  • Every code block is a pre.code with a data-lang.

  • No TODO, no placeholder text, no {{...}} left in the file.

  • If there is a mockup panel: look at it, at the real width, and confirm every ring sits on its element in both states — a rect measured under the wrong conditions lands plausibly but wrong, and only the rendered page shows it. iframe count must be zero.

  • Open the file and confirm it renders — a broken <script> silently costs you the contents list, the layer badges and every code block's highlighting. Reload with the cache ignored: a fragment-only navigation does not re-read the file from disk, so you can screenshot a stale copy and think you verified the new one.

  • With --pdf: look at the pages, do not just check the file exists. The renderer prints its own count — 2 printed of 2 — and a panel that silently failed to convert shows up there first. Then rasterise a spread and open them:

    node .claude/skills/explain-pr/references/pdf-pages.mjs \
         <folder>/explainer.pdf <folder>/shots 1,4,9

    sips only ever renders page 1 and there is no pdftoppm on this machine, which is why that helper exists. It takes an optional scale — … <folder>/shots 2 3 renders page 2 at 3x, and some faults are invisible at 100%. The shots land inside the explainer's own folder; leave them there or delete them, but never beside another PR's.

    What actually goes wrong, in the order it has gone wrong:

    • a mockup still sliced mid-UI across a page break;
    • a page four-fifths empty because something taller than the remaining space would not break — check print.css has not acquired a container in its break-inside: avoid list, and that render-pdf.mjs still reports boxes "allowed to split";
    • code or a table running past the right edge, where the lost text leaves no mark;
    • a card whose background looks unfinished, with a faint rectangle cutting across it. That is a blurred box-shadow: Chrome cannot put one in a PDF, so it rasterises the box with the backdrop composited in. Print removes --shadow for exactly this reason. It only shows above 1:1, so look at 3x before believing a card is clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment