# BW Guides — Session Log

Append-only history of development sessions. Newest entries at the top.

## Format

```
## YYYY-MM-DD HH:MM — <dev name>

**Goal:** What we set out to do
**Done:** What got done
**Left off at:** Where we stopped
**Notes:** Anything worth remembering
```

<!-- BW-RELEASES:BEGIN — written by tools/release.sh; do not hand-edit -->
## Releases

Written by `tools/release.sh` at publish time. This block is the only claim about
release state in this file that stays current — a session entry below saying "not
released" was true when it was written and nothing updates it. Full history across
all plugins: `tools/release-history.json`.

> RELEASED 0.3.5 — 2026-08-13T16:13:56Z, sha256:dc675e72054788c8
> RELEASED 0.3.4 — 2026-07-27T04:27:59Z, sha256:c16c880620efc89d
> RELEASED 0.3.2 — 2026-07-24T16:25:34Z, sha256:0ab30efd34104264
<!-- BW-RELEASES:END -->

---

## 2026-07-24 16:50 — Claude (screenshots for the five original standard guides)

**Goal:** Capture and embed screenshots for the five ORIGINAL standard guides (23 Getting
Started, 24 How to Edit a Page, 42 Page and Post Settings, 59 Working with Blocks, 60 Images
and Media), which had none. Unlike the Brentwood guides, these five have no draft files or
`BWG-SHOT` markers — the hub post is the source of truth, so content was edited directly on
the hub and verified by stripping the inserted image blocks back out and diffing against the
pre-edit content (byte-identical, modulo whitespace).

