# bw-guide-shot

Headless-Chrome screenshot capture for WordPress admin screens, for the
[BW Guides](../../wp-content/plugins/bw-guides/) authoring pipeline (Layer 3
of `docs/PLATFORM-ROADMAP.md`) and any other project that needs a clean,
authenticated wp-admin screenshot.

Entry point is the wrapper, not this directory directly:

```
tools/bw-guide-shot.sh --project <name> --path "<wp-admin path>" --out <file.png>
         [--selector "<css selector>" | --region <name>]
         [--actions "<verb:arg; verb:arg; ...>"]
         [--trim] [--width <px>] [--height <px>] [--full-page]
```

Run `tools/bw-guide-shot.sh --help` for the full option reference.

## Examples

```bash
# Fixture: crop the page-settings sidebar, trim whitespace.
tools/bw-guide-shot.sh --project guide-shots \
  --path "/wp-admin/post.php?post=2&action=edit" \
  --out /tmp/settings-sidebar.png \
  --region settings-sidebar --trim

# A client site's custom post type list, full viewport.
tools/bw-guide-shot.sh --project brentwooddev \
  --path "/wp-admin/edit.php?post_type=staff" \
  --out /tmp/staff-list.png

# Same page, cropped to just the list table.
tools/bw-guide-shot.sh --project brentwooddev \
  --path "/wp-admin/edit.php?post_type=staff" \
  --out /tmp/staff-list-table.png \
  --region list-table --trim

# Click a block inside the editor canvas iframe, then capture its Block
# settings tab. See "Interactions" below for the full DSL.
tools/bw-guide-shot.sh --project guide-shots \
  --path "/wp-admin/post.php?post=2&action=edit" \
  --out /tmp/block-settings.png \
  --actions "click:.block-editor-block-list__block; waitfor:.interface-complementary-area" \
  --region block-inspector --trim
```

## Named regions (`selectors.json`)

