---
name: docs-relevance
description: >-
  Check that a project's documentation still matches the code and that what should be documented is.
  TRIGGER when: /commit step 4 runs it, or the user asks whether the docs are stale, out of date, or missing a feature.
  DO NOT TRIGGER when: the task is how a document is written rather than whether it is true — that is `docs-style`.
user-invocable: false
context: fork
effort: medium
background: true
allowed-tools: Read, Edit, Write, Bash(python ~/.claude/scripts/worktree-sandbox.py:*), Bash(cd:*), Bash(git add:*), Bash(git diff:*), Bash(git status:*), Bash(git rev-parse:*), Bash(git ls-files:*), Bash(git grep:*), Bash(git log:*), Bash(file:*), Bash(cp:*), Bash(ls:*), Bash(mkdir:*), Bash(bash docs/screenshots/capture/:*), Bash(pwsh -File docs/screenshots/capture/:*)
---

<!--
This skill runs as a backgrounded forked subagent, which has three consequences its steps must respect.

A fork starts with a fresh context: it gets this file and CLAUDE.md, and sees nothing of the conversation
that invoked it. Every step below therefore derives what it needs from the repository, never from what was
discussed. A step that needs the conversation does not belong here.

A background fork runs with a narrower tool set than a normal turn, and `Grep` and `Glob` are not in it —
measured 2026-09-30. Use `git grep` (add `--untracked` to reach new files) and `git ls-files` instead; a
step written around the missing tools loses itself silently rather than failing. `allowed-tools` above no
longer lists them for that reason.

Edits made here land outside the session's checkpoints, so `/rewind` will not undo them. That is why
step 0 moves the work into a sandbox and this skill never writes to the user's tree at all.
-->


# Update Documentation

Scan project documentation and comments for references that no longer match the code, and fix them.

## Context
- Repo root: !`git rev-parse --show-toplevel 2>/dev/null || pwd`
- Uncommitted changes: !`git status --short`
- Diff summary: !`git diff --stat $(git rev-parse -q --verify HEAD || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)`
- Full diff: !`git diff --no-textconv $(git rev-parse -q --verify HEAD || echo 4b825dc642cb6eb9a060e54bf8d69288fbee4904)`
- Code in unpushed commits (empty when there is none; read it with `git log -p --no-textconv HEAD --not --remotes`): !`git log --stat --oneline HEAD --not --remotes`
- GH Pages index present: !`R=$(git rev-parse --show-toplevel 2>/dev/null || pwd) && test -f "$R/docs/index.md" && echo yes || echo no`
- Doc image files: !`git ls-files --full-name -co --exclude-standard -- ':/docs/*.png' ':/docs/*.jpg' ':/docs/*.jpeg' ':/docs/*.gif' ':/docs/*.webp' ':(top,exclude,glob)docs/**/raw/**' | grep . || echo NONE`
- Screenshot manifest: !`R=$(git rev-parse --show-toplevel 2>/dev/null || pwd) && cat "$R/docs/screenshots/screenshots.json" 2>/dev/null || echo MISSING`
- Remotes, nothing above `(end of remotes)` meaning none: !`git remote -v; echo "(end of remotes)"`

## Working directory

All file paths below (`README.md`, `docs/`, `docs/pages/`, `docs/index.md`, `docs/screenshots/`, `CLAUDE.md`) are relative to the root of the **sandbox** step 0 creates, not to **Repo root** from Context. Those two are different directories, and Repo root is the user's tree, which this skill must not write to — prefix the sandbox path on every Read/Edit/Write and `git` call once step 0 has run. The Context probes above were taken in the real repo before the sandbox existed, which is correct: the sandbox is built to mirror exactly that state, so a diff quoted there describes the sandbox too.

Bare paths are resolved against the current working directory, so they are only safe after step 0's `cd` and never before.

## Process

**First, read the project's own guidelines, and let them override any step below.** A project `CLAUDE.md` — `.claude/CLAUDE.md` where it exists, otherwise the one at the repo root — is the only place a repo can say how it wants to be documented, and this skill has opinions that a repo is entitled to refuse: that a features list should exist, that a page describing something visual wants a picture, that a project with no images is the one most likely to want its first. Those are defaults, not requirements. Where the project says otherwise, the project wins, and the step is skipped rather than argued with.

Say in the report which of its rules applied, so a skipped step reads as a decision already taken rather than as a check that quietly did not run. Step 5 reads the same file for staleness — that is a different job, and finding a rule here does not excuse checking whether the rest of it is still true.

A repo that says nothing gets every step as written. Never infer a rule from the absence of something: a project with no screenshots has not thereby declined them, which is exactly the case step 4 is built to raise.

**Documentation another account owns is reported and nothing else.** Read **Remotes** from Context: an `upstream` pointing at an account that is not the user's marks a fork, and an `origin` that does marks a clone of someone else's project, where every file is upstream's. The files that account wrote are not this skill's to edit, so a stale screenshot or a comment that omits a field there is reported in step 9 and left alone — no patch for it, and **no memo**, because the backlog is work this repo has undertaken and upstream's documentation is not it. Two exceptions, where the finding is really the user's: the gap touches a change they carry on their own branch, or it belongs to an issue they have already raised upstream. Anything else reaches upstream only as an issue they ask for, so never offer one unprompted and never open the offer by proposing a memo. The project's own code and the user's own docs are checked as written in every step below; it is only the verdict on an upstream-owned file that stops at reporting.

0. **Work in a sandbox, not the user's tree.** Run `python ~/.claude/scripts/worktree-sandbox.py create --name docs-relevance` and `cd` to the path it prints on its last line. That is a detached git worktree outside the repo, already holding the pending change set — the uncommitted edits, the new files, the deletions — so every step below sees exactly what the caller sees. Do all of your reading and every edit there.

   Nothing this skill does reaches the user's tree, which is the point: a fork's writes land outside the session's checkpoints, so `/rewind` cannot undo them, and the caller applies a patch it has reviewed instead. Two consequences to respect. **Ignored files are not carried in** — the script says so when it runs — so a step needing a build artifact must copy it in itself rather than concluding it is absent. And a file you *create* is untracked in the sandbox and therefore absent from the patch, so `git add -N <path>` it as you go; `patch` warns on stderr about any you missed, and that warning is the one output of this flow that must never be ignored.

   When the carry-in cannot run at all — not a git repo, or the worktree add fails — say so and stop rather than falling back to editing the real tree. The caller's step 4 treats a missing sandbox as a reason to run this skill in the main session, where the checkpointing works.

   At the end, emit the patch with `python ~/.claude/scripts/worktree-sandbox.py patch <sandbox> --binary` (the flag is what carries a screenshot change), save it inside the repo's gitignored `tmp/`, and report that path plus a plain-language summary of every file it touches. Then tear the sandbox down with the `remove` subcommand. Report the patch path even when the patch is empty, so "nothing to fix" and "the run failed" cannot be confused.

1. **Read `README.md`** (at the repo root) and fix any references to changed paths, APIs, or behavior