**Done:** 9 new screenshots captured from the `guide-shots` fixture (never brentwooddev or any
client site) + 2 reused (191 inserter-categories, 187 block-toolbar) = 11 images across 4
guides: 23 (+3 login-screen/admin-menu/admin-bar), 24 (+2 pages-list/update-button), 42 (+3
page-settings-rows/status-visibility/post-sidebar-excerpt), 59 (+3
inserter-reuse/block-toolbar-reuse/list-view). Guide 60 got zero — see Notes. Every capture was
read (Read tool) before import, not just checked for `selectorFound`. Section-keyword heading
audit (own script, mirrors the hub's save-time check): 0 mismatches across all 5 guides
post-edit. Synced clean on both bw-plugins (sandbox) and brentwooddev: created=0, updated=4,
trashed=0. Sandbox render check (`wp_kses_post(do_blocks(...))`, the exact client pipeline)
confirms the expected `<img>` count on all 5 guides. Fixture's Sample Page / Hello world
`post_modified` byte-identical before/after (no save ever fired despite block-insert
interactions); fixture media count still 0.

**Left off at:** Guide 60 (Images and Media) still has zero screenshots — both its shots turned
out not viable against an empty-media fixture. Nothing else pending for these five guides.

**Notes:**
- Verified selectors against the ACTUAL shipped JS (curled `wp-includes/js/dist/editor.min.js`
  etc. straight off the container's host-only port, grepped for real class names) rather than
  trusting this doc's own "Field recipes" section — `.editor-block-list-item-<slug>` does NOT
  exist in WP 7.0.2; inserter block-type items have no per-block-name class anymore
  (`.block-editor-block-types-list__item`, positional/first-match only, which is fine when the
  search query's top hit is the block you want).

  > **⚠️ CORRECTION (added later, 2026-07-25 — this claim is WRONG; left in place because this
  > log is append-only, but do not act on it).** `.editor-block-list-item-<slug>` DOES exist,
  > in both 6.8.3 and 7.0.2. It is generated in **`blocks.min.js`** (NOT `editor.min.js` or
  > `block-editor.min.js`, which have zero occurrences — the likely source of this false
  > negative) by `getBlockMenuDefaultClassName`:
  > `"editor-block-list-item-" + name.replace(/\//,"-").replace(/^core-/,"")`.
  > **The trap: the `core-` prefix is STRIPPED**, so `core/paragraph` yields
  > `.editor-block-list-item-paragraph`, not `…-core-paragraph`; testing a core block with the
  > obvious form finds nothing. Exact-class matching is the PREFERRED method; positional
  > matching is a fallback only (it depends on inserter search ranking).
  > Also: **this session was actually running against WordPress 6.8.3, not 7.0.2** — the
  > `guide-shots` fixture is a version behind the client fleet. Any version-specific claim
  > here should be re-read with that in mind. See the 2026-07-25 entry. Newly verified and used successfully this
  session, worth promoting into the README's verified-selectors table:
  `.editor-post-status__toggle` → `.editor-change-status__content` (Status & visibility
  popover), `.editor-header__settings` (Save/Preview/View cluster), `.editor-document-tools__document-overview-toggle`
  → `.editor-list-view-sidebar` (List View, despite the UI now calling it "List View" — the
  class still says "document-overview", an older internal name).
- **New failure signature, distinct from the two documented `CAPTURE_WARNING`s:** an
  element-scoped capture of `.interface-complementary-area` right after inserting +
  auto-selecting a brand-new block came back fully BLANK — `selectorFound:true`, a plausible
  bounding box, correct `textPreview` — but the PNG itself was solid white, even after a 2.5s
  settle `wait:`. A same-script full-viewport capture at the identical moment rendered the
  sidebar correctly. Two *other* captures this session (`status-visibility`, `list-view`)
  showed a related diagnostic/pixel mismatch — bounding box measured mid-animation (opacity
  ~0.16 / a large negative x, i.e. still off-screen) — but self-corrected by the time the actual
  screenshot pixels were taken; this one didn't self-correct even with extra wait. Didn't chase
  the root cause since the shot wasn't viable anyway (see below), but flagging for whoever hits
  it next: if an element-scoped sidebar capture comes back suspiciously blank, try a
  full-viewport capture of the identical script before concluding the UI itself is broken.
- **Two shots not viable, by design of the empty `guide-shots` fixture (0 media items) — did
  not upload anything to force them, per instructions:**
  - `media-library` (guide 60): both the plain `/wp-admin/upload.php` grid AND the in-editor
    Media Library modal (Image block → Media Library) show "No media items found" / "No items
    found" with mostly blank space either way. Neither illustrates the feature.
  - `image-alt-text` (guide 60): confirmed via a full-viewport capture that the Block tab's
    "Alternative text" `ToolsPanelItem` is conditionally rendered in `block-library.min.js` — it
    doesn't appear on a freshly inserted, still-imageless Image block placeholder. Can't show it
    without a real image in the library.
  - **If guide 60 needs screenshots later:** the clean fix is adding one or two innocuous stock
    images to `guide-shots`' media library ONCE (e.g. a generic placeholder JPG/PNG) — that
    permanently unblocks both shots for this and any future session. Deliberately not done here
    since the brief said not to populate the fixture; flagging as the natural next step.
- Embedding used the same block template as `tools/bw-guide-shot/embed-shots.py` (checked after
  the fact, even though that tool didn't directly apply here — no draft file, no markers):
  `{"id":ID,"sizeSlug":"large"}` + `figure.wp-block-image.size-large` +
  `figcaption.wp-element-caption`.
- A concurrent session was active on the hub throughout this one (new attachment ids 238/240 and
  a new Brentwood guide appeared mid-session, unrelated to this work) — cross-checked before
  interpreting sync counts so brentwooddev's `remote:28` (grown from the ~27 in the last
  handoff) isn't mistaken for something this session caused.

---

## 2026-07-25 — Claude (capture race fixed · fixture version discovery · library complete)

**FINAL LIBRARY STATE:** brentwooddev **28 guides / 75 images / 0 locally-authored**,
sandbox **10 / 22**, 0 orphaned keyword rows, 0 slug mismatches, both endpoints healthy.
Exactly ONE guide per site carries no images, both deliberately: brentwooddev's
"Getting started with your Brentwood website" (hub 63 — a text-only orientation index that
routes to other guides) and the sandbox's local test note. NOTE: an earlier draft of this
entry misattributed the brentwooddev one to guide 19 "Brentwood blocks at a glance", which
does have its image (attachment 233) — corrected after the Brentwood session challenged it.

**Counting gotcha found while checking that:** `substr_count($content,'wp:image')` counts
**2 per image** (it matches the closing `<!-- /wp:image -->` too). Count rendered
`<img` tags on `wp_kses_post(do_blocks(...))` output instead — that also proves the block
survived kses. Documented in the tool README.

#### The capture race — a real bug that reached published guides

Rian spotted straddled crops live in guides 10 and 11. Root cause (diagnosed by the
Brentwood session): **element captures race late layout shifts.** Capture is
measure-then-grab; on ACF-heavy screens the page finishes laying out in between, so pixels
come from stale coordinates. Crops landed ~145px off; one was pure canvas.

**Why every safeguard passed:** `capturedElement` reported `x:1320, w:280, visible`,
`warnings:null` — all truthful, because the measurement *was* correct when sampled. My
x-position check validates **the measurement, not the pixels**. That was a reasoning error,
not a missing case, and it's the second time in one day a "verified" capture was wrong.

Fixed, in the order the Brentwood session ranked them:
1. **Settle wait** — poll the bbox until it stops moving (3 stable samples / 150ms / 6s cap).
2. **Re-measure + verify** — grab via explicit clip computed immediately before, then
   re-measure; if it moved, set `movedDuringCapture` and emit a loud `CAPTURE_WARNING`.
   The tool now shouts rather than hand over a plausible-looking bad crop.
3. **`--clip WxH+X+Y`** — deterministic rectangle capture, race-free by construction.
   Workflow: one selector run to read `capturedElement.capturedBox`, then `--clip`.
   **Coordinates are layout-specific** — y=61 on the fixture, y=97 on brentwooddev.

Also: wrapper now **retries `srv-gw status` 3× with 4s backoff**. A batch of 14 captures
made the (single-threaded) gateway transiently answer `running:false` for a container up 6
days, aborting 9 with a misleading error.

#### ⚠️ THE FIXTURE WAS ON THE WRONG WORDPRESS VERSION

`guide-shots` = **6.8.3**; brentwooddev / bw-plugins / bw-plugins-dist / template =
**7.0.2**. Surfaced while adjudicating a dispute between two sessions about whether a CSS
class existed "in WP 7.0.2" — the session claiming to test 7.0.2 had been testing 6.8.3 all
along. **Standard guides are shot on the fixture and ship to every client site**, so their
screenshots currently show an older admin UI than clients have. Flagged for rian as a
decision (update fixture → re-shoot), not actioned unilaterally. Brentwood's own guides are
unaffected (shot on brentwooddev).

**Process rule added:** before trusting any version-specific finding, confirm the version
actually tested — `srv-gw wp --project <name> -- eval 'echo get_bloginfo("version");'`.

#### I documented a wrong finding and reverted it

I had recorded that `.editor-block-list-item-<slug>` "does not exist in WP 7.0.2" and
replaced it with positional matching. **Wrong.** Verified independently: the class is
generated in **`blocks.min.js`** (zero occurrences in `block-editor.min.js` — the likely
false-negative source) by `getBlockMenuDefaultClassName`, present in BOTH 6.8.3 and 7.0.2.
**The trap: the `core-` prefix is stripped**, so `core/paragraph` →
`.editor-block-list-item-paragraph`. Testing a core block with the obvious `-core-` form
finds nothing and makes the scheme look absent. Exact-class matching restored as primary;
positional demoted to fallback (it depends on inserter search ranking).
The decisive evidence was empirical: 14 captures that could only have worked if the class
resolved, versus a grep of the wrong bundle.

#### The six invisible images — and the machine check that now catches them

**Six images across four guides were captured, imported, and never displayed.** The guides
published with no screenshots despite having them ready. Cause: those four were hand-written
with the fragment and the guide body authored as separate steps, so the inline `BWG-SHOT`
marker was never added. Subagent-written guides were unaffected — their brief said to inline
the marker.

**The signal was available from the very first import report**, which listed exactly these
six under "orphan PNG (shot exists, no marker in any draft)" and even guessed the right
guides. It was triaged as housekeeping rather than as a defect. That was my misread, and it
is the reason this is now a machine check rather than a habit:

`tools/bw-guide-shot/check-shots.py <project>/docs/guides-draft` — verifies declarations
(`_frag/*.shots.md`), inline markers, and PNGs on disk all agree. Catches: declared-but-never-
placed, image-never-displayed, duplicate markers, marker-with-no-image, and markers for shots
flagged `NOT CAPTURED`. Exit 0/1, so it can gate an import. **Negative-tested against a
deliberately broken fixture — all five failure modes fire.** A check never seen to fail is
worthless. Now referenced in the write-guides skill as a pre-import step.

Final coverage audit across all 20 drafts: **60 markers, 60 PNGs, perfect 1:1, zero orphans
in either direction.** How this surfaced is worth remembering: I mislabelled which guide was
text-only, the Brentwood session noticed the name didn't match the guide it knew, and checking
that discrepancy exposed a real defect in four *other* guides.

**One duplicate attachment I caused:** I staged `signin-google-button.png` as
`.bwg-sg-signin.png`, so a later lookup by filename couldn't find it and imported it again
(276 and 278 are the same image; 278 is the one in use, 276 is orphaned). Lesson now in the
README: **stage media with the shot id as the filename** — that filename is the only durable
link between an attachment and its shot.

#### Content work completed this pass
- 14 inspector shots re-captured race-free by the Brentwood session (verified by eye, not
  by JSON), re-imported (attachments 252–265), 10 guides rebuilt. 16 orphaned attachments
  recorded, none deleted.
- `livestream-categories` **deleted** rather than shipped weak — its panel rendered closed
  behind a Yoast button, so the crop was a header row in whitespace. Marker, fragment and
  image all removed; guide 03 now has 3 shots.
- `blog-status-date` was a duplicate of `blog-categories`; now its own crop of the
  Status/Publish panel, matching the caption it always claimed.
- Captions corrected where they described invisible state (taxonomy panels render *closed*
  on a new course).
- **Guide 14 completed** with the `signin-google-button` shot — the capture `--anonymous`
  was built for. `signin-wrong-domain` stays prose-only by agreement (it needs a genuinely
  rejected Google round-trip).
- Guide 60 unblocked permanently: the fixture now holds 2 generated placeholder images and
  a draft page with a real image block. The Alternative text field only renders once an
  image is set — exactly why it was previously "not viable".

## 2026-07-24 (final+) — Claude (0.3.3 normalisation fix · guide 19 · canonical embedder)

**Client 0.3.3 — the paired normalisation fix, staged not released.** New
`BW_Guides_Keywords::normalize_for_match()` is applied to BOTH heading↔keyword matching and
search matching: decodes HTML entities, straightens curly apostrophes/quotes, converts
non-breaking spaces, collapses whitespace. Query tokens additionally have apostrophes
stripped and the haystack is compared both with and without its own. This kills two silent
failures at once — a keyword `width & height` never matching content rendering
`width &amp;amp; height` (`wp_strip_all_tags` doesn't decode entities), and `cant get in` never
reaching a keyword written `can't get in`. Verified on both sites: `cant get in` now
resolves, entity guide still resolves, normaliser unit checks 4/4, no regressions. Gates
green. Deployed by direct install; release is rian's call.
Out of scope on purpose: "width and height" still won't match "width & height" — that's
semantics, not normalisation, and guessing there causes wrong matches rather than preventing
missed ones.

**Canonical embedder promoted to `tools/bw-guide-shot/embed-shots.py`.** The
marker→image-block substitution had been re-implemented several times and the regex was wrong
twice, both ways shipping internal text to clients: hardcoded attribute order (stops matching
when `actions="…"` appears, publishing the marker verbatim) and `[^>]*` (breaks on a `>`
inside an actions value, e.g. `click:.a > .b`). The canonical version matches lazily, scans
attributes generically, prefers the marker's caption over the map, and **asserts** neither
`BWG-SHOT` nor `actions=` survives. An import agent cross-checked it against its own parser
and found a 1-byte difference (leading blank line) — trusted the tool, as instructed.

**Guide 19 "Brentwood blocks at a glance"** created (hub 238, client:brentwood, Building
pages) — an index of all 25 custom blocks by purpose, answering the two things people hunt
for: Media Grid (inserter-visible but migration-only) and heroes (not a block — Page
Settings). **Guide 14** rebuilt after rian approved removing Google auto-provisioning;
it now quotes the new user-facing message verbatim. Brentwood: **28 guides**.

**Verified rather than trusted:** grepped all 8 standard guides for Google/SSO before
assuming they were unaffected by the auth change. Two hits, both false positives — "sso"
inside "pro*cesso*r", and a "ranking in Google" reference about URL changes. Nothing needed
changing.

**Three "source of truth" traps, all of which bit someone today** — same mistake in different
clothes, now documented together in HANDOFF-NOTES: hub content vs drafts (cost me the
sign-in heading fix); `SHOTS.md`/`KEYWORDS.md` vs `_frag/` (they're generated — hand-edits
silently revert); marker parsers vs open-ended attributes. None failed loudly.

## 2026-07-24 (final) — Claude (third block primer + corrective re-import + capture warnings)

**Three standard block primers now live** (all audience=all, category "Building pages"):
193 "Where Your Blocks Come From" · 194 "Changing How a Block Looks" · **223 "Using
Patterns (Ready-Made Layouts)"**. Search battery across all three: **7/7**.

Guide 223 was written only after verifying the UI rather than from memory — the exact
wording **"Create pattern"** (block's three-dot menu) and **"Manage patterns"** (editor's
⋮ menu) were both confirmed by capture-and-read. It also warns about the **synced** option,
which updates a pattern everywhere it's used — the one genuinely surprising behaviour here.

**Corrective re-import done** (agent): all 14 suspect inspector shots replaced
(attachments 198–211), 10 guides rebuilt from current drafts, `{"created":0,"updated":10,
"trashed":0,"remote":26}`. The agent flagged two edits it couldn't attribute — hub 87's
entity fix and hub 63's heading change — both were mine; it correctly identified them as
external and left guide 63 alone.

**Two capture warnings added, both from the Brentwood session's field findings:**
1. **Sidebar-not-flush-right** — a docked sidebar always satisfies `x + width == viewport
   width`; anything else means the crop straddled the editor canvas. This is the exact
   signature of the silent wrong captures. Verified no false positives (healthy: x=1320,
   width=280, viewport=1600 → `warnings:null`).
2. **"No block selected"** — clicking an existing **server-rendered** block in the canvas
   does NOT select it (the click lands on inert output markup), so the inspector stays
   empty. Fix: **insert** the block on a new page instead — insertion auto-selects. This
   explains why inserter-driven shots work and click-an-existing-instance shots don't.
Both are deliberately narrow (verified signatures only), so a warning always means
something is genuinely wrong. Their image-diff calibration is documented too: a straddling
crop differs 11–20% or changes dimensions; two correct captures differ under 7%.

**Whole-library verification:** brentwooddev **27 guides / 60 images**, sandbox **10 / 9**,
**0 orphaned keyword rows**, **0 slug mismatches**, 9 categories, both endpoints healthy.

**Deferred, deliberately (paired fix, worth one release together):** search matching
normalises neither HTML entities nor apostrophes, so `width & height` missed
`width &amp;amp; height` (fixed by hand) and `cant get in` still misses `can't get in`.
The principled fix is to normalise both sides in `BW_Guides_Keywords` — kills both bugs and
future siblings. Not worth a release alone.

**Remaining gap:** the FIVE original standard guides (23, 24, 42, 59, 60) still have no
screenshots. All generic WP UI → capture from the `guide-shots` fixture, not a client site.
`--anonymous` now covers the login screen that guide 23 wants. Offered to the Brentwood
session; either side can take it.

## 2026-07-24 (later still) — Claude (block-primer standard guides + 59 Brentwood screenshots + capture hardening)

**Goal:** rian asked for top-down standard primers on adding/configuring blocks, including
where blocks come from (core vs theme/plugin vs custom-built). Ran alongside the Brentwood
session finishing its screenshots.

**Two new STANDARD guides published** (hub 193, 194; audience=all; category **"Building
pages"**, a new group — guide 59 "Working with Blocks" moved into it so all block content
groups together, and on Brentwood it sits beside their custom-block guides):
- **193 "Where Your Blocks Come From"** — the three sources, how to tell them apart (inserter
  category / naming / the block's own description), and safe experimentation on a draft.
  Carries the point that custom blocks usually *display* content managed elsewhere in the
  admin, so the fix for wrong wording is editing the source entry, not the block settings.
- **194 "Changing How a Block Looks"** — toolbar vs sidebar, the Page/Block tab confusion,
  Styles/Color/Typography, Dimensions/Border (with a warning that ad-hoc spacing is what
  makes pages look subtly wrong), why blocks differ, three ways to undo.
- 6 real screenshots captured from the CLEAN FIXTURE (correct for guides shipping to every
  site — generic WP UI, no client branding). Search battery 8/9; the 1 "fail" was a wrong
  expectation on my part (the answer returned was better than the one I predicted).

**59 Brentwood screenshots imported** (agent): 53 embedded across all 19 guides, 2 markers
correctly dropped (the deliberately-uncaptured sign-in pair), 6 orphan PNGs imported but
unused. Attachments 107–165. All 19 validated, synced (`updated:19, remote:24`), and
spot-checked through the client's real render path. Sandbox isolation re-confirmed.

**Two real bugs found by the import's own audits — both fixed:**
1. **Two-sources-of-truth regression.** Rebuilding guide content from the Brentwood drafts
   REVERTED my earlier hub-side rename of guide 63's "How do I sign in?" heading, resurrecting
   the duplicate-heading ambiguity with guide 90 and orphaning the keyword row that pointed at
   the renamed heading. **Lesson: the draft is the source of truth — a hub-only content edit is
   lost on the next rebuild.** Fixed in the DRAFT first, then pushed to the hub.
2. **HTML-entity drift in keyword matching.** Guide 87's keyword row said `width & height`
   while the content renders `width &amp;amp; height`; `wp_strip_all_tags` doesn't decode
   entities, so the row silently matched nothing. Re-keyed to the content's form, and
   re-audited ALL guides: **0 remaining orphaned keyword rows across 26 guides.** A permanent
   fix would decode entities on both sides of the comparison in `BW_Guides_Keywords` — worth
   doing in the next client release rather than releasing for it alone.

**Capture tool hardened** (tools/ only — no plugin release involved):
- `--anonymous` — skips the cookie mint so `/wp-login.php` captures the real login screen
  instead of redirecting to the Dashboard. Unblocked the Brentwood sign-in shot and is needed
  by every "how do I log in" section. Verified: `loggedIn:false`, `mode:mitm-anonymous`.
- `--reset-ui` — pins the editor's persisted UI state (sidebar open, welcome modal dismissed)
  before capturing. Gutenberg stores these in **user meta**, so one run that toggles the
  sidebar shut silently breaks every later sidebar capture for every caller on that project.
  Proven by poisoning the state and watching it self-heal.
- **`capturedElement` diagnostic** in the result JSON (box, position, opacity/visibility,
  `textPreview`) — because `selectorFound:true` is NOT proof of a correct capture. Immediately
  earned its keep: a test run reported success while `textPreview` revealed it had grabbed the
  **Page** tab, not the block inspector. Unambiguous failures (zero-size/hidden/transparent)
  now fall back with SELECTOR_MISSING instead of claiming success.
- `redact.sh` — solid boxes (default) or `--pixelate` for unavoidable private data in shots.
- Verified selectors + the Brentwood session's four field recipes documented; the
  README's self-contradictory "use --reset-ui AND click Settings" recipe corrected.

**Honest gap:** the Brentwood session reported the settings sidebar spontaneously closing a
few seconds after opening, which would explain their silent wrong captures. **I could not
reproduce it** — under my tests a closed sidebar fails cleanly. I deliberately did NOT ship a
guard for an unreproduced mechanism; `--reset-ui` is the prevention and the diagnostic makes
any recurrence visible whatever the cause.

**Left off at:** Brentwood is re-capturing ~14 inspector shots with `--reset-ui` into
`docs/guides-draft/shots-fixed/`; a corrective re-import of that subset is the next step.
**Notes:**
- Orphaned hub attachments to tidy at some point: 103–105 (superseded flagship shots),
  185/186 (uncropped inserter shots I replaced with crops 191/192), plus the 6 orphan
  Brentwood imports. Nothing deleted — hub media deletion is destructive and unreviewed.
- Guide 25 (sandbox site-notes) still carries pre-standard "contact" phrasing. Sandbox-only.

## 2026-07-24 (later) — Claude (capture interaction support: click/type/hover/iframe-aware regions)

**Goal:** Close out the "NEXT RELEASE — HIGH PRIORITY" item from HANDOFF-NOTES: the
capture tool (`tools/bw-guide-shot.sh`) loaded a screen and shot it with zero
interaction, so block settings, the classic-metabox drawer, and anything else revealed
by clicking were uncapturable.

**Done:**
- New `--actions "<script>"` flag (wrapper + `capture.js`): `;`-separated `verb:arg`
  steps — click/hover/focus/waitfor/scrollto/wait, plus `type:<selector>|<text>` and
  `key:<KeyName>`. Parsed and fully validated (unknown verb, missing `:`/`|`, >15 steps,
  out-of-range `wait:`) **before Chrome launches**. 15-step cap, 45s total budget.
- **Iframe-aware selector resolution** (`resolveInFrames()`): every selector-taking verb,
  and the pre-existing `--selector`/`--region` capture path, now try the main frame THEN
  every child frame — unblocks Gutenberg's iframed post-content canvas (`editor-canvas`),
  where block markup and block-level UI actually live.
- **Navigation tripwire**, deliberately NOT a `framenavigated`-URL diff: it watches for a
  genuine main-frame navigation *request* (CDP `requestId===loaderId`, type `Document`).
  Discovered during verification that a naive URL-diff tripwire would false-positive on
  Gutenberg's own benign `history.replaceState()` auto-draft URL rewrite on
  `post-new.php` (zero network requests, but still fires `framenavigated`) — the
  request-based signal is immune to that and still catches real submits/link-clicks.
- **Failure contract:** any step failure (not-found/timeout/budget/tripwire) writes a
  full-viewport `<out>.failed.png`, prints `FAILED_STEP <n> <verb> <detail>`, exits 2,
  never writes the normal `--out` file. Wrapper relays this distinctly from the existing
  `CAPTURE_ERROR`/exit-1 path and still runs its cleanup trap (cookie + token destroy).
- `selectors.json`: added `block-inspector` (alias of `settings-sidebar` — same
  `.interface-complementary-area` container, shows the Block tab once a block is
  selected) and `metaboxes-drawer` (`.edit-post-meta-boxes-main`, discovered live — see
  gotcha below).
- README `## Interactions` section: full DSL reference, iframe note, read-only rule
  (tripwire is a backstop, not permission), the `;`/`|` delimiter limitations, the
  failure/debug-PNG workflow, the caps, and the `BWG-SHOT` marker's `actions=` attribute
  (documentation only — publisher strips markers, no parser code added here).

**Real gotcha found (worth remembering — cost real debugging time):** on
brentwooddev's classic Meta Boxes drawer toggle button
(`.edit-post-meta-boxes-main__presenter button`), Puppeteer's synthetic `click()` (a CDP
mouse event at the computed clickable point) silently did nothing — no error, no state
change. Root cause: `elementsFromPoint` at that coordinate showed **two overlapping
`<button>` elements** (the toggle plus what's almost certainly the resizable-box's
keyboard-resize handle); the synthetic click's hit-test landed on the wrong one.
`ElementHandle.focus()` is a real DOM `element.focus()` call (no coordinate hit-testing)
— `focus:<selector>` then `key:Enter` reliably activates the correct button regardless
of the overlap. Documented as a general tip; no DSL change was needed (existing verbs
compose to fix it).

**Verified (real runs against `guide-shots` + `brentwooddev`, PNG evidence read, all
cleanup checked):**
- Regression (no actions, fixture, `--region settings-sidebar --trim`): byte-identical
  280×512 panel to the pre-existing baseline.
- Block settings (fixture, `click:` a paragraph block inside the canvas iframe →
  `waitfor:` the inspector → `--region block-inspector --trim`): panel correctly shows
  the Block tab (name "Paragraph", Styles/Color/Typography), not Page/Status.
- Metaboxes drawer (brentwooddev, `post-new.php?post_type=staff`, read-only —
  `focus:`+`key:Enter` to expand, `scrollto:`, `--selector "#acf-group_bwm_staff"
  --trim`): clean capture of the ACF "Staff Details" box (Title/Position, Credentials
  fields) — previously uncapturable.
- Negative tests: unknown verb rejected pre-launch (no Chrome process spawned, exit 1);
  a nonexistent selector → exit 2 + `.failed.png` written + no `--out` file; no leftover
  chrome/node processes or `/tmp/bw-guide-shot.*` files after either success or failure
  runs; wrapper's `trap cleanup EXIT` confirmed to fire on the new exit-2 path too.
- Smoke-tested `type:`/`hover:` on a safe unsubmitted list-table search box (fixture) —
  all 8 verbs now exercised through the real shipped tool at least once.

**Left off at:** Feature complete and verified. Not a plugin release (this is `tools/`
infra, not a `bw-*` plugin) — no version bump, no `release.sh` involved.

**Notes:**
- My own ad-hoc diagnostic scripts (session scratchpad, NOT the shipped tool) minted 4
  raw session tokens directly against brentwooddev's `support` user while
  reverse-engineering the metaboxes selector, and — unlike the shipped wrapper — had no
  cleanup trap. Caught it via the session-count invariant (2 documented baseline → 6
  while testing), destroyed exactly those 4 tokens by value (never `destroy_all`), back
  to 2. The shipped tool's own mint/destroy cycle was independently verified clean
  throughout (see negative tests above).
- `post-new.php?post_type=staff` was loaded ~6 times total (diagnostics + real
  verification) — each creates a normal WP auto-draft (standard, harmless, self-expiring
  core behavior; not something this tool causes beyond what any page visit already does).
  Not cleaned up; no sanctioned write path for it and not worth adding one.

## 2026-07-24 — Claude fable orchestrator (OVERNIGHT RUN — COMPLETE)

**Final state:** hub 0.2.0 · client 0.3.2 (staged, unreleased) · 25 published hub guides ·
brentwooddev = 24 guides at v0.3.2 · sandbox = 7 at v0.3.2 · both endpoints healthy ·
all three gates green · security-audit 0 new findings. Full morning summary in
HANDOFF-NOTES (top entry). P5 critique found 5 issues; 4 fixed tonight (category
priority, honest empty-state, hero verb keywords, sign-in heading duplication), 1
accepted (photo-gallery dual match). Six subagent runs, ~1.4M subagent tokens, zero
invariant violations, zero endpoint downtime, no credentials surfaced anywhere.

**Orchestration notes for future overnight runs:**
- Coordinating file ownership per agent (hub code / client code / tools / content)
  made 4-way parallelism collision-free; content imports were serialized behind hub
  code completion deliberately.
- The two batches' search batteries caught real issues each time (stopword/plural
  gaps; heading duplication across guides; verb-variant vocabulary) — the battery is
  cheap and should be standard for every publish.
- Cross-session handoff (Brentwood session drafting → this session publishing) via
  drafts-dir + ccd send_message worked cleanly; its 19/19 drafts all passed
  validation with zero forbidden phrases.

## 2026-07-24 — Claude fable orchestrator (OVERNIGHT RUN, mid-run checkpoint)

**Goal:** v1 platform overnight (rian's mission; see PLATFORM-ROADMAP status refresh).
Orchestrating Sonnet subagents; this entry checkpoints integration so a crash loses nothing.

**Done so far (all verified, in order):**
- Guide 3 (hub 42) rewritten to the writing standard (self-service, concise, H2s kept).
- write-guides skill live (user-global). Brentwood session notified + division of labor set.
- Hub 0.2.0 (agent, 22/22 checks): client groups (client_slug, "Specific client" targeting,
  manifest resolution), "Search keywords & category" metabox, payload meta ships
  category + section_keywords. Existing 4 guides byte-untouched during upgrade.
- Client 0.3.0 (agent): BW_Guides_Keywords, ranked search, category browse, quick links.
- Client 0.3.1 (orchestrator): search robustness — STOPWORDS filter for filler words
  ("forgot MY password") + trailing-s plural tolerance ("reorder blockS" vs "block").
  Found via a 15-query realistic battery: 13/15 → 15/15 after fix. Gates green.
- Capture CLI (agent): tools/bw-guide-shot.sh — MITM-fulfill via CDP, zero gate changes,
  zero outbound network (aborts non-target requests; DNS mapped to TEST-NET-1 fail-closed).
  Proven on fixture AND brentwooddev (real data, authenticated, styled). security-audit:
  0 new findings. Ungate mode never built — not needed.
- 4 standard guides (writer agent → orchestrator import): 23/24 reworked, 59 "Working with
  Blocks" + 60 "Images and Media" created; categories (Getting started / Editing content /
  Media) + section keywords authored on all 5 standard guides (42 included).
- Brentwooddev DEPLOYED: hub site id 7 client=brentwood; plugin 0.3.1 rsync'd (docs/ +
  CLAUDE.md excluded from client copy), activated; key transferred file-only (never
  printed, deleted both sides); first sync {"created":5,remote:5} — guide 25 correctly
  absent (site-1 targeting). Hub bookkeeping tracks both sites at v0.3.1.
- Adversarial invariant re-verified vs new hub: bad key → bw_guides_invalid_key,
  zero_local_changes=1, clean restore. Sandbox re-sync stable at remote=6.
- Browser-verified sandbox: grouped browse (search box, quick links) + keyword search
  ("forgot my password" → "How do I log in?" with match hint).

**In flight:** publisher agent importing the Brentwood session's 12 drafted guides
(client:brentwood, categories mapped, keywords from _frag files, BWG-SHOT markers
stripped at import), then sync+isolation+search verification.

**Banked findings for the P5 improvement round:**
- Search results page shows "No guides found." under a successful "Jump straight to"
  list when full-text misses but sections hit — confusing empty-state; fix wording/logic.
- Per-client suppression of a standard guide (e.g. a client-specific getting-started
  superseding the generic) has no mechanism — roadmap candidate, not tonight.

## 2026-07-24 — Claude (client 0.3.0: ranked search + category browse)

**Goal:** Build client 0.3.0 — navigation & findability — ahead of the standard library
growing to dozens of guides: rank section search using a guide's hidden keywords (the
payload contract hub 0.2.0 is shipping in parallel), and group the browse grid by category.

**Done:**
- New class `BW_Guides_Keywords` reads `category` / `section_keywords` from the payload
  `meta` object (`_bw_guides_remote_meta`, hub guides) or `_bw_guides_local_category` /
  `_bw_guides_local_keywords` (local guides, no authoring UI yet) — both optional,
  tolerated absent/malformed. Heading matching is case-insensitive/trimmed exact text,
  never slug, so this plugin stays decoupled from slug generation on either side.
- `BW_Guides_Content::search_sections()` now ranks instead of returning the first 8
  found: phrase-in-heading 5, all-words-in-heading 3, phrase-in-section-keywords 4,
  all-words-in-section-keywords 2, guide-level keyword match adds +1 to every section of
  that guide. `via` (heading|keyword) drives a subtle "(matches: <term>)" hint on the
  guides list — no scores shown. Tiebreak is explicit encounter order (`usort()` isn't
  guaranteed stable until PHP 8; this plugin supports 7.4).
- `BW_Guides_Keywords::group_guides()` + guides-list.php: the browse grid now groups
  cards by category (alphabetical), "More guides" for uncategorized hub guides, "Your
  guides" (always last, regardless of category) for local guides. Page size raised
  20→60 so a category isn't routinely split across pages (documented tradeoff, not
  solved further). Cards gained up to 3 quick links to their first h2s. Search box made
  visually prominent with placeholder "What do you need help with?".