| Region | Selector |
|---|---|
| `settings-sidebar` | `.interface-complementary-area` |
| `block-inspector` | `.interface-complementary-area` (alias of `settings-sidebar` — after `click:`-ing a block, WordPress switches this same panel to its Block tab; there's only one sidebar container, so the two regions share a selector on purpose) |
| `editor-canvas` | `.interface-interface-skeleton__content` |
| `admin-menu` | `#adminmenuwrap` |
| `list-table` | `.wp-list-table` |
| `full` | *(none — viewport or `--full-page`)* |

Add more by editing `selectors.json` directly — it's a flat `{"name": "css selector"}`
map (`null` for "no selector"). `--selector` accepts any raw CSS selector for
one-off captures that don't warrant a named entry.

If the selector doesn't appear within 15 seconds, the tool falls back to a
full viewport (or full-page, with `--full-page`) screenshot, prints
`SELECTOR_MISSING`, and still exits `0` — a missing selector is a WordPress
UI-changed warning, not a tool failure. Resolution tries the main frame
first, then every child frame (see "Interactions" below) — a `--selector` or
`--region` target inside Gutenberg's editor-canvas iframe resolves the same
way an `--actions` step finding it does.

## Interactions (`--actions`)

By default the tool loads a screen and screenshots it with **zero
interaction** — anything only revealed by clicking (a block's own settings,
the classic-metabox "Meta Boxes" drawer, a dropdown, a hover toolbar) can't
be captured. `--actions "<script>"` runs a small scripted-interaction DSL
after page load (and after the welcome-modal dismissal), before the
screenshot:

```bash
tools/bw-guide-shot.sh --project guide-shots \
  --path "/wp-admin/post.php?post=2&action=edit" \
  --out /tmp/block-settings.png \
  --actions "click:.block-editor-block-list__block; waitfor:.interface-complementary-area" \
  --region block-inspector --trim
```

### DSL reference

Steps are separated by `;`; each step is `verb:arg`. Leading/trailing
whitespace around each step is trimmed; empty steps (a stray leading/trailing
`;`, or `;;`) are silently ignored. Verbs are case-insensitive.

| Verb | Syntax | Does |
|---|---|---|
| `click` | `click:<selector>` | Click the element. |
| `hover` | `hover:<selector>` | Hover the element. |
| `focus` | `focus:<selector>` | Focus the element. |
| `type` | `type:<selector>\|<text>` | Focus + type `<text>` into the element. |
| `key` | `key:<KeyName>` | Press a key globally (`Escape`, `Enter`, `Tab`, `ArrowDown`, ...) — puppeteer `keyboard.press`; not tied to a selector/frame. |
| `waitfor` | `waitfor:<selector>` | Wait for the element to exist and be visible; no action taken. |
| `scrollto` | `scrollto:<selector>` | Scroll the element into view. |
| `wait` | `wait:<ms>` | Sleep. Bounded: max 10000ms. |

Each step is parsed by splitting on the **first** `:` only — CSS selectors
legitimately contain `:` (`:has()`, `:not()`, `::part()`, pseudo-classes in
general), so only the first colon is treated as the verb/arg separator.

`type:` further splits its argument on the **first** `|` — everything before
is the selector, everything after is the literal text to type (kept exactly
as given, not trimmed, since leading/trailing spaces may be intentional).
Two hard limitations fall out of reusing `;` and `|` as delimiters instead of
a real quoting syntax:

- Typed text **cannot contain `;`** — the outer split on `;` happens first,
  so a `;` inside your text gets misparsed as the start of a new step (which
  will then almost always fail validation, since the fragment after it
  rarely has its own `:`). There's no escaping for this — if you need to
  type a semicolon, this DSL can't express it today.
- A `type:` selector **cannot contain a literal `|`** (e.g. a CSS namespace
  selector like `ns|element`) — the first `|` is always taken as the
  selector/text boundary, no matter what.

### Iframe awareness

Modern Gutenberg renders post content inside an iframe
(`<iframe name="editor-canvas">`) — block markup, the block toolbar, and most
block-level UI live inside it, not in the main document. Every
selector-taking verb (`click`, `hover`, `focus`, `type`, `waitfor`,
`scrollto`) — and the pre-existing `--selector`/`--region` capture path —
resolve a selector by trying the **main frame first, then every child
frame**, waiting up to the step's timeout for the element to appear AND be
visible in whichever frame actually has it. In practice this means
`--region block-inspector` (or any `--selector`) works whether the element
lives in the main document or inside the editor canvas, with no special
syntax — the resolver just looks in both places. Frames are re-read on every
poll, so a selector that only exists in an iframe that hasn't mounted yet is
retried correctly once it does.

### Read-only rule

**Interaction scripts must never change state.** Never script a click on
Publish, Update, Save, Save Draft, Trash, Delete, or any other state-changing
control — full stop, regardless of what the navigation tripwire below does
or doesn't catch.

A **navigation tripwire** aborts the shot if any step causes the main frame
to issue a real navigation request (a followed link, a form submit). This is
a backstop for *accidental* submits/link clicks in an otherwise-read-only
script — **it is not permission to deliberately click something
state-changing and rely on the tripwire to save you.** WordPress can do a lot
of damage (publish a draft, trash a post, delete a user) in the single click
itself, well before any navigation would even happen.

The tripwire specifically watches for a genuine top-level navigation
*request* on the main frame (CDP `requestId === loaderId`, resource type
`Document`) rather than diffing `framenavigated` event URLs. That distinction
is deliberate, not incidental: Gutenberg performs benign same-document
`history.replaceState()` URL rewrites entirely on its own — e.g. the very
first auto-draft save on `post-new.php` swaps the visible URL to
`post.php?post=<id>&action=edit` with **zero** network requests involved —
and a naive URL-comparison tripwire would false-positive on that every
single time, action script or not. A real navigation (a submit, a followed
link) always issues an actual document-type request; Gutenberg's own SPA URL
bookkeeping never does. Request-based detection catches the real danger
(actual navigations) while staying completely silent on that benign case.

### Caps

- **Max 15 steps** per `--actions` script.
- **45-second total budget** across all steps — the run aborts once
  exceeded. This is checked *between* steps, not mid-step: a single slow
  step (a near-max `wait:`, or a selector wait that runs its full timeout
  before failing) can finish before the check fires.
- Each selector-visibility wait defaults to **10 seconds**, further capped by
  whatever's left of the 45s budget — step 14 of a full 15-step script won't
  get a fresh full 10s if the budget is nearly spent.
- Both caps are fixed, not CLI-configurable. They're a safety ceiling, not a
  tuning knob.
- The whole script is parsed and validated **before Chrome launches** — an
  unknown verb, a missing `:` or `|`, too many steps, or an out-of-range
  `wait:` all fail immediately with `CAPTURE_ERROR` and exit 1. No browser
  process ever starts for a script that doesn't validate.

### Failure / debug-PNG workflow

If any step fails — selector never found/visible within its timeout, the 45s
budget is exceeded, or the navigation tripwire fires — the run:

1. Writes a **full-viewport** debug screenshot to `<out>.failed.png` (not
   cropped to any selector/region — the point is to show what the page
   actually looked like when it broke).
2. Prints `FAILED_STEP <n> <verb> <detail>` on stderr (the wrapper relays
   this as `[ERROR] Action step failed: FAILED_STEP ...` and also points at
   the `.failed.png` path).
3. Exits **2** — distinct from the generic `CAPTURE_ERROR`/exit-1 path used
   for pre-launch validation errors and other unexpected failures. The
   normal `--out` file is **not** written when a step fails.

Iterate by looking at `<out>.failed.png` — it shows the exact state Chrome
was in at the moment of failure: which frame the page was actually showing,
whether a modal/overlay is covering the target, whether the page scrolled
somewhere unexpected, a typo'd selector, etc. Adjust the script and re-run.

### The `BWG-SHOT` marker (`write-guides` skill)

The `actions=` attribute is the documented way to carry an interaction
script through a guide's `BWG-SHOT` marker for later capture:

```
<!-- BWG-SHOT id="x" path="/wp-admin/post.php?post=2&action=edit"
     region="block-inspector"
     actions="click:.block-editor-block-list__block; waitfor:.interface-complementary-area"
     caption="..." -->
```

This is a **documentation-only contract** — the publisher strips markers at
import time, and there is no marker-parsing code in this tool. Whoever runs
the capture reads the marker's `actions` attribute and passes it straight
through as `--actions`. The same read-only rule applies inside a marker as
on the command line: never author a marker whose `actions` clicks
Publish/Update/Save/Trash.

## Security model

**This tool has exactly one capture mode: MITM-fulfill.** There is no
"temporarily ungate the site" mode, no flag to add one, and no code path
that calls `srv-gw id-gate` / `id-ungate` for anything. That mode was in the
original design (for the no-data `guide-shots` fixture only) but wasn't
needed — MITM-fulfill worked against both the fixture and a real
client-data site (`brentwooddev`) on first real use, so the less-safe
fallback was never built. See "Why MITM, and why it was built first" below.

### What "MITM-fulfill" means, concretely

The browser is made to believe it is browsing `https://<project-domain>/` —
correct origin, correct scheme, cookies scoped correctly, all relative
asset URLs resolve — but Chrome is **never told to open a network
connection to that hostname**. Every request Chrome would make for that
host is intercepted before dispatch (Puppeteer/CDP request interception),
and `capture.js` fulfills it itself: a plain HTTP call, in the same Node
process, to the project's container at its documented host-only address
(`172.17.0.1:<port>` — see `/srv/CLAUDE.md`, never internet-reachable),
with `X-Forwarded-Proto: https` so WordPress's `is_ssl()` resolves the same
way it would behind the real Caddy proxy. The upstream response (status,
headers, body) is handed back to Chrome via `request.respond()`. Chrome
never dials the real site — not over HTTP, not over HTTPS, not through
Caddy, not through id-auth.

Consequences of that design:

- **No site is ever ungated.** id-auth's gate is never touched, for the
  fixture or for any client site. There is nothing to "remember to
  re-gate" and no window during which a site is exposed.
- **No public request is ever made for the target site.** Not one packet
  for `<project-domain>` reaches the internet, Caddy, or id-auth — the
  entire round trip is Node-process → loopback-bridge container → back.
- **No listening socket is opened.** `capture.js` is a one-shot CLI. It
  makes exactly one kind of outbound connection (to `172.17.0.1:<port>`)
  and accepts none.
- **Requests to any other host are aborted, not proxied.** Web fonts,
  Gravatar, the `s.w.org` emoji script, etc. get `request.abort()`, not
  `request.continue()`. This tool has no legitimate reason to make *any*
  real network call, so the default is zero outbound network activity
  full stop — not just "protect the target site." A missing emoji sprite
  or fallback font on an admin screenshot is a cosmetic no-op; there was
  no case in testing where this affected a capture. If a future guide
  genuinely needs an external resource to render correctly, change the
  `host !== args.targetHost` branch in `capture.js` deliberately — don't
  flip it globally without reading this paragraph again.

### Defense in depth beyond the JS-level interception

CDP request interception is thorough but not provably total — the known
gap is WebSocket upgrades, which the `Fetch` domain does not intercept.
Core wp-admin doesn't use WebSockets, but this tool doesn't rely on that
holding forever. Chrome is launched with:

```
--host-resolver-rules=MAP <target-host> 192.0.2.1
```

`192.0.2.1` is `TEST-NET-1` (RFC 5737): reserved for documentation, globally
unroutable, and not coincidentally a real local service the way `127.0.0.1`
might be. If anything in Chrome ever tries to actually resolve/dial the
target hostname — a bug, an edge case, a browser-internal warmup — this
makes that attempt fail closed instead of quietly reaching a real network.
`--dns-prefetch-disable` and a standard set of "don't phone home"
launch flags (`--disable-background-networking`,
`--disable-domain-reliability`, `--disable-sync`, etc.) reduce Chrome's own
background chatter as well.

### Credentials

- **No password is ever stored.** Auth is a WordPress session cookie pair
  (`wordpress_sec_<hash>` + `wordpress_logged_in_<hash>`) minted on demand
  for the `support` user via `srv-gw wp -- eval` (a fresh
  `WP_Session_Tokens` entry, 30-minute expiry), written straight into a
  `mktemp`'d, `chmod 600` file that only the wrapper and `capture.js` ever
  read. Nothing is printed to stdout/stderr/logs at any point.
- **The specific session token is destroyed on exit — success or
  failure** (`trap cleanup EXIT` in the wrapper), via
  `WP_Session_Tokens::destroy($token)`. It is **never**
  `destroy_all()` — the site owner may have live interactive browser
  sessions logged in as the same `support` user, and force-logging those
  out is not this tool's call to make. Verified in testing: on
  `brentwooddev`, the `support` user had 2 pre-existing sessions before
  any capture ran; after two full capture runs the count was still
  exactly 2 — only the tool's own minted-and-destroyed tokens ever
  touched the count.
- **Known residual gap:** a bash `trap ... EXIT` fires on essentially any
  exit path (normal return, `die`, Ctrl-C/SIGINT/SIGTERM) but *cannot*
  fire on `SIGKILL` or a host crash. In the rare event this script is
  killed with `-9` mid-run, check for stray `/tmp/bw-guide-shot.*` files
  and, if needed, manually list/destroy sessions for the project's
  `support` user. This is the same class of caveat already accepted
  elsewhere on this server (e.g. id-auth's no-auto-re-enable window) —
  documented rather than silently assumed away.

### Container access

`capture.js` hardcodes `172.17.0.1` as the only address it will ever
connect to — it is not a CLI parameter and there is no way to point this
tool at any other IP. That's deliberate: accepting an arbitrary target
address would turn a screenshot tool into a general-purpose SSRF proxy.
The port and domain come only from `srv-gw status --project <name>`
(gateway-controlled truth), never typed/guessed.

### Why MITM, and why it was built first

The alternative considered in the design (and still described, historically,
in `docs/PLATFORM-ROADMAP.md`) was "temporarily ungate the site, capture
over the real HTTPS origin, re-gate in a `finally`." That's acceptable for
`guide-shots` (a no-data throwaway fixture) but was explicitly ruled out
for any real client site — and id-auth has no auto-re-enable timer, so a
crash mid-run could leave a site exposed with no backstop.

MITM-fulfill removes the exposure window entirely rather than
minimizing it: there's no gate state to restore because the gate is never
touched. It was verified end-to-end against both `guide-shots` and
`brentwooddev` (a real client site) on the first real attempt, so the
ungate-mode fallback (project-whitelist, trap-guaranteed re-gate) was
never implemented. If a future site somehow defeats MITM (e.g. a page
that hard-requires a real external resource to render, and aborting it
breaks the capture) treat that as a reason to extend MITM (a
per-project allowlist of extra hosts to relay instead of abort) — not a
reason to reach for ungate-mode.

## Files

| File | Purpose |
|---|---|
| `../bw-guide-shot.sh` | Wrapper: arg parsing, region resolution, `srv-gw status` lookup, cookie mint/destroy, ImageMagick trim, permission fix-up. |
| `capture.js` | The MITM-fulfill capture engine (puppeteer-core only) — page load, `--actions` interaction scripts, iframe-aware selector resolution, screenshot. |
| `selectors.json` | Named `--region` → CSS selector map. |
| `package.json` / `node_modules/` | `puppeteer-core@23` — no Chromium download; drives the system's `/usr/bin/google-chrome`. |

## Requirements

Already present on this server: `/usr/bin/google-chrome`, `node`, `jq`,
`/usr/bin/convert` (ImageMagick, for `--trim`), `srv-gw`.

## Privacy: what to do when a capture contains private data

Checking every capture for private information is required. But don't
capture-and-discard in a loop — escalate:

1. **Prefer a clean source.** Generic WP screens: shoot the `guide-shots`
   fixture. Client-specific features: create a sample/draft record on the
   client site (delete it after), or shoot the empty add-new form.
2. **Redact when incidental data is unavoidable:** `redact.sh <img>
   <WxH+X+Y> [...]` (solid boxes; `--pixelate` for faces). Blur alone is NOT
   sufficient for text.
3. **Inspect locally before `media import` — always.** The hub media library
   is publicly served and may be CDN-cached: deleting after upload is not a
   reliable undo. Capture → inspect → (redact) → import, never import first.

Form submissions, emails, phone numbers, and non-public names count as
private even though a logged-in user could see them in wp-admin.

## Capturing logged-out screens (`--anonymous`)

The tool normally mints a session cookie, so `/wp-login.php` redirects straight
to the Dashboard and login screens are uncapturable. `--anonymous` skips the
mint entirely and captures as an anonymous visitor:

```bash
tools/bw-guide-shot.sh --project guide-shots --path "/wp-login.php" \
  --anonymous --selector "#login" --trim --out login.png
```

The result reports `"mode":"mitm-anonymous"` and `"loggedIn":false`. Everything
else (host-only MITM fulfilment, no outbound requests) is unchanged. There is
no session token to destroy, so nothing is minted or cleaned up.

**Out of scope:** screens that require a *failed* third-party round-trip (e.g. a
rejected Google sign-in). Those need real external navigation, which capture
deliberately aborts. Document those states in prose or use a manual screenshot.

## Gotcha: Gutenberg persists editor UI state per USER

`isComplementaryAreaVisible` (settings sidebar open/closed) and the welcome-modal
flag are stored in **user meta**, not per session. Consequences:

- A script that clicks the Settings button when the sidebar is **already open**
  *closes* it — and that closed state persists to every later run, for every
  caller, on that project.
- The symptom is confusing: a `--region settings-sidebar` / `block-inspector`
  capture that worked yesterday suddenly reports `SELECTOR_MISSING`.

**Fix:** add `--reset-ui`, which pins the sidebar open and the welcome modal
dismissed before capturing (surgical merge — nothing else in the preference blob
is touched). Use it routinely for any sidebar/inspector shot rather than
assuming inherited state.

## Field recipes (verified on real sites)

- **ACF boxes:** `--selector '#acf-<group_key>'` crops a whole ACF metabox
  cleanly; `--selector '.acf-field[data-name="<field_name>"]'` isolates a single
  field.
- **Classic metaboxes** live in a collapsed drawer in the block editor. Expand it
  with `focus:.edit-post-meta-boxes-main__presenter button; key:Enter` — a plain
  `click:` can silently hit an overlapping element (see the interaction notes
  above). `--region metaboxes-drawer` targets the container.
- **Block settings:** `--reset-ui` → open the inserter → type the block name →
  click `.editor-block-list-item-<derived-slug>` → `waitfor:.interface-complementary-area`
  → capture `--region block-inspector` (or better, `--clip` — see the race section).

  **Deriving the slug** (this is generated by `getBlockMenuDefaultClassName` in
  `wp-includes/js/dist/blocks.min.js` — NOT `block-editor.min.js`):
  `"editor-block-list-item-" + name.replace(/\//, "-").replace(/^core-/, "")`
  - `bw/card-gallery` → `.editor-block-list-item-bw-card-gallery`
  - `core/paragraph` → `.editor-block-list-item-paragraph` — **the `core-` prefix is
    STRIPPED.** Testing a core block with the obvious-looking `-core-paragraph` form
    finds nothing and makes it look as though the whole scheme is gone. It isn't.

  Prefer this exact class. Positional matching on
  `.block-editor-block-types-list__item` is a **fallback only** — it depends on inserter
  search ranking, which shifts with the search term, the block's title, and any plugin
  that reorders results. An exact class is deterministic.
  **Do NOT add a `click:button[aria-label='Settings']` step.** `--reset-ui`
  already pins the sidebar open, so that click would *close* it — "use
  --reset-ui" and "click Settings" are mutually exclusive instructions.
- **Query-heavy blocks need longer waits.** Blocks that fetch posts (listings,
  galleries, video lists) commonly need `wait:4000` — 1500ms fails
  intermittently, which looks like a selector bug but is a race.
- **ACF repeaters** whose first row is expanded by default capture with no
  interaction at all.

## Silent wrong captures: check the `capturedElement` diagnostic

An element capture can report `"selectorFound":true` and still contain the wrong
thing — if the resolved element has a real bounding box but isn't visually what
you expect (a collapsed/off-position panel), the screenshot clips the page at
those coordinates and you get whatever is underneath. This has happened in
practice with `block-inspector` / `settings-sidebar` shots when the sidebar was
closed, and it is nastier than a clean `SELECTOR_MISSING` because nothing
flags it.

Two defences:

1. **Prevention:** always pass `--reset-ui` for sidebar/inspector captures.
2. **Detection:** the result JSON includes `capturedElement` — the element's
   width/height/x/y, its computed `opacity`/`visibility`, and `textPreview`
   (the first ~120 characters of its text). For a real block inspector the
   preview contains words like `Block`, `Styles`, `Typography`. If it contains
   your page's content instead, the capture grabbed the canvas — re-run with
   `--reset-ui`.

Unambiguous failures (zero-size box, `opacity:0`, `visibility:hidden`) are
treated as not-found and fall back to a viewport shot with `SELECTOR_MISSING`
rather than silently succeeding.

## Verified selectors (WP 7.0.2, confirmed by capture)

| Purpose | Selector |
|---|---|
| Inserter toggle (the black **+**) | `.editor-document-tools__inserter-toggle` |
| Inserter panel (categories/search) | `.editor-inserter-sidebar` — `--region inserter` |
| Inserter search field | `.block-editor-inserter__search input` |
| Block toolbar (floats above a block) | `.block-editor-block-contextual-toolbar` — `--region block-toolbar` |
| Settings sidebar open/close toggle | `[aria-label="Settings"]` |
| A block in the canvas (iframed) | `.block-editor-block-list__block[data-type="core/<name>"]` |

Does **not** exist in this version (don't copy from older Gutenberg docs):
`.components-panel__body-toggle`. To expand a specific settings panel there is no
purpose-built selector — `.components-panel__body:last-of-type button` reaches the
last one (Advanced) but is positional and breaks if a panel is added after it.

**Tall panels:** the inserter grows with the viewport instead of scrolling
internally, so a full category list needs `--height 3200`. Anything over 2560px
gets auto-scaled by WordPress on `media import` (normal, not an error) — better to
crop to the region that makes your point than to ship a 3000px-tall image into a
guide.

## Trap: server-rendered blocks are inert in the canvas

Clicking an existing **server-rendered** block (a dynamic/PHP-rendered custom
block) in the editor canvas does **not select it** — the click lands on inert
output markup. The sidebar therefore stays on an empty inspector, and the shot is
useless. The tool still reports `selectorFound:true`, so this is another silent
failure; the giveaway is `capturedElement.textPreview` reading
`"Page Block No block selected."` (now emitted as a `CAPTURE_WARNING`).

**Fix: insert the block on a new page instead of clicking an existing one** —
insertion auto-selects it. That is why inserter-driven block shots work while
click-an-existing-instance shots don't.

## Built-in warnings

`capture.js` emits `CAPTURE_WARNING` lines on stderr (and a `warnings` array in
the result JSON) for the two failure signatures that have actually bitten us:

| Warning | Meaning |
|---|---|
| sidebar capture is not flush with the viewport right edge | The crop is straddling the editor canvas. A docked sidebar always ends exactly at the viewport's right edge (`x + width == viewport width`); anything else is a mis-resolved or closed panel. Re-run with `--reset-ui`. |
| inspector reads "No block selected" | The click didn't select a block — see the server-rendered trap above. |

These are deliberately narrow: they fire only on signatures verified in practice,
so a warning means something is genuinely wrong. Everything else is left to
`capturedElement.textPreview`, which you should read for any element capture — a
correct block inspector preview contains the block's name plus `Styles` /
`Typography`.

**Calibrating "is this shot right?" by image diff** (field-tested): comparing a
suspect capture against a known-good re-capture of the same panel, a *straddling*
crop differs by **11–20%** or changes dimensions outright, while two *correct*
captures of the same panel differ by **under 7%** (transient text like "Last
edited a second ago", slider positions). Useful when auditing a batch.

## embed-shots.py — turning markers into image blocks

```bash
tools/bw-guide-shot/embed-shots.py <draft.html> <shots.json> <out.html>
```

`shots.json` maps each marker `id` to `{"id": <attachment id>, "url", "alt",
"caption"}`. Frontmatter is stripped by default (`--keep-frontmatter` to keep it).
Caption/alt come from the **marker** when present — the draft is the source of
truth for wording — with the map as fallback.

**Use this rather than writing the substitution again.** It has been
re-implemented ad hoc several times and the marker regex was wrong twice, in two
ways that both ship internal text to client sites:

1. **Hardcoding attribute order.** A pattern like
   `id="..." path="..." region="..." caption="..."` stops matching the moment a
   new attribute (e.g. `actions="..."`) is added — and a non-match means the
   marker, including its capture recipe, is published verbatim.
2. **`[^>]*` inside the marker pattern.** Breaks on any attribute value
   containing `>`, which a CSS child selector in `actions="click:.a > .b"`
   naturally has.

The tool matches the comment lazily, scans attributes generically, and
**asserts** that neither `BWG-SHOT` nor `actions=` survives into the output. A
marker with no corresponding shot is removed entirely, never left behind.

## Race: element captures vs late layout shifts

An element capture is inherently two steps — **measure**, then **grab**. On screens that
finish laying out late (ACF metaboxes are the repeat offender) the page moves in
between, and the pixels come from stale coordinates. This produced live, published
screenshots cropped ~145px off target.

**No amount of inspecting the diagnostics catches it.** `capturedElement` reported
`x:1320, w:280, visible`, `warnings:null` — all truthful, because the measurement *was*
correct when sampled. The x-position check validates the measurement, not the pixels.

Mitigations, now built in:

- **Settle wait.** Before grabbing, the element's bounding box is polled until it stops
  moving (3 stable samples, 150ms apart, 6s cap). If it never settles you get a warning.
- **Re-measure and verify.** The grab uses an explicit clip computed immediately before,
  then the box is re-measured. If it moved, `capturedElement.movedDuringCapture` is set
  and a loud `CAPTURE_WARNING` tells you the image is probably wrong — the tool would
  rather shout than hand you a plausible-looking bad crop.

**For anything on an ACF-heavy screen, prefer `--clip`** (below). It cannot race, because
nothing is measured.

### `--clip WxH+X+Y` — deterministic rectangle capture

```bash
# measure once (a selector run reports capturedElement.capturedBox) …
tools/bw-guide-shot.sh --project X --path "…" --reset-ui --region block-inspector --out /tmp/m.png
# → "capturedBox":{"x":1320,"y":61,"width":280,"height":1114}

# … then capture deterministically, cropped to the useful part
tools/bw-guide-shot.sh --project X --path "…" --reset-ui --clip 280x640+1320+61 --out shot.png
```

**Coordinates are layout-specific — never copy them between sites.** The settings sidebar
starts at `y=61` on the `guide-shots` fixture but `y=97` on brentwooddev (different admin
notices/header height). Measure on the site you're shooting. That portability cost is the
tradeoff for being race-free.

### Aspect ratio is an authoring decision

A correct full-height sidebar capture is ~280x1078 — a 1:3.85 strip that reads terribly
in a guide. Cap it: `280x640` shows the block description plus the first settings panels,
which is the part that carries the point. Same applies to the inserter (350x3092 full →
crop to ~350x871). A screenshot's job is to orient, not to inventory.

### More verified selectors (WP 7.0.2, grepped from the shipped JS)

| Purpose | Selector | Region name |
|---|---|---|
| Header Save/Preview/View cluster | `.editor-header__settings` | `header-actions` |
| Status & visibility toggle → popover | `.editor-post-status__toggle` → `.editor-change-status__content` | `status-popover` |
| List View toggle → panel | `.editor-document-tools__document-overview-toggle` → `.editor-list-view-sidebar` | `list-view` |
| Inserter item, exact (preferred) | `.editor-block-list-item-<slug>` (see derivation above) | — |
| Inserter item, positional (fallback) | `.block-editor-block-types-list__item` | — |

Note the List View toggle's class still says `document-overview` — an older internal name
for the feature the UI now calls "List View". Verify selectors against
`wp-includes/js/dist/*.min.js` on the container rather than trusting older documentation
(including earlier revisions of this file).

### The `guide-shots` fixture's deliberate contents

Kept minimal but NOT empty, because an empty library makes some shots impossible:

- **2 placeholder images** (generated gradients, no copyright, no people) — without media
  in the library, the media-library grid shows an empty state and an Image block's
  **Alternative text** field never renders (it's conditionally shown only once an image
  is set). Both shots were previously written off as "not viable" for exactly this reason.
- **A draft page "Screenshot Fixture — Image Block"** containing a real image block, so
  alt-text/image-settings captures are a simple select-and-shoot instead of a fragile
  seven-step insert-and-choose interaction.

Add to this sparingly and keep it generic — it ships nothing, but every shot taken here
represents WordPress as clients see it.

### A fixed crop height only works for top-anchored content

`--clip 280x640+1320+<y>` captures the top of the settings sidebar, which is right for a
block inspector (block name, description, first panels). It is **wrong for anything lower
down** — taxonomy panels commonly sit at y≈700+ and a fixed 640px window cuts them off
entirely. Measure a per-shot rectangle for those instead of reusing the standard one.

### Don't hammer the gateway in a batch

The gateway is effectively single-threaded, and every capture does a `srv-gw status`
lookup plus a cookie mint and destroy. Firing ~14 captures back-to-back has been observed
to make `srv-gw status` transiently answer `running: false` for a container that had been
up for days — aborting 9 of 14 captures with a misleading "Project is not running".

The wrapper now **retries the status lookup up to 3 times with a 4s backoff** before
believing a not-running answer, which removes most of this. For large batches, still pace
them (a few seconds between captures) rather than firing them in a tight loop.

## ⚠️ The fixture is on a DIFFERENT WordPress version than the client sites

Verified 2026-07-24:

| Site | WordPress |
|---|---|
| `guide-shots` (the screenshot fixture) | **6.8.3** |
| `brentwooddev`, `bw-plugins`, `bw-plugins-dist`, `template` | **7.0.2** |

This matters more than it looks. **Standard guides are shot on the fixture and shipped to
every client site.** If the fixture lags, those screenshots show clients an older admin UI
than the one in front of them — the exact failure the fixture exists to prevent.

It also invalidates version-specific claims made "against WP 7.0.2" while actually testing
on the fixture. One such claim (that `.editor-block-list-item-<slug>` no longer exists) was
wrong on both counts: the class is present in **both** 6.8.3 and 7.0.2, generated in
`blocks.min.js`.

**Before trusting any version-specific finding, check which version you actually tested:**

```bash
srv-gw wp --project <name> -- eval 'echo get_bloginfo("version");'
```

**Action outstanding:** update `guide-shots` to match the client fleet, then re-shoot the
standard-guide screenshots taken from it. Until then, treat those shots as 6.8.3 UI.

### Counting gotcha: don't count images by raw block comments

`substr_count($content, 'wp:image')` counts **2 per image** — it matches the closing
`<!-- /wp:image -->` as well as the opening comment. That silently doubles any
"how many screenshots does this guide have" check, and has fooled people in both
directions.

Count one of these instead:

| What you want | How |
|---|---|
| Images a reader actually sees | `substr_count(wp_kses_post(do_blocks($content)), '<img')` |
| Image blocks in the source | `substr_count($content, '<!-- wp:image')` — note the leading `<!-- ` |

The first is the one that matters for verification: it goes through the client's real
render path, so it also proves the block survived `wp_kses_post`.

## check-shots.py — do declarations, markers and images agree?

```bash
tools/bw-guide-shot/check-shots.py /srv/apps/<project>/docs/guides-draft
```

Exit 0 if consistent, 1 if not (`--quiet` for exit code only). **Run it before every
import.**

It catches four silent drifts between a guide's shot *declaration* (`_frag/*.shots.md`),
its inline `BWG-SHOT` *marker*, and the *PNG* on disk. All four publish a wrong-looking
guide without erroring:

| Problem | Consequence |
|---|---|
| Declared but never placed | Image captured, imported, **never shown to anyone** |
| Image never displayed | Same failure seen from the PNG side |
| Duplicate marker | Same image embedded twice |
| Marker with no image | Embedder strips it; guide silently loses a planned image |
| Marker for a `NOT CAPTURED` shot | Stripped silently; the marker is a leftover |

**This is not hypothetical.** Six images across four guides were captured, imported, and
never displayed — the guides published with no screenshots at all. The signal was even
present in an import report as "orphan PNG, no marker in any draft" and was triaged as
housekeeping rather than as the defect it was. A machine check doesn't get tired or
generous.

A shot you deliberately won't capture must be marked `NOT CAPTURED` on its fragment `id`
line, with a reason. That turns an omission into a recorded decision, and this check then
expects it to have no marker.

### Stage media with the shot id as the filename

When importing a shot, name the staged file after the **shot id**:

```bash
cp shots/<shot-id>.png <dist>/wp-content/.bwg-<shot-id>.png   # good
cp shots/<shot-id>.png <dist>/wp-content/.bwg-signin.png      # bad — id is now unrecoverable
```

The media library filename becomes the only durable link between an attachment and the
shot it came from. A later pass looking up "is `signin-google-button` already imported?"
searches by filename — and a staged name that doesn't contain the shot id **won't match**,
so the image gets imported a second time and the first copy is orphaned. That happened
once: the same screenshot exists as `bwg-sg-signin.png` and `signin-google-button.png`.
Harmless, but it is avoidable bookkeeping debt.