2. **Read all files in `docs/pages/`** (if the folder exists) and check for both staleness and gaps:
   - Rewrite any sections that no longer match the code — removed features, changed message protocols, new data flows, renamed concepts
   - Check whether features added or significantly changed in the current diff are missing from the relevant docs page. A new user-visible capability, config option, or behavioral change should appear in the page that covers its area. Propose additions and wait for approval
   - **Update the features section.** When the diff adds a new user-facing feature or changes an existing one, the curated feature listings must reflect it — the dedicated features page (e.g. `docs/pages/features.md`), any enumerated feature list in `docs/index.md`, and the README's feature list. Add a new entry for a new feature; revise the existing entry for a changed one. A feature that exists in code but is absent from (or stale in) these listings is a documentation gap even when every other page is accurate. Propose the additions/edits and wait for approval
   - **Check embedded diagrams** (` ```mermaid ` blocks, ASCII flowcharts/trees, and structural tables) that depict architecture, data flow, state machines, or file layout. When code changes — or another doc you just edited — alters the structure a diagram illustrates (a renamed/removed module, a new component, a changed path or edge), update the diagram itself, not just the surrounding prose. A stale node, missing arrow, or wrong label in a diagram is as misleading as stale text.
   - **Check pages that moved in the nav hierarchy** (e.g. a former top-level page now nested as a subpage). A relocated page often (a) duplicates content that now belongs to a sibling page — trim it to a pointer so each page owns one concern; and (b) keeps heading levels from its old position (a former subsection's `###` where siblings use `##`). Also re-verify its relative links resolve from the new location.

3. **Align README with the GH Pages index** — only if **GH Pages index present** is `yes`. Read `README.md` and `docs/index.md` together and reconcile them so they describe the same product at the same point in time:
   - Tagline / one-line description must match (ignoring italics and minor punctuation).
   - The set of user-facing features / supported sites / supported games listed in each must match exactly — no feature appears in one but not the other.
   - Per-feature blurbs in the README must match the intro paragraph of the corresponding `docs/index.md` section (same facts, same scope claims). Wording may differ slightly; facts must not.
   - Install link, beta / access notices, and status blurbs must match.
   - If the README contains per-feature blurbs, each feature link must point to `https://<org>.github.io/<repo>/pages/<feature>` (or `/pages/<product>` in the monorepo variant) and the corresponding `docs/pages/<feature>.md` file must exist.
   - The footer "See full project documentation at …" block in the README must list every page that exists under `docs/pages/` (user-facing pages + Developer guide); no page may be listed that doesn't exist, and no existing user-facing page may be missing.
   - When in doubt about which side is correct, treat `docs/index.md` + `docs/pages/<feature>.md` as the source of truth and update the README to match.

4. **Check documentation screenshots.** A screenshot is documentation that goes stale invisibly — the prose beside it stays true while the pixels stop being. Run the staleness checks whenever **Doc image files** is not `NONE`; when it is `NONE`, say so rather than staying silent, and go straight to the missing-shot pass below — a project with no images at all is the one most likely to want its first. Report the counts on every run, including a clean one.

   **Unless the project has said it does not use them.** This whole step is governed by the guidelines read at the top of the Process: a repo whose `CLAUDE.md` rules out screenshots in its documentation gets no staleness pass, no manifest, no gap hunt and no offer — say the rule applied and move to step 5. Asking anyway is the specific failure that rule exists to stop, since the gap hunt below finds the same absence every run and would re-raise it forever.

   - **Start from the manifest, and leave it more complete than you found it.** **Screenshot manifest** in Context is `docs/screenshots/screenshots.json` — one entry per screenshot or per series taken together, recording what the frame shows, how to reproduce it, and whether this skill may replace it (`auto` / `confirm` / `never`). See `references/screenshot-manifest.md` for the schema, the policy semantics and the upkeep rules; read it before writing to the file. When Context reports `MISSING`, build the manifest during this run rather than working without one — every fact the steps below establish belongs in it, so the next run does not re-derive them.
   - **Ask about every undecided screenshot, once.** A file in **Doc image files** with no manifest entry, or an entry carrying no `policy`, has never been ruled on. Ask the user which policy it should have — `auto` (replace it without asking), `confirm` (propose, replace on approval), or `never` (report only; they take the picture) — and write their answer into `policy`. Ask about all of them in one exchange, not one question per image. When the question cannot be asked, write the entry with everything else you know and **no `policy` key at all**, then name those entries in the report so the next interactive run settles them. Never infer a policy from how a similar image was treated elsewhere.
   - **Reconcile the manifest against the files too.** An entry whose files have all vanished is orphaned metadata — report it, and remove the entry only once the user confirms the image was dropped on purpose. An entry whose `files` list has grown stale (a renamed image) is a rename to fix, not a new screenshot to register.
   - **Reconcile the files against the references.** List the referencing markup with `git grep --full-name -InoE '\]\([^)]+\.(png|jpg|jpeg|gif|webp)\)|src=.[^ >]+\.(png|jpg|jpeg|gif|webp)' -- ':/docs' ':/README.md' ':!/docs/plans'` and resolve each path relative to the file citing it, skipping `http` and `data:` URLs. A reference with no file is **DANGLING** — a broken image on the published site. A file no reference resolves to is an orphan *candidate*: confirm with `git grep -F <basename> -- '*.md' '*.html' '*.json' '*.xml' ':!/docs/plans' ':!/CHANGELOG.md'`, which catches the `href=` and `content=` forms the embed pattern misses (favicons, `og:image` cards, store manifests). **A prose mention is not a reference** — read the matching line before it clears anything. Those two exclusions matter: a plan doc or changelog recording that an image was *dropped* names the file, and grepping it unexcluded clears every orphan it names, turning the whole check into a silent pass. Before reporting that nothing is wrong, confirm the scan finds a reference you already know exists — a missed embed form returns a comfortable empty result forever.
   - **Classify a confirmed orphan before proposing anything about it.** No output from `git log --oneline -S '<basename>' --all -- '*.md'` means it was never referenced in any revision — wired up wrong when it was added. Output means it lost a reference it once had, which is usually a deliberate removal recorded in a commit or a plan doc. Also look for a derived twin by stem before calling anything dead: the master under `docs/` is often the source of a converted copy that a published site tree still serves, so an image orphaned in the docs can be live on the website. Report either kind; delete neither without asking.
   - **Examine every shot that has never been examined; scope the rest to the diff.** An entry with no `verifiedAt` is opened and checked whatever the diff says, and so is any screenshot with no entry — otherwise a shot that went stale in an *earlier* commit is never revisited, because its backing source is not in today's diff, and it stays wrong indefinitely while the prose beside it keeps reading true. Record the current short sha in `verifiedAt` once a shot has been examined, stale or not. For shots that already carry one, map each page embedding a screenshot to its backing source by grepping the page's bolded and code-formatted terms over the source tree — filename tokens and co-change history rank candidates, they are not evidence — and check it when those files appear in **Diff summary**. In either case, once a shot is being checked, Read the screenshots that page embeds and check the code two ways: every literal visible in the shot still exists in source, and every set the source enumerates — menu items, tabs, status labels — appears in the shot in full. Scope the grep with `':!*.md' ':!docs' ':!CHANGELOG.md'`; unscoped, it hits the changelog entry describing the removal and reports a false negative. A missing literal, or a set the shot shows only part of, is **STALE** — name the commit with `git log -S`, ordering same-day commits by `git merge-base --is-ancestor`, never by date.
   - **An impressionistic frame is held to what it argues, not to its literals.** A hero collage, a store marquee, an angled or overlapping composite exists to show *that* something is broad — several surfaces, several games, a lot of product — and is not read for a particular toggle or label. Literal matching flags such a frame on almost every release, and a verdict that fires every time trains the reader to skip it. Mark it `"precision": "impressionistic"` in its manifest entry and judge it by its claim instead: it goes stale when the claim does — a game it advertises is dropped, a whole surface it should show is missing, it no longer reads as broad — never because a control inside one of its panels gained an option. Still open it, still record `verifiedAt`, and say on the contact sheet which standard it was judged by. The default is literal; write the key only when the user tells you the frame is impressionistic, since it lowers what every future run will catch.
   - **Only strings this repo renders are evidence.** A frame containing third-party UI — a mail client, a game table, the IDE's own menus, whatever runs inside a terminal pane — holds stable text that will never appear in this source, and reading its absence as proof produces a confident false positive on exactly the projects whose screenshots must not be re-shot. Where the frame is not wholly this project's UI, report `NOT CHECKED (third-party UI in frame)` and prove nothing. Making the check work there takes one hand-written line in that project's `CLAUDE.md` naming the source trees that own the strings — propose it, never guess it.
   - **No verdict from a date.** File mtime is the checkout time and is identical across the whole tree; commit age says only that the codebase moved on. Neither is evidence of anything about an image.
   - **When a shot is stale, sweep its neighbourhood** before reporting: its alt text and caption, the prose describing it, every other page embedding the same file, sibling shots of the same screen, and any second copy under a published site tree — a `site/` or equivalent directory with its own derived images and its own `<figcaption>` text, which goes stale independently of the image it captions. Say whether that tree was scanned. Do not compare a master's dimensions against a derived copy's; deliberate re-crops make that a permanent false positive.
   - **Report three verdicts and the size of the haystack** — `STALE` with its proof, `NOT CHECKED` with its reason, and a count of the images only inventoried — with any `NO EDGE` frame from the edge check below listed beside them. Give the reach in one sentence: which directories were scanned, which images were opened rather than listed, and that this check reads text, so it can prove a shot stale and can never prove one current. Say what the manifest gained this run: entries added, policies answered, entries still carrying no `policy`, and any image replaced under `auto` — a capture that happened without a question must be the loudest line in the report, not a silent one.
   - **Look for the shot that is missing, not only the one that is wrong.** The checks above all start from an image that exists, so a page that should carry one and never has is invisible to every one of them — the failure mode is a feature described in three paragraphs of prose that a picture would have settled in a glance. Sweep the user-facing pages for sections that describe **something with a visual result** — a window, a panel, a menu, a chart, a badge, a state the UI shows — and carry no image. Rank the gaps by how much the prose is straining: text naming a layout ("the header tracks…", "a small badge carrying…"), enumerating what is on screen, or describing where to click is doing a picture's work. A section documenting behaviour with no visual surface (a config field, a protocol, a timing rule) is not a gap — do not manufacture one. Sections whose sibling pages already carry a shot rank higher, since the inconsistency is itself confusing.
     Propose at most the two or three strongest per run, each as: the page and section, what the shot would show, and why the prose alone is not enough. Then **ask whether to capture it**. This is the one place in this skill that may drive the product, so it is gated on an explicit yes every time — never bundle it into another approval, and take silence as no.
     Number these in **their own series — G1, G2, …** — so a reply can take some and drop others without colliding with the frame numbers below, and carry the user's triage forward so a later run does not re-raise what they already declined.
   - **Replacing a stale shot is governed by its entry's `policy`.** Detection above runs for every screenshot; only replacement is gated.
     - `never` — report it and stop. The user takes the picture.
     - `confirm` — say what changed and what the replacement would show, then wait. Replace on approval.
     - `auto` — replace it, then report what was replaced and why. No question first.
     - An **absent `policy`**, and a screenshot with no entry at all, both behave as `confirm` — which is why undecided is safe to leave lying around: it stops to ask, so nothing is ever captured on silence. Settle it by asking, not by acting.

     Under `never`, and under an absent `policy`, a replacement is not *offered*: a stale shot means the UI moved, and which of the several states it now has is worth documenting is the user's call, not something to infer. **But when they ask for one and name the state, that reason is spent** — an instruction like "recapture it, and make sure nothing sensitive is in frame" has already answered the only question the restriction was protecting, and refusing at that point is deferring to a rule instead of to the person the rule exists for. Capture it under every honesty rule below, and show them the result before it replaces anything.
   - **Every capture runs a script, including the first one.** Wherever a capture is permitted — `confirm` after approval, `auto`, a named-state request, or an approved gap — run the entry's `capture.command`: a recorded script reruns identically, lands in the diff, and does not depend on the machine's window size, theme or which apps are open. When no command exists yet, **write the script rather than driving the app ad hoc**: turn `capture.steps` into `docs/screenshots/capture/<id>.sh` (or `<id>.ps1`, run with `pwsh -File`, for a Windows capture), run it, iterate on the script until the frame is right, then record it in `capture.command`. That directory is the only capture surface this skill drives directly, which is deliberate — it makes the second capture free and the diff reviewable, and it stops a one-off sequence of clicks from becoming the only record of how a published image was made. The script is what reruns; the staging and the shutter below are what it calls. A capture script builds against the current working tree, never an installed release, or the shot documents a build nobody has.
     - **The machinery lives in this skill; only the staging lives in the project.** Anything a second project would want unchanged — taking the picture, driving a browser, trimming a halo, stroking an edge, converting a colour profile — belongs in `~/.claude/skills/docs-relevance/scripts/` and is *called* from the project, so a fix reaches every project at once and two copies cannot drift. What belongs in `docs/screenshots/capture/<id>.sh` is what only this project knows: which window or route, which state to stage, which region to crop, which sessions may appear in frame, which week the chart should show. Two smells mark the boundary moving: a capture script that has grown image-processing code has almost always grown a second copy of something shared, and a script in this skill that knows an app's window title has taken on something it cannot know for the next project. Copying a helper into a project to avoid depending on a path under `~/.claude` is the wrong trade — that path is the same on every machine these dotfiles are installed on, which is exactly the condition under which the capture runs at all.
   - **If the capture runs off fixture data, build the fixture the frame needs.** A script that renders from whatever fixture was already lying around produces a correct picture of the wrong moment — a history with no compound action, a deck with nothing identified — and quietly stops illustrating the sentence beside it. That is a fixture problem, not a reason to accept a thinner shot or to go back to shooting by hand. Find the real session the original came from (its distinctive content is the search key — a card name or a label visible in the old frame will often identify it outright), commit the smallest artifact that reproduces it, and add whatever flag selects the moment. Prefer a processed intermediate to raw capture data when the renderer accepts one — an order of magnitude smaller, and the parser that produces it has its own tests. Keep this data out of the product: a fixture beside the capture scripts is documentation support, and it must never become a seed-data hook in the shipped code.
   - **Match the device pixel ratio of the shots you are not replacing.** A hand-taken screenshot carries the DPR of the display it came from — commonly 1.5 or 2 on a scaled monitor — while a headless capture defaults to 1. The replacement is then correct and renders about a third smaller, which no text check can see and which shows up as two apparent zoom levels on one page, since the un-replaced frames beside it are still at the old ratio. Measure it rather than guessing: a repeating feature in the old shot (a card, a row, a cell) against the same feature rendered at 1 gives the ratio directly, and the numbers land on a clean value. Match the framing width the same way — the old shot's pixel width, less 2 × the ring width when `hairline.py` framed it outward, divided by that ratio is the CSS width it was taken at, and a wider viewport silently shrinks everything laid out to fill it.
   - **A re-shoot keeps the subject of the shot it replaces.** Same week on a chart, same project and same scroll position in a log or a conversation, same record in a list — change only the thing the re-shoot exists to change. Three reasons, and the first is the one that bites: the prose around a figure, its alt text and its caption were written against *that* frame, so a new subject silently re-points every sentence that refers to it, and the check above cannot catch it because the words are all still true of something. Second, a paired figure — one variant per platform — is read as two pictures of one thing, so two subjects turn a comparison into a puzzle. Third, an image diff is unreviewable: holding everything else still is the only way the intended change is visible at all.
     Record the anchor in the manifest entry's `capture.steps` — which session, which week, which point in the history — in the same breath as capturing, because it is knowable now and expensive to recover later, and the next re-shoot is the run that needs it. Where the script can take it as a parameter, give it a default rather than leaving it to whatever is on screen.
     **When the old subject genuinely cannot be used, say which one it was and what replaced it** rather than quietly picking a new one. Private content in the frame, a session that no longer exists, a chart week that has aged out of the data — all real, and all reasons the user should hear once, not discover later from a figure that stopped matching its paragraph.
   - **Writing that script: staging, then the picture.** Two separate problems, and they are worth keeping apart: *staging* the thing (getting the right window open, showing the right content) and *taking the picture*.
     **Staging — climb down from the top, never start at the bottom.** In order: (1) the app's own interface — a URL scheme, CLI, or local HTTP route — which needs no OS permission, says what it means, and does not break when the UI moves; (2) App Intents / `shortcuts`; (3) Apple Events against the app's scripting dictionary, whose Automation grant is scoped *per source→target pair*; (4) the Accessibility API, semantic but a system-wide grant; (5) synthesized clicks at coordinates. Rung 5 needs the same broad grant as 4 while being strictly more fragile, and a helper that injects input is a general "control this machine" capability — expect it to be refused, and do not treat that as an obstacle to route around. **For an app the project owns, the answer is usually to give it rung 1 rather than to puppet its UI** — that is a legitimate control surface, and distinct from the in-product capture hooks the rules below forbid: it drives the product through the same door real use does. A fixture replayed through that door stages the content, and the rules below say how.
     **Taking the picture** needs Screen Recording on macOS, and where that grant goes matters. Not to the agent's own binary: it is version-named, so the grant goes stale on every update, and it would hand whole-display access to every session forever. A small purpose-built helper at a fixed path with a stable signature is granted once and scoped to the one job — `~/.claude/skills/docs-relevance/scripts/` holds `docshot.swift` (capture) and `build-docshot.sh`, which installs it as `DocShot.app` and refuses to clobber an existing install, since rebuilding resets the grant. Two traps that cost real time: TCC attributes the request to the **responsible process**, so the helper must be launched through LaunchServices (`open -a … --args`) or the grant is ignored and it must therefore return results in a file rather than on stdout; and a denial is silent — window ids, owners and bounds still populate while every *title* comes back empty, so probe for that (`--check`) rather than discovering it as a black PNG. **On Windows no grant is involved, and the shared machinery is PowerShell:** `window-shot.ps1` is the shutter (`-Method PrintWindow`, `Screen`, or `Alpha`, the two-backdrop solve below; `-Popup` for a menu), and `windows-capture.ps1` is what a project's `<id>.ps1` dot-sources to reach it — `Invoke-WindowShot`, `Add-WindowFrame`, `Invoke-WithPointerAt`, `Get-PrimaryWorkArea` and `Find-ProcessWindows`. A project whose Windows captures dot-source this `windows-capture.ps1` runs `~/.claude/skills/docs-relevance/scripts/check-capture-scripts.ps1` from its commit gate, from the repo root — captures built on a library of their own give it nothing to check, and it fails on them: it resolves every function, parameter and type the captures use from this library, so renaming one here fails that project's commit instead of its next capture, which would otherwise break after the pointer has moved.
     Then the honesty rules that make the result worth committing:
     - **A screenshot of an empty app is worse than no screenshot.** Most UI worth documenting only looks like itself with real content in it — a session list with sessions, a chart with history, an inbox with mail. When real use does not supply that content, replay a fixture (next rule) rather than shipping a picture of an empty shell; hand the capture back to the user only when neither can reach the state.
     - **Stage the state through the product's own input, never inside it.** Two sources are legitimate: real use, and a **fixture replayed through an input the product already takes** — synthetic hook events posted to its event API, a recorded file its importer reads, a URL its router accepts — kept beside the capture scripts. Prefer the fixture whenever the frame needs a particular spread of states: it reproduces the same frame on every run, keeps private project names and real prompt text out of the picture because every string in it was chosen for the shot, and needs nobody's live sessions arranged by hand. Name its rows after the user's own public projects, not invented sample names (the user's call, 2026-09-26); only the prompt text and labels are written for the shot. Measured 2026-09-25: staging real sessions for one frame took three failed launches and still produced badges nobody wanted in the picture. Either way the pixels must be the real product rendering a state it can reach from that input:
       - never a demo mode, seed data or fixture path inside the product — the out-of-scope rule below;
       - never a hand-edited or composited frame;
       - never a fixture that makes the product show what its code cannot produce from real input, such as a label no event maps to.

       Undo whatever the replay leaves in the product's own stores — a history file, a cache, a session index — before handing over, so the fixture does not outlive the capture.
     - **Crop to the subject.** A whole-desktop grab of a small widget documents the wallpaper. Capture the window, not the screen. **And take all of the subject, corners included.** When what you are capturing has rounded corners — a dialog, an IDE's island panel, a card — the capture takes its full bounds so its own arcs are in frame, and the background outside them is removed by the two-backdrop solve or the known-shape mask (*Capture the background out* and *A known shape needs only one capture* below), never cropped away. A rectangle inset past the arcs discards the real corners and keeps faint remnants of them at the edges, and no later step gets the shape back: rounding the inset crop with `hairline.py --radius` imitates corners the capture threw away. In-process, that means painting the component that draws the rounded shape — the island, not the editor inside it — and on screen, capturing to the shape's bounds rather than inside them. **Where the cut goes is read off the pixels:** capture with some background beyond the shape on every side, then crop with `scripts/outline.py`, which compares each row and column with its own outermost pixel — a window's backdrop can be a tinted gradient — and cuts on the first pixel that differs, so the line sits just outside the shape and its antialiased edge stays in. A cut placed on the border line, or on bounds taken from the layout, trims that edge off the flats and bites into the arcs.
     - **A cut through content keeps every glyph on one side of it.** A side that is not the subject's own outline — a crop out of a larger view — passes through whatever lies there, and the sliver it keeps of a letter or an icon reads as a stray contrast spot against the frame rather than as the edge of a crop. Clean every such side with `scripts/outline.py --sides <outline sides, or none> --clean SIDES --reach N`. `--sides` names only the sides that are the subject's own outline, which are cut on the first pixel unlike the edge; every other side stays where it is, and a crop with no outline of its own takes `--sides none`. Left out, it defaults to every side `--clean` does not name, and a side named by both is refused. `--clean` moves the cut inward to the nearest line that cuts no glyph in two (the fewest, when none within reach is clear) and leaves a cut that is already clean where it is. A glyph that only touches the cut is whole and does not move it. It judges a cut by the line it drops, so run it on a capture that reaches past every side it cleans; at the image's own edge it has to move at least one line. Keep `--reach` inside the gap between the cut and anything that must stay whole, since it counts glyphs cut in two, not a border the new cut would run along.
     - **Frame with an even margin, and let the pixels decide it.** A subject that stretches to its container, a page shorter than its own bottom padding, or a grid whose widest row stops short each leave a band of background on one side and none on the other — and choosing a viewport width only frames a subject that happens to fill it. Trim to the pixels actually drawn and pad back out to a uniform margin, so the frame is even whatever the subject did. Pad outward rather than clipping a wider region: on a stacked layout the neighbour sits a few pixels away, so a generous clip frames the subject with someone else's content. Do not do this to a shot over a textured or photographic background — the trim keys off a flat background colour, finds "content" everywhere, and pads a solid-colour border around the outside; a hand-cropped shot of a real page is usually already tight, so leave it.
     - **Capture the background out, don't crop it out.** Anything that isn't a plain rectangle — rounded window corners, a drop shadow, a translucent overlay — has boundary pixels that are a *blend* of the subject and whatever was behind it, so no crop separates them: trimming either leaves a grey fringe or eats content, and for a genuinely translucent window there is nothing to trim at all. Photograph the subject twice over known backdrops instead and solve `O = C·a + B·(1−a)` per pixel for its real colour and coverage, writing a PNG with an alpha channel. The shot then composites correctly on a light page or a dark one, which a baked-in background never does. Treat a stray dark or light fringe at a corner as this problem, not as a crop that needs another pixel or two — a rectangular trim is the *diagnosis* (corners that stay the backdrop's colour prove the subject is round), never the remedy.
       **Do not re-derive this.** On Windows it is already implemented as `window-shot.ps1 -Method Alpha`. `~/.claude/learnings/windows-window-capture.md` carries the solved form (`a = 1 − (O_w − O_k)/255`, `C = O_k / a`), how to place the backdrop, and two things the formula alone will not tell you: **the recovered colour is also the diagnosis** — a fringe coming back as the subject's own colour at rising alpha is background and the alpha removes it, while one coming back *dark at ~50% coverage* is the window's own chrome, which no keying will touch and which must instead be inset past — and that verifying with a threshold tuned to the previous defect sails straight past the next one. **A backdrop need not be a window behind a window**: for anything rendered in a page — a web app, a themeable UI — the two known backdrops are two values of its own background, which is usually one line of CSS and easier than the desktop case the learning was written for.
     - **A known shape needs only one capture.** The two-backdrop solve exists because both the colour and the coverage are unknown at a boundary pixel. When the subject is a shape you can describe — a rounded rectangle, a panel, a card — its **geometry supplies the coverage**, and a single capture is enough: build the mask analytically, supersampled, and apply it. Two details decide whether it looks right. **Inset one pixel**: a mask alone leaves a fringe, because the pixels it half-covers really are half backdrop, and dropping that ring takes the fringe with it at the cost of a corner a pixel tighter — invisible at viewing size. **Do not un-blend an inset edge**: with an inset the boundary sits inside the subject, so a partial pixel is pure subject being antialiased, and subtracting a backdrop that is not in it over-brightens toward white (seen as a 7%-alpha white fringe that passed every numeric check). Un-blending is correct only at zero inset. Masking by shape also sidesteps a backdrop too close to the subject to colour-key — in one case 28 levels of blue apart. Two consequences: the border that follows must trace the **shape**, since a keyed image has no rectangular edge for a canvas-wide box to sit on; and estimate the subject's own luminance from a **median over a grid**, because a single sample lands on text as readily as on fill and will pick a dark outline for a dark panel.
     - **Give a screenshot an edge when its own capture has none.** This applies to every frame the skill files, not only to ones captured by script — see *Every published frame carries an edge* below for the check that runs on inherited and user-supplied frames too. A shot whose border colour matches the page it lands on has no visible boundary — a dark app on a dark page, a light app on a light one — so the reader cannot see where the picture stops, and **you do not get to choose the page**: a docs site pinned to a light scheme still has its README rendered by github.com in whichever theme the reader uses, so "the site is light, so a dark frame shows its edge" is never a reason to skip. macOS draws a *decorated* window a light hairline and transparent rounded corners for free, which is why a shot of one already reads correctly; an **undecorated** window has neither, a **dark-themed** decorated window gets no light stroke either, and a region crop never does. **The edge belongs in the image, not the stylesheet** — `~/.claude/skills/github-pages/SKILL.md` gives the reasoning, of which the decisive part is that no CSS reaches `README.md`, which github.com renders in dark mode. Bake it with `scripts/hairline.py`, called from the project's capture script the way the capture helper beside it already is — a plain CLI precisely so the PowerShell half of a cross-platform project can shell out to the same one. This is framing, not retouching, and it moves nothing: on a fully opaque frame the edge goes in a new margin outside and every captured pixel survives, bar the corners `--radius` clips when asked to round them; on a shaped frame it recolours only the outermost band inside the silhouette, where the OS would have drawn its own edge.
       - **Check all four sides, at any alpha, before adding one.** Two probes that look sufficient are not. Sampling only the top edge called a dark-themed window bordered when three of its four sides were bare — its top row was the title bar's own highlight. Sampling only pixels above `alpha 200` cannot see a translucent border at all, and reported plainly bordered Windows frames as bare; theirs measures `(101, 101, 101)` at `alpha 119`. A window either has a frame all the way round or it has none, so at every position along the middle half of each side walk in as far as the midpoint, compare the outermost pixel above the antialiased fringe — alpha 64 or more, low enough to include Windows' band, high enough to step past a keyed frame's alpha-18 fringe — against one a few pixels further in, and count a side bordered only when most of its positions contrast — a border differs from the content behind it nearly everywhere, text only where a glyph falls, and one midpoint probe per side called a bare crop of editor text bordered. Require three sides of four. `has_own_edge` in that script is the implementation.
       - **Derive a shaped frame's ring from its own alpha; never draw a shape over it.** The one drawn shape is `hairline.py --radius` on a fully opaque frame whose subject's corners really are circular arcs (next bullet). macOS does not round a window with a circular arc — its corners are continuous, so no radius makes `CGPath(roundedRect:)` or Pillow's `rounded_rectangle` sit on the curve, and both were tried. Worse, compositing a drawn stroke takes the output alpha from the source, so wherever the arc falls outside the real corner it paints new opaque pixels and quietly *enlarges the window*. Eroding the alpha and subtracting cannot do either, because the ring is a subset of the shape by construction. Erode with a **disk**, not a square: a square kernel — all `ImageFilter.MinFilter` offers — erodes `w` along the axes and `w·√2` diagonally, which is a border 41% thicker everywhere the edge curves, and a ratio of exactly √2 between corner and flat is that bug's fingerprint.
       - **A square frame gets its edge outside, never inside.** The inward ring is right for a shaped frame, whose edge is antialiased coverage. On a frame with square corners — every region crop, and any capture with no transparent pixel — a ring inside the silhouette paints over the outermost pixels of content, which on a crop are the pixels right at its cut sides. `hairline.py` grows a fully opaque frame's canvas by the ring width on every side instead, so every captured pixel survives, bar the corners `--radius` clips, and the file comes out 2 × width larger each way. Rounded corners come from the subject's own arcs, which a whole capture keeps (per *Crop to the subject*). An opaque whole capture still carries backdrop outside those arcs, so once `outline.py` has cut it to its outline, `hairline.py --radius N` at that outline's radius is the known-shape mask that removes it; without the flag the frame comes out square with backdrop in its corners. The same flag is also the fallback for a capture cropped inside its corners that cannot be taken again, where it imitates corners the crop threw away. Use it either way only when the corners are circular arcs, such as an IntelliJ Islands panel: the content is clipped to radius N at its own extent and the ring follows it outside, with `--cut` keeping a crop's cut corners square.
       - **Decide square or rounded from the subject, not from the alpha.** A capture of a whole window or dialog is rounded — 8 DIP on Windows 11 — even when it arrives as an opaque rectangle: `PrintWindow` returns the window's own surface without DWM's rounding, opaque at all four corners (measured in `~/.claude/learnings/windows-window-capture.md`), and a crop taken just inside the border keeps only faint arcs of it at the corners. **Those arcs are the evidence, so look for them.** An opaque crop that shows no trace of an arc at any corner — one cut well inside the dialog, through a scrollbar or a list — is a region crop, whatever it shows: it gets a square frame from `hairline.py`, or a new capture of the whole window, never `-Grow` and rounded corners, which would imitate corners the crop never had. `hairline.py` sees an opaque rectangle and squares it off, so a window capture keeps its shape through `winframe.py` instead (`Add-WindowFrame` from a capture script). A new capture takes the window to its own bounds, which `window-shot.ps1` does by cropping to DWM's extended frame. When a capture already sits just inside the window's border — faint arcs at its corners — and cannot be taken again, pass `-Grow` (`--grow` to `winframe.py`): it grows the canvas by the border thickness first — 2 px at 144 DPI — so the content is clipped to the capture's own extent and the frame lands in the new margin, outside it. The ring `Add-WindowFrame` passes is `#BDBDBD` opaque, the colour `hairline.py` strokes, so the rounded frames and the square ones read as one set.
       - **A crop of a translucent-bordered window needs its border replaced, not tinted.** Windows draws a window's frame as a 2px band that captures at `alpha ~120-150`, so a crop of one keeps a real border on its uncut sides and has none on the cut ones — and writing colour alone can never make the two agree, because the kept sides stay translucent and take their tone from whatever is behind the page. Measured on a cropped tab strip: the native two read `~162` on a white page and fell to `53` against a `46` background on a dark one, while the stroked two read `189` on both — reported as a *darker* border, which it is, on the half of the picture nobody was checking. For a crop of a Windows window that means `Add-WindowFrame -Cut <sides>`, which redraws the whole frame from DWM's model (below), so none of the translucent band survives. For a translucent-bordered crop `winframe.py` cannot model, pass `hairline.py --opaque`: it takes the ring's alpha to 255 where the ring is solid — which includes the shape's outer fringe, so a rounded corner comes out stepped rather than soft — and thresholds the silhouette *before* the supersample, since interpolating first drags samples across a threshold sitting inside that alpha plateau and leaves a staircase of half-strength pixels through the corner. **The flag is the project's to pass, not the script's to infer** — whether a frame is a crop of such a window is a fact about how it was taken. Two attempts that look right and are not: stroking only the cut sides (correct on white, two visible edges and two invisible ones on dark), and stroking all four while preserving alpha (colour diluted to whatever the native alpha allows, so the corner reads darker than the flats). Default it off — on a capture whose alpha is genuine coverage the same step squares the corners off. **For a Windows capture, draw the frame with `scripts/winframe.py` instead of editing the one Windows drew.** A captured Windows border is the border mixed with the shadow and backdrop behind it — its top-to-bottom shading is the drop shadow, not the border — so no treatment of those pixels recovers it. `winframe.py` keeps only the content inside DWM's clip and draws the frame from DWM's measured model (8 DIP corners, or 4 for a menu, flattened into chords; a 1 px antialiasing ramp; a uniform `rgba(117,117,117,0.40)` border for a window and an opaque `#E5E5E5` ring for a light menu, either of which `--ring` can replace). Drawn with Windows' own ring and the window's own shadow (`--shadow <style>`) it reproduces a real capture to under one level RMS. A capture script calls it through `Add-WindowFrame` in `windows-capture.ps1` rather than directly: that wrapper keeps the raw first and passes the ring every frame shares — `#BDBDBD` opaque, the colour `hairline.py` strokes and macOS draws round a decorated window, since Windows' own fades to 55 on a dark page — and no shadow, so a rounded Windows frame and a square hairlined crop read as one set.
       - **Take the colour from the set, not from a principle.** Matching what the platform strokes its own decorated windows with — `#BDBDBD` on macOS, measured — is what lets a bordered undecorated window sit beside a decorated one without looking edited, and that consistency matters more than contrast in the abstract. A mid grey on the reasoning that a light hairline vanishes on a light page reads as a drawn-on line among frames that all begin `(189, 189, 189)`. A set with nothing native to match takes `#BDBDBD` as well — a web app's region crops, or a Windows project whose own border `Add-WindowFrame` redraws — which is the ring these tools draw on every new frame. Frames an older capture bordered in another colour keep it until they are re-framed; nothing here recolours a set.
       - **Assert the border landed, and keep the raw.** A version of this drew the corners only — Pillow's rank filters replicate at the canvas boundary, so a straight edge running to the image edge erodes to itself — and reported success, because the check compared alpha before and after, which a border that was never drawn passes perfectly. Check for the colour on all four edges and refuse to write the file otherwise. **Assert the SKIP as well as the stroke**, which is the same hole one level up and was found by review rather than by use: the "this frame already has its own edge" path exits 0, indistinguishable from success to the capture script that called it, so a false positive from that gate ships an unbordered frame with nothing reporting it — and on a crop whose content runs to the edge, glyphs dense enough along a side can still read to that gate as a border. Pass `--require` from any caller that actually wants a border (the macOS capture lib does, unconditionally; the Windows one draws its frame with `winframe.py` instead). It asserts the *outcome* — all four edges carry the colour — rather than that a stroke happened, so re-running on an already-bordered frame stays idempotent while a frame carrying somebody else's border is refused. Separately: **keep the capture as it stands just before the frame step** — after any crop, `outline.py` included — as `raw/<name>` in the manifest's directory, `docs/screenshots/raw/`, committed with the frame. Every treatment here edits in place, so without a raw the only way to try a different one is to take the shot again — which needs the window on screen and the app in a staged state, neither of which survives the day, and on the other machine not at all. Tuning a border is post-processing and should not need the app running, so the raw travels with the repo rather than staying in one machine's `tmp/`.
     - **Read back what you captured** before wiring it in, and run it through the staleness check above as if it were an inherited file: every literal in the frame must exist in source. A shot taken from a dirty working tree can already be wrong on the day it is committed.
     - Wire it in per the path table the `github-pages` skill owns, write alt text that says what the image shows rather than repeating the caption, and report the new file as part of the change set so it is committed with the prose it illustrates.
   - **Every published frame carries an edge — check it on every run, whoever produced the frame.** Run `scripts/hairline.py --check` over every screenshot the docs pages and the README embed — the files *Reconcile the files against the references* resolves to — not only over the ones this run captured. The flag applies `has_edge` and writes nothing, while the same script without it strokes every file it is given in place, whatever that file's `policy`. Report a frame with no edge as its own finding (`NO EDGE`) beside the staleness verdicts. It is the finding the other checks cannot make: the text is current, the frame is correct, and on a dark page it still has no boundary.
     An image that is not a screenshot is not a frame and gets no edge: an icon, a diagram, generated chart output, or store art uploaded at a fixed size, which an outward border would grow past the store's size rule. Leave those out of the audit and of any proposal, and give their count in the reach sentence. **`has an edge` is a probe's opinion, not a measurement.** It reads the middle half of each side and needs three sides of four, so a frame bare on one side passes, and a crop whose content runs dense to the edge can pass on its glyphs. Say so in the reach sentence. When the run builds a contact sheet anyway, look at every opaque frame's edge on it with the theme toggle on dark; otherwise list the opaque frames in the reach sentence as passed by the probe, not checked by eye. A probe pass alone does not call for a sheet, and a clean pass stays one line. Only a single-frame PNG can be stroked — `hairline.py` refuses anything else — so a bare JPEG, GIF or WebP screenshot is `NO EDGE (convert to PNG first)`, and an animated GIF never joins a proposal to border the set.
     Two reasons to skip the check are both wrong:
     - **The site's theme.** See the rule above — the README reaches readers in dark mode whatever the site's scheme.
     - **Consistency with a bare set.** When the committed frames all lack an edge, matching them repeats the defect across the new ones. The fix is to border the whole set, so propose it for every frame rather than leaving new frames bare to match; the entries' `policy` governs the change like any other, since it rewrites committed images.

     Which tool follows from what the frame is, not from the platform alone:
     - a Windows window, whole or cropped — `Add-WindowFrame` in `windows-capture.ps1`, which draws DWM's frame with `winframe.py`; `-Cut <sides>` names a crop's cut sides, which get the border straight along them, in a new margin outside the crop, with square corners, and `-Grow` covers a capture taken just inside the window's border, which shows as faint arcs at its corners; an opaque crop with no arc at any corner is a region crop and goes to the next item instead. `Add-WindowFrame` runs only from a script file inside the repo, since it finds the repo from its caller, and it commits nothing itself but writes the raw to `docs/screenshots/raw/<leaf of -Path>`. For an inherited or user-supplied Windows frame with no capture script, copy it to `raw/<name>` and run `scripts/winframe.py --kind window --shadow none --ring BDBDBD:1.0 --dpi <display DPI> [--cut <sides>] [--grow] raw/<name> --out <name>` directly. Pass the DPI of the display the shot came from: without `--dpi` the scale comes from the PNG's own `pHYs`, which a screenshot tool may write as 96 whatever the display's scaling, and the frame then gets the 96-DPI border and corners;
     - any other frame whose capture carries no edge, such as a region crop of a page or an undecorated macOS window — `scripts/hairline.py --require`, called from the capture script so a re-shoot is bordered by the same step;
     - a rounded subject captured whole, cropped to its own outline — `scripts/hairline.py --require --radius N` at that outline's radius, where the clip is the known-shape mask, so the corners are the subject's own arcs, and the ring goes outside them. A subject with its own border keeps it, and the ring goes outside that; a capture that arrives already keyed, with transparent corners from the two-backdrop solve, takes plain `scripts/hairline.py --require`, which traces the ring from its alpha. Measure the outline off the pixels rather than assuming it: an IntelliJ island's one-pixel border sits half a pixel outside the component's inset bounds, and its radius, fitted to that border, is the theme's `Island.arc` halved;
     - a rounded subject whose only capture was cropped inside its corners and cannot be taken again — `scripts/hairline.py --require --radius N`, with `--cut <sides>` for the crop's cut sides; N is the subject's own radius, which for an IntelliJ Islands panel is half the theme's `Island.arc`, 15 px at 144 dpi. A capture you can still take goes to the item above instead.

     Copy the file as it stands just before the stroke — after any crop, `outline.py` included — to `raw/<name>` beside the manifest, where it is committed with the frame, and keep that directory out of the published site — for Jekyll, an `exclude:` entry in `docs/_config.yml` — since a raw is an input to the frame step, not documentation. A re-capture overwrites its raw, and git keeps the one before. Show before and after on the contact sheet with the theme toggle on dark, which is the view that proves the edge is there.
   - **Filing any replacement, whoever produced it.** A user-supplied replacement — with nothing attached, the newest PNG on their Desktop — is filed the same way as a captured one. Compare its dimensions against the file it replaces with `file <path>` and raise a mismatch before going further, bordered with bordered: allow for the framing step the committed frame went through — `hairline.py`'s outward border adds 2 × the ring width, `winframe.py` adds the border thickness for each `--cut` side, or on every side under `--grow` — and compare with the outgoing file — its `tmp/screenshot-originals-<date>/` copy, or `git show HEAD:<path>` once a capture script has already written the path. A raw compares only with a raw, and the outgoing one is `git show HEAD:<dir>/raw/<name>`: the one in the working tree is already the replacement's, since the capture that re-shoots a frame overwrites the raw of the same name. Then copy it into place and run the neighbourhood sweep above so the caption, alt text and prose move with it. Do not overwrite the committed image until the replacement is in hand and accepted.
   - **Write every artifact this run produces into the repo's own gitignored `tmp/`, never `$TEMP`.** The file tools refuse reads outside the working directories, so a sheet written to `$TEMP` is one this skill can build and hand over but never open again to check — which breaks the rule that sends it to a browser in the first place. A project-local `tmp/` needs no read grant, behaves identically on the Windows and macOS machines, and cannot collide with another repo's run; add `/tmp/` to the project's `.gitignore` on first use, anchored with the leading slash so it matches only the repo root. Keep a descriptive `<artifact>-<date>` name for the sheet and for the directory of saved originals — the repo name that used to be in it is now carried by the directory. Apply the same rule to anything else this skill writes outside the change set.
   - **Every replacement is reversible.** Copy the outgoing file to `tmp/screenshot-originals-<date>/` *before* overwriting it — `auto` replaces without asking, which makes the undo path the only thing standing between an unattended run and a lost image. Do **not** delete those copies when the user accepts: for a frame git never had they are the only previous version — the one `contact-sheet.py` reads when it rebuilds the sheet, and the one **revert** copies back. A sheet already built embeds every image and renders either way. For a tracked image the copy is belt-and-braces anyway — `git checkout -- <path>` restores the committed version, and `git show HEAD:<path>` recovers it for the sheet — so the copy exists for the untracked case, for as long as the sheet may need rebuilding or the change reverting.
   - **The contact sheet is how this check talks to the user about pictures, and it comes before the change more often than after.** Any run with something visual to settle writes one HTML page into the repo's gitignored `tmp/` and links it — a stale frame, a proposed replacement, a verdict worth a second pair of eyes. Under `confirm`, `never`, and an absent policy the sheet **is** the proposal, so it is written *before* any capture: "say what changed and what the replacement would show" is a claim about pictures, and no amount of prose puts the picture in front of the person deciding. Only `auto` writes it afterwards, as evidence of a swap already made — permission to skip the question is not permission to skip the evidence, and an image swapped unattended is exactly the one whose before and after must be easy to compare. **The trigger is a picture changing on disk, not a reply mentioning one and not a run ending.** Any turn that writes an image file ends with a sheet, unasked — whatever produced it, whichever skill is loaded, and however small the change. The earlier wording keyed on whether the reply "says anything about" an image, and that is a hole a reply can walk through by saying little; the file changed either way, and the user cannot see it. **Iteration counts as changes, not as one change:** re-shooting the same frame five times while tuning it is five sheets, because each one moves the point of comparison, and the run that ends with "it looks right now" is precisely the one whose claim nobody has been shown. If that feels like a lot of sheets, the answer is fewer speculative re-shoots, not fewer reports. Skip it only when the turn touched no image at all. The rationalisations for skipping are always the same and always wrong: *the change is one pixel* (an argument for magnified crops, not against the sheet), *it is uniform across many images* (uniformity is itself a claim about images), *they have seen a representative example inline* (a sample the reader did not choose, so they cannot see the one that went wrong), and *this was not a documentation run* (the pictures do not know that). Every time this was skipped the thing missed was visual and uncatchable in prose. A fifth rationalisation is newer and is the one to watch for: *the user is watching me work and will say if it looks wrong*. They said it four times in one session — a border 41% too thick on curves, a border doubled to 4px on the sides that already had one, a frame left on a superseded border, and a corner that read thin — each after a reply that had described the change in prose and shown nothing. Being asked for the report is the failure, not the reminder. Build the sheet **before** writing the summary, and let the summary point at it rather than repeat it.
   - **What goes on it.** Every screenshot, not only the ones in question — anything replaced as a before/after pair with both sets of dimensions, everything else as the picture currently committed. **Every file the frame step touched also carries its unframed stage**, the capture as it was before `hairline.py` or `winframe.py` ran: always in the page, and hidden until the reader ticks the nav's **Show before framing** checkbox, which starts unticked on every load. The committed version a replacement supersedes sits behind a second nav checkbox, **Show previous version**, which starts ticked. "Unchanged" and "not implicated" are claims about an image, so a page that argues them in prose while showing only the ones that changed asks the reader to take the quiet ones on trust; it is also where a wrong verdict gets caught, since this check reads text and cannot see layout, a cropped edge or a control that moved. Each frame carries its staleness proof, and each one awaiting a decision carries what a re-capture would need — the session, the app state, the preconditions — so the cost of the shot sits beside the case for it. **Build it with `scripts/contact-sheet.py`** — `python ~/.claude/skills/docs-relevance/scripts/contact-sheet.py --notes <notes.json>`, run from anywhere in the repo. It derives the page from the manifest, the images, `raw/<name>`, git at `--base` and the saved originals, copies the fixed parts from `references/contact-sheet.html`, writes `tmp/screenshot-contact-sheet-<date>.html`, publishes it on the tailnet through `~/.claude/skills/shared/tailnet_publish.py` and prints the URL, or the page's `file:///` URL after a `NOTE:` giving the reason when publishing fails. The one hand-written part is the notes file — the verdicts in words, each frame's proof and what a re-capture would need, the lede and the G-series gaps — whose schema is in the script's docstring; keep it in the repo's `tmp/` beside the sheet. The dots and verdicts compare against `--base`, `HEAD` by default, and the legend calls that "this session": once the session has committed, pass `--base <first-session-commit>^` on every later build, the commit `HEAD` was at before the session's first commit, read from `git log`. The builder needs a `screenshots.json`; for pictures outside a manifest — an icon, a candidate in `tmp/`, a screenshot the user supplied — build the page from the template's parts by hand, keeping its data URIs, theme toggle and corner crops. Otherwise do not fill the template's slots by hand or hand-roll the HTML from this description: the script is what makes two runs, in two repos, by two agents, produce the same page, and a sheet built another way silently drops the parts described nowhere else: the `#intro` section with its lede and the summary table of every frame, the legend, `W × H → W × H` in each meta line, the 6× nearest-neighbour corner crops that carry pixel-level proof, the `#theme` toggle that tests a contrast claim against both themes, the **Show previous version** and **Show before framing** checkboxes, and ← / → stepping. `references/screenshot-manifest.md` explains why each part is there. Open the finished page in a browser before handing it over, which is the check that catches what grepping the HTML cannot. Do **not** substitute reading images into the transcript: they arrive in the order they were read, split by tool output, and six pictures from a three-image run cannot be paired up by eye.
     **Hand over the URL the script printed, verbatim, as a Markdown link** and let the user open it themselves: the tailnet URL opens on either machine, where a `file:///` link opens only on the one that wrote it. When the script fell back to `file:///`, hand that over with the reason from its `NOTE:` line, never as a silent substitute. Do not open it through an annotation tool: that returns nothing a reply cannot carry, and is not how these get read. Embed each image as a **base64 `data:` URI**, never a path, so the page is self-contained: the sheet and the saved originals sit in different directories, and a sheet that still renders after `tmp/` is cleared is worth the bytes. Scale for display with CSS (`max-width`) — never by touching the file. A clean pass stays a one-line text summary: do not open a browser to say nothing is wrong.
   - **Number the frames, and make them one click apart.** Every entry gets a number matching its manifest order, shown in its heading and used in the chat write-up too, so "re-shoot 2 and 6" is unambiguous where "the help one and the crew suits one" is not. The sheet carries a fixed list of those numbered entries down the left, outside the content column, each switching to its frame on a single click; a page that can only be scrolled makes the reader hunt for the shot they were just told about. Keep an **All frames** view alongside it for reading straight down, since the argument for showing every frame is that they get compared.
   - **Then ask, with the sheet open.** The page states the case; it does not collect the decision. Ask once, covering every screenshot in question, and let the user settle each on its own — a run that proposed three replacements may have got two right. **Before a capture** the question is which frames to shoot, and whether the framing described is the one they want. **After one** it is keep, revert, or re-capture: **revert** copies the saved original back; **re-capture** means the framing was wrong, so change `docs/screenshots/capture/<id>.sh` and run it again — never re-shoot by hand around a script that produced the wrong thing, because the next run would reproduce that same wrong thing.
   - Do not restate layout or markup rules here — `~/.claude/skills/github-pages/SKILL.md` owns the `docs/screenshots/` location, the per-page relative-path table, and the hero-versus-inline image markup. Consult it when a screenshot has to be added, moved, or re-pathed.

5. **Read `CLAUDE.md`** (project-local `.claude/CLAUDE.md` if it exists, otherwise repo root) and fix any stale file descriptions

6. **Check comments and docstrings** in modified source files (use **Uncommitted changes** and **Full diff** to identify them) that reference changed behavior

7. **Update dimensioned drafts** — only if the repo keeps drafts (`git ls-files -- '*dimensioned_drafts/*.py'`, which lists tracked drafts and so skips ignored dirs by construction; skip this step when it prints nothing). Drafts are documentation of model geometry: when a model source file changed in the diff, find the draft scripts that document it (match by model name/directory and by constants mirrored from the model's dimensions class) and check every drawn value — dimensions, profile vertices, removed/added features, not just labels. Update the draft script to the current model, re-run it to regenerate the SVG, and include both files in the change set. A draft documenting a feature the model no longer has is stale documentation just like prose.

8. **Suggest improvements** — if documentation would benefit from a new file or reorganization, suggest it to the user and wait for approval before proceeding

9. **Report** what was updated. If nothing was stale, say so. Call out README ↔ `docs/index.md` mismatches explicitly, even when fixed. When step 4 opened an HTML report, say so and do not restate its findings in the chat — repeating them splits one decision across two surfaces, and the annotations come back from the page.

   **Mark every claim this run could not check, not only the paths.** A cross-repo *pointer* is the easy one to notice, because a missing file is visibly a thing the sandbox cannot see. A sentence asserting how another repo's code *behaves* is the same unverifiable claim wearing prose, and it reads as settled fact in the patch — so it goes out under the same flag, naming what would settle it. The word a document already uses is not evidence either: carrying it forward is how an unchecked claim acquires a second source. Measured 2026-10-01: a patch rewrote a docstring to say the dashboard recognises a relayed turn by "a preamble it minted", flagged the `classification.md` path beside it as unverified, and said nothing about the mechanism — which was wrong, reached a pushed commit, and took one `grep` for the constant to settle once anybody looked.

## Out of scope

- Do NOT touch code logic — only comments, docstrings, and doc files
- Do NOT patch, or memo, a documentation gap in a file the project's upstream owns, except where it belongs to a change the user carries on their own branch or to an issue they have already raised. Report the rest and stop; raise one upstream only when the user asks
- Do NOT create new documentation files or restructure existing ones without explicit approval
- Do NOT capture, crop, resize, recompress or retouch an image whose manifest entry says `never` — report those and let the user decide. Capture without asking is permitted only under `auto`; every other state, including an absent `policy`, requires their approval for that specific replacement first. Opening the step-4 HTML report is **not** covered by this: it renders findings to look at rather than producing an image, and alters nothing on disk
- Do NOT capture a *new* shot for a documented gap without the explicit approval step 4 requires — never bundled into another yes, never inferred from enthusiasm about the gap itself
- Do NOT delete an image file, and do NOT remove a manifest entry, without explicit confirmation
- Do NOT write a `policy` the user did not give you, and do NOT raise one (`never` → `confirm` → `auto`) on their behalf — it widens what future runs may do unasked, so the key is theirs alone. Leaving it out is always available and always safe. Every other field is this skill's to maintain
- Do NOT add a screenshot flag, demo mode, or seed-data hook to the product to make a capture easier. This holds even with a capture approved: permission to take the picture is not permission to change the product so the picture looks better. A fixture replayed through an input the product already takes is not such a hook. If neither real use nor such a fixture can reach the state, the shot is the user's to take