- Bumped 0.3.0; CHANGELOG + this plugin's CLAUDE.md (keyword/category contract + ranking
  rules) updated; all three gates (cleanup/security/test) pass.

**Verified on the sandbox** (fixture meta fabricated on real hub guide 175 since hub
0.2.0 isn't shipping real payloads yet — read-modify-write, existing
`min_wp_version`/`requires_plugins`/`tokens` keys preserved):
- "permalink" and "rename page" → top hit is the web-address section of 175, score 4,
  via=keyword. "url" → same section, score 9, via=heading (a visible match still wins
  even though the keyword also matched). "page options" (a guide-level keyword) →
  surfaces all 8 sections of 175 tied at score 1, via=keyword, nothing else scores —
  exactly the "boost with no specific section identified" case.
- Slug-consistency invariant (`prepare()` vs `headings()`) re-checked across all 5
  published guides after these changes: ALL MATCH.
- Browse grouping exercised via the same `WP_Query` + `group_guides()` guides-list.php
  uses: "Content basics" (175, the fixture category), "More guides" (171/172/173 —
  hub-sourced, no category), "Your guides" (174 — local) — correct on all three axes
  (category grouping, uncategorized bucket, local-always-last).

**Left off at:** Release-staged at 0.3.0, not published (no "release it" this session).
**Fixture meta left in place on guide 175** — `_bw_guides_remote_meta` now carries
`category`/`section_keywords` alongside its real `min_wp_version`/`requires_plugins`/
`tokens` keys. Harmless (a sync only re-fetches a guide when the hub's
`content_hash`/`modified_gmt` differ, so this will be naturally overwritten once the hub
actually ships guide 42's real category/keywords) but flagging it so nobody mistakes it
for real hub-authored data if the sandbox is browsed before then.

**Notes:**
- The full ranking/contract detail now lives in this plugin's CLAUDE.md (new bullet
  under "Plugin-specific notes") — read that before changing scoring or the meta shape.
- Heading text in this guide's fixture contains an apostrophe ("a page's web address"),
  which breaks the usual single-line `wp eval` bash single-quoting. Used the
  `eval-file` staging workaround for all four verification scripts this session: a temp
  file under `wp-content/.tmp-*.php` (chmod 644), `wp eval-file
  /var/www/html/wp-content/.tmp-*.php`, then removed immediately after each use.

---

## 2026-07-24 — Claude (platform vision + screenshot pipeline PROVEN)

**Goal:** Rian expanded the vision: an authoring capability other project sessions can
invoke ("write guides for all the custom features we built"), publishing in a standard
way with screenshots, targeted at client sites **by domain/client**, so docs land on
`dev.brentwood.ca` now and both dev+prod after launch.

**Decisions locked with rian** (full write-up in the new `docs/PLATFORM-ROADMAP.md`):
- **Targeting = client groups.** Sites carry a client slug; guides target the client;
  registering prod at launch auto-delivers the backlog. No per-guide re-targeting.
- **Authoring = CLI + skill**, not a network API (keeps it owner-gated via the gateway,
  no public write surface). MCP only as a later wrapper if ever needed outside Claude Code.

**Screenshot pipeline — PROVEN end-to-end today.** This was the big de-risk.
- Created a clean fixture site **`guide-shots`** (blank WP via `--skip-template`, port
  3137, id-auth gated). Admin password generated at creation and **deliberately not stored** —
  capture uses on-demand cookies instead.
- Solved headless auth the secure way: **google-chrome is already installed** on the
  server (no browser download); `puppeteer-core` drives it. Auth = a WP session cookie
  **minted on demand via `srv-gw wp -- eval`** (no stored password, 1h expiry, wiped after).
- **The asset-loading gotcha** (documented so nobody re-loses hours to it): capturing via
  direct container access (host-only) fails to load CSS/JS, because WP emits **https**
  asset URLs and the container speaks plain http. Capturing over the **real HTTPS origin
  through Caddy** is what makes assets render. Over https WP needs the **secure** cookie
  `wordpress_sec_<md5(siteurl)>` + `wordpress_logged_in_`, both from the same fresh session
  token — not the plain `auth` cookie.
- For the fixture, capture **temp-ungates** (id-gate public → capture → re-gate), safe for a
  no-data throwaway; verified re-gated after. **Client sites can't use temp-ungate** (real
  data) — the general solution is an id-auth *capture identity*, noted in the roadmap.
- Result: a clean, styled, **selector-cropped** (`.interface-complementary-area`) WP 7
  page-settings panel — none of `template.demoing.info`'s BW Dev pollution. Trimmed with
  ImageMagick to 280×512. Sent to rian.

**Left off at:** Pipeline concept fully proven; nothing of it built as a reusable CLI yet.
Proof code in session scratchpad (`capture/capture.js`). Roadmap + build order in
`docs/PLATFORM-ROADMAP.md`.
**Notes:**
- Credential hygiene: all minted-cookie temp files wiped; support sessions destroyed;
  fixture re-gated. Nothing sensitive left in scratchpad.
- The `guide-shots` fixture creation was interrupted earlier by an unrelated gateway
  restart + a heavy `brentwooddev` hero-regen holding the (single-threaded) gateway;
  waited it out, then created cleanly. No partial state remained.

## 2026-07-24 — Claude (Guide 3 "Page and Post Settings", hub id 42)

**Goal:** Write the first of the five core guides as a test of the structure, with
screenshots if the tooling allowed.

**Reconnaissance first — and it changed the guide.** Checked every screen on
template.demoing.info (WP **7.0.2**) rather than writing from memory. Three findings that
would have made the guide wrong:
1. The field is labelled **"Slug"** — not "URL" or "Permalink". The WP 7 sidebar is a flat
   row list (Status · Publish · Slug · Author · Template · Parent), not the old collapsible
   "Summary" panel.
2. **Excerpt does not exist on Pages** — only Posts, via an "Add an excerpt…" link near the
   top. My planned heading list had assumed it was on both.
3. **Parent is Pages-only; Categories/Tags are Posts-only.** The guide now carries a table
   of the differences, which is the kind of thing clients actually trip over.
Also: `Status` → **"Status & visibility"** popover holds Draft/Pending/Private/Scheduled/
Published plus a Password protected checkbox.

**Written and live:** hub id **42**, audience `all`, synced to sandbox as local 175.
8 headings, all core blocks, design-kit callouts and steps throughout.

**New QA step worth repeating for every guide — search-test the headings.** After writing,
run the client questions through `search_sections()`. First pass hit 9 of 11; two misses,
both fixed by rewording the heading rather than by code:
- "schedule" missed "…or scheduling for later" (not a substring) → renamed to
  "…or schedule a page for later".
- "excerpt" matched nothing → heading renamed to "Settings that only apply to posts
  (excerpt, categories, tags)".
All 13 queries now resolve, including "page url", "web address", "slug", "password",
"featured image", "private", "template", "parent", "categories", "tags".

**Screenshots — blocked, not done.** Three transports all failed: `zoom` produces a good
tight crop but returns **no image id**; `upload_image` rejects captured screenshots
("Unable to access message history to retrieve image"); `save_to_disk: true` leaves no file
anywhere reachable from the server. So Claude can *see* but cannot *transport* pixels into
WordPress. **Workaround for next session:** rian takes plain full-window screenshots and
uploads them to the hub media library; Claude then locates them under
`/srv/apps/bw-plugins-dist/wp-content/uploads/`, crops precisely with ImageMagick (present
at `/usr/bin/convert`), re-imports the crop and places it in the guide. Cropping matters —
template.demoing.info is **not** a clean install (BW Dev "Page Title Override" / "Scheduled
Action" / "Admin Note" panels sit right below the core rows and must not appear).

**Left off at:** Guide 3 complete except images. Guides 1, 2, 4, 5 not written.
**Notes:**
- Nothing was created on template.demoing.info — existing Sample Page / Hello world! were
  opened read-only and all three posts still carry their original 2025-11-29 timestamps.
  No cleanup owed there.
- Hub images hotlink from plugins.bowden.works to client sites (known roadmap item:
  sideloading). Worth resolving before screenshots go out to many sites.

## 2026-07-24 — Claude (guide structure: anchors, contents, section search — 0.2.0)

**Goal:** Rian is planning the real starting library and asked how to structure it, given
that clients mostly arrive with small specific questions ("how do I change the page url")
that don't deserve a guide each but must be answerable.

**The structural decision:** *one guide per area of the admin, one task per heading.*
Guides map to where clients are; questions map to headings. That only works if search
returns **sections rather than guides**, so headings had to become addressable.

**Done — `includes/class-bw-guides-content.php` (new):**
- `prepare()` renders a guide (still `do_blocks()`, never `the_content`) and gives every
  h2/h3 a stable `bw-`-prefixed anchor. Anchors are injected **after** `wp_kses_post` so
  they're guaranteed to survive; they're safe by construction (`sanitize_title`).
  Verified separately that kses does allow `id` on h2/h3 — that assumption is load-bearing.
- `headings()` reads headings straight from raw block markup (headings are static blocks,
  so no render needed — cheap enough to run across every guide on each list view).
- `search_sections()` matches the whole phrase OR every word of it against heading text,
  so "page url" would find "Changing a page's web address (URL)".
- `contents_list()` builds the "In this guide" list, suppressed below 3 h2s.

**The one real hazard, and its check:** `prepare()` parses rendered HTML while
`headings()` parses raw markup. **If their slugs ever diverge, every search link points at
an anchor that doesn't exist.** Both use the same slugify + dedupe in document order.
Verified equal across all four sandbox guides — re-run this after touching either method:
```
foreach(array(171,172,173,174) as $id){ $p=get_post($id);
  $a=BW_Guides_Content::prepare($p->post_content)["headings"];
  $b=BW_Guides_Content::headings($p->post_content);
  echo $id." ".(wp_json_encode(array_column($a,"slug"))===wp_json_encode(array_column($b,"slug"))?"MATCH":"MISMATCH")."\n"; }
```

**Verified in the browser, full loop:** search "questions" → "JUMP STRAIGHT TO / Common
questions — How to Edit a Page" → click → lands on `#bw-common-questions` with the
heading clear of the admin bar (`scroll-margin-top: 48px`). Contents list renders as a
styled box. All three gates pass at 0.2.0.

**Minor not patch:** section-level search + deep linking is a new capability surface a
user notices, not iteration on the design kit. Easy to re-cut as 0.1.3 if rian disagrees.

**Left off at:** Structure layer done. Guide *writing* not started — see the agreed plan
in HANDOFF-NOTES.
**Notes:**
- **CSS edits need a version bump to show up.** The stylesheet is enqueued with
  `BW_GUIDES_VERSION` as the cache-buster, so editing CSS *after* a bump serves stale CSS
  and the change looks broken in the browser. Bump, then look.
- Nothing here touched the hub: no protocol change, no payload change, `content_hash`
  untouched. A client on 0.2.0 gains all of it against an unchanged hub.

## 2026-07-24 — Claude (hub content rewrite + design kit)

**Goal:** Replace the test-polluted content in hub guides 23/24/25 with real client-facing
copy, do the pending browser visual pass, and — after rian asked why the hub and client
showed different things — make it possible to design good-looking guides that render
identically on client sites.
**Done:** Rewrote all three hub guides. Guide 23 "Getting Started with Your WordPress
Admin" (logging in, the left menu, the admin bar, three habits), 24 "How to Edit a Page"
(find → edit → preview → publish, plus revisions), 25 "Your Site-Specific Setup Notes"
(what this site is, how it's built, the BW plugins on it, how tags/notes/local guides
work, getting help). Plain language aimed at non-technical site owners; excerpts were
already good and were left alone.

**Found and fixed a real invariant violation:** guide 25 was not merely placeholder text —
it contained a full **Kadence** pattern (`kadence/rowlayout`, `kadence/column`,
`kadence/advancedheading` × 6 "Add a Title" / "medium length description" blocks) plus a
hotlinked test image (`uploads/2026/07/1534370968828.jpg`). That breaks the CORE BLOCKS
ONLY rule and would have rendered degraded on every client site. All Kadence markup and
the image are gone. Verified via `parse_blocks()`: all three guides are now
`core/paragraph`, `core/heading`, `core/list`, `core/list-item`, `core/separator` only,
with zero stray HTML and balanced delimiters.

Synced to the sandbox and confirmed E2E: `{"created":0,"updated":3,"trashed":0,"remote":3}`
— in-place updates, no duplicates, no trashing. Guide 171 kept its tags
(`basics`, `getting-started`) and its 57-char private note through the update, and local
guide 174 was untouched — invariants 2 and 3 demonstrated on real content.

**Lost update — worth understanding, it will happen again.** Rian had guide 25 open in a
hub Gutenberg tab from *before* the rewrite. That tab saved at 01:12:38 and wrote its
stale copy back, restoring all the Kadence markup; the client meanwhile still showed the
good version it had synced at 00:42. The two screens weren't disagreeing about sync — they
were snapshots either side of an overwrite. Recovered with
`wp_restore_post_revision(36)`; the Kadence version survives as revision 37. **Any wp-cli
content edit while an editor tab is open on the same post is at risk of this** — tell rian
to reload before touching the tab.

**Design kit (the answer to "how do I design nice guides?").** Established that the
constraint was never "plain content", it was specifically Kadence:

- Verified core blocks survive the client pipeline `wp_kses_post( do_blocks() )` with
  styles intact — Group w/ background + padding + radius, Columns, Buttons, Details,
  Table, Media & Text, preset classes. Only whitespace normalisation differs.
- Verified the real gap: `wp_enqueue_global_styles` is hooked to `wp_enqueue_scripts`
  only, **never** `admin_enqueue_scripts`. Since guides render in wp-admin, theme.json is
  absent — no block-gap, no `--wp--preset--*`, no `.wp-element-button`. So a Buttons block
  rendered as a bare link and Columns had no gutter.
- Client 0.1.2 now styles the core blocks used in guides, plus the kit components
  (`bw-callout` + warning/success/danger, `bw-callout-label`, `bw-steps`), all scoped to
  `.bw-guides-content`.
- Hub 0.1.1 registers 7 patterns for `bw_guide` only (Note, Important, Numbered steps,
  Do/Don't columns, FAQ accordion, Reference table, Button row) plus an editor stylesheet
  mirroring the client's components so the editor previews accurately.
- **Components are class-based, not inline-styled.** Hand-serialized `style` attributes
  risk Gutenberg's "unexpected or invalid content" warning, and classes mean the house
  style can be restyled in the client plugin without editing a single published guide.
- Proved it end-to-end: rewrote guide 24 using the kit, synced, and confirmed the rendered
  client output retains `bw-callout` ×8, `bw-callout-label` ×3, warning/success variants,
  `bw-steps`, and 3 `<details>` — with zero leftover block comments.

**The insight worth keeping:** guides render in wp-admin, *not* through the theme, so the
rendering environment is identical on every client site. Design once → looks the same
everywhere. That is what makes a shared design kit viable at all.

**Block allowlist (hub, added after rian asked "what if I use a Kadence block?").**
Tested every candidate block through the client pipeline. Findings:

- Kadence blocks don't error — they **degrade**: the saved HTML passes through (kses
  strips `data-kb-block`), but the per-`uniqueID` CSS is generated by the plugin and the
  colours come from the Kadence *theme*, so a 3-column row becomes three stacked unstyled
  divs. Dynamic third-party blocks would render as nothing at all.
- Three **core** blocks are broken here too, because the client uses `do_blocks()` and
  never the `the_content` chain: `core/embed` renders as a bare URL (oEmbed filters never
  run), `core/shortcode` prints literal `[shortcode]` text, and `core/html` has its
  iframes/scripts stripped by kses (96→36 bytes in the test).
- Verified safe and now allowed: paragraph, heading, list, list-item, quote, pullquote,
  code, preformatted, details, table, image, gallery, cover, media-text, video, audio,
  file, group, columns, column, buttons, button, separator, spacer (24 total).

So the hub now enforces an **allowlist** via `allowed_block_types_all`, scoped to
`bw_guide` only (verified: pages on the dist site remain unrestricted). Note it governs
*insertion* — pasted markup and restored revisions still bypass it.

**Browser visual pass — DONE, both halves.** Verified in Chrome against the live sandbox
and hub:

- Client list: source badges, tags, excerpts, search + tag + source filters all correct.
- Client single guide (172): every design-kit component renders — blue Note callout with
  accent bar, blue numbered step circles, amber Important, green Remember, inline `code`
  styling. The `<details>` accordions open and show the `[open] summary` divider.
- Tags and Notes both save via admin-ajax ("Saved." feedback), and **survived a
  hub-driven content re-fetch** — invariant 3 proven with real UI-created data, not
  fixtures.
- Hub block inserter shows **only the 24 allowlisted blocks — no Kadence at all**, on a
  site where Kadence would otherwise contribute dozens.
- **All 7 patterns insert cleanly with zero "unexpected or invalid content" warnings**,
  and render styled in the editor via the mirrored editor.css. This was the one residual
  risk in the class-based approach; it is now cleared. Test draft's parsed blocks: 11
  distinct core types, 0 non-core.

**Left off at:** Client bumped to **0.1.2**, hub to **0.1.1**; all three gates pass at
0.1.2. Still unpublished — awaiting rian's explicit "release it".
**Notes:**
- **wp-cli via `srv-gw wp` splits arguments on newlines.** `--post_content="$(cat f)"`
  fails with "Unable to read content from '<p>…'" because each line after the first is
  taken as a positional file arg. Two workarounds: keep `wp eval` on a single line, or
  stage a file under `/srv/apps/<project>/wp-content/` (host) and pass the container path
  `/var/www/html/wp-content/<file>` to `wp post update`. Used the latter; staged files
  were removed immediately after import.
- Attachment 29 (`1534370968828.jpg`) is now unreferenced in the dist media library. Left
  in place deliberately — repo is not under git and it's harmless. Delete only if rian says so.
- **Chrome MCP gotcha (cost a lot of back-and-forth — read this before driving a browser).**
  The MCP tab kept hitting `wp-login.php?...&reauth=1` on both sites even though rian was
  logged in. It was NOT a separate profile: the long-lived MCP tab was holding a **stale
  WordPress cookie** (`reauth=1` means a cookie is present but invalid). Closing the tab
  group and creating a **fresh tab** (`tabs_context_mcp createIfEmpty:true`) picked up the
  live session immediately. **If the MCP tab looks logged out, open a new tab before
  concluding anything about profiles or asking the user to log in again.** Also: the tab
  group renders as a small collapsed chip at the far left of the tab strip, which users
  don't recognise — and the window defaults narrow enough that the Gutenberg inserter
  renders off-screen (`resize_window` to ~1600×1000 fixes it).
- **The hub's `bw_guide` CPT does not support trash** — `wp post delete` refuses without
  `--force`, so deleting a guide on the hub is **permanent and unrecoverable**. The client
  side deliberately trashes rather than deletes; the hub has no such safety net. Worth
  considering `'supports' => …` / trash support on the hub before rian deletes a real guide.
  A leftover empty test draft (ID 40) is parked on the hub pending rian's OK to remove.

## 2026-07-24 — Claude (check-on-view syncing, 0.1.1)

**Goal:** Rian: don't waste power on scheduled re-syncs (updates are rare), but make
updates visible right away when they exist.
**Done:** Opening any Guides screen now fires a background admin-ajax update check
(`bw_guides_check_updates`) ~0.8s after load, throttled server-side to once per 10 min
(filter `bw_guides_check_interval`, throttle keys off `last_sync_at` so a down hub
isn't hammered). On change: immediate sync + "refresh to see the latest" banner; the
single view also detects the open guide being updated (post_modified_gmt compare) or
withdrawn (trashed). Cron reduced twicedaily → daily backstop (also keeps hub
last-sync/site-liveness bookkeeping alive). Bumped 0.1.1; all three gates pass;
throttle + change-detection verified on sandbox against a live hub edit.
**Left off at:** Release-staged at 0.1.1 (0.1.0 was never published). Browser visual
pass still pending.
**Notes:** The cheapness argument: manifest is ~1KB; content only downloads on hash
change — so check-on-view costs nearly nothing on quiet sites.

## 2026-07-23 — Claude (full MVP build)

**Goal:** Build the complete BW Guides MVP: central publishing hub + client plugin.
**Done:**
- Hub plugin `bw-guides-server` built and activated on the dist host
  (`/srv/apps/bw-plugins-dist/wp-content/plugins/bw-guides-server/`): `bw_guide` CPT
  (Gutenberg authoring), targeting metabox (all/selected sites), sites registry table
  with sha256-hashed per-site keys (display-once, regenerate/revoke), REST
  `bw-guides/v1` (health / manifest / guides), Sites admin screen.
- Client plugin fully implemented: CPT + tag taxonomy + map_meta_cap read-only lock,
  sync engine (cron twicedaily + Sync Now, manifest diff, batched fetch, kses upsert,
  untrash-on-retarget, trash reconciliation, failure safety), wp-admin reading UI
  (list/search/tag filter/source filter + single view via do_blocks), tags + notes
  admin-ajax, settings screen (masked key, HTTPS-enforced hub URL, test connection),
  uninstall (keeps client-created guides).
- E2E verified on sandbox against the live hub: targeting (decoy invisible), content
  update propagation with tag+note survival, untarget→trash→retarget→untrash (no dup),
  local guide survival, wrong-key zero-change failure, revoke→403→reactivate cycle,
  hub last-sync bookkeeping. cleanup-scan / security-scan / test-plugin all pass.
- 2 standard guides + 1 sandbox-targeted guide authored on the hub (IDs 23, 24, 25).
**Left off at:** Release-staged at 0.1.0. NOT released (awaiting rian's "release it").
Visual pass of the reading UI in a browser still pending.
**Notes:** Sandbox is registered as hub site id 1; its key lives only in the sandbox's
`bw_guides_settings` option (never in chat/transcripts). Hub guides must be authored
with CORE BLOCKS ONLY (dist site has Kadence, clients don't). Deferred features
(tokens, wp-version notice, plugin-conditional visibility) ride in the payload `meta`
object — see docs/SPEC.md.

## 2026-07-23 — scaffold

**Goal:** Create initial plugin scaffold
**Done:** Scaffolded via tools/new-plugin.sh at version 0.1.0
**Left off at:** Ready to begin development
**Notes:** Standard BW plugin structure. Docs stubs in place, awaiting real content.
