---
name: write-guides
description: Write and publish BW Guides documentation for a site or feature — the house standard for client-facing WordPress guides. Use when asked to write guides, document features for clients, or publish docs to the BW Guides hub.
---

# Writing BW Guides — the house standard

BW Guides delivers wp-admin documentation to client sites from a central hub
(`bw-guides-server` on plugins.bowden.works, content authored as `bw_guide` posts).
Clients read guides inside wp-admin; search returns *specific sections*, not just
guides. Your job: write guides that a non-technical site owner can act on alone,
and that search can find even when the user's words don't match yours.

**Orient first, every time:**
1. `/srv/apps/bw-plugins/wp-content/plugins/bw-guides/docs/PLATFORM-ROADMAP.md`
2. The feature you're documenting, IN THE LIVE ADMIN — never from memory.
   WordPress 7 moved things; field labels differ from what you assume.

## The writing standard (non-negotiable)

- **Self-service, always.** Never "contact Bowden Works", "ask us", "call us",
  "get in touch". The reader can do this themselves — that's the product. Safety
  nets are WordPress's own: revisions, previews, drafts, undo.
- **Concise.** No throat-clearing, no padding. Short sentences. Cut every
  sentence that doesn't help someone DO the thing.
- **One guide per feature-area. One task per H2.** Phrase every H2 as the
  question a client would ask, in THEIR vocabulary: "How do I add a staff
  member?", "Changing the order staff appear in" — not "Staff CPT management".
  Search matches heading text, so vocabulary IS the index.
- **Reads top-to-bottom as a guide, and each H2 stands alone** for someone who
  jumped straight to it. Don't say "as above" — one-line recap if needed.
- **Day-to-day coverage, not exhaustive.** The things people do weekly and
  forget. A guide that intimidates doesn't get read.
- **Verify every instruction against the live screen** (field labels, button
  names, panel locations) before writing it.

## Format rules

- **CORE BLOCKS ONLY** — client sites don't have Kadence. The hub editor
  enforces a 24-block allowlist; pasted markup bypasses it, so don't paste
  foreign blocks. Allowed: paragraph, heading, list(+item), quote, pullquote,
  code, preformatted, details, table, image, gallery, cover, media-text, video,
  audio, file, group, columns(+column), buttons(+button), separator, spacer.
- **House components** via `className` on core blocks (styled by the client
  plugin; also previewed in the hub editor):
  - Callout: `<!-- wp:group {"className":"bw-callout"} -->` with first inner
    paragraph `{"className":"bw-callout-label"}` as the label. Variants:
    `bw-callout-warning` (amber), `bw-callout-success` (green),
    `bw-callout-danger` (red).
  - Numbered steps: `<!-- wp:list {"ordered":true,"className":"bw-steps"} -->`
  - The hub's pattern inserter has a **BW Guides** category with all of these.
- **No theme.json preset colour classes** (`has-*-background-color`) — they
  render colourless in wp-admin.
- Never hardcode a specific site's URLs in content that targets multiple sites.
- Screenshots: reference hub-hosted images with a core image block, or leave a
  marker for later capture:
  `<!-- BWG-SHOT id="x" path="/wp-admin/..." region="settings-sidebar" caption="..." -->`
  Capture tool: `/srv/apps/bw-plugins/tools/bw-guide-shot.sh` (see its README).
- **Privacy in screenshots — the escalation ladder** (checking captures for
  private data is required; capture-and-discard loops are not the goal):
  1. **Shoot a clean source first.** Generic WP screens → the `guide-shots`
     fixture. Client-specific features → create a sample/draft record on that
     site (e.g. a draft staff member "Sample Person"), shoot it, then remove it.
     Empty add-new forms are always safe.
  2. **If private data is incidental and unavoidable** (a required list view, a
     dashboard corner), capture, then **redact before import**:
     `tools/bw-guide-shot/redact.sh <img> <WxH+X+Y> [...]` — solid boxes by
     default (light blur on text can be recoverable; boxes also read as
     deliberate). `--pixelate` for faces/photos.
  3. **Inspect locally BEFORE importing to the hub — always.** The hub media
     library is publicly served and may be CDN-cached; deleting after upload is
     not a reliable undo. Local capture → look → (redact) → import, in that
     order. Form submissions, emails, phone numbers, and non-public names count
     as private even though logged-in users could see them.
- **Interactions before capture** (for UI revealed by clicking — block settings,
  the Meta Boxes drawer, dropdowns): add `actions="..."` to the marker / the
  `--actions` flag. DSL: `;`-separated `verb:arg` steps — `click:<sel>`,
  `hover:<sel>`, `focus:<sel>`, `type:<sel>|<text>`, `key:<KeyName>`,
  `waitfor:<sel>`, `scrollto:<sel>`, `wait:<ms>`. Selectors resolve into the
  Gutenberg canvas iframe automatically. Example — capture a block's settings:
  `actions="click:.block-editor-block-list__block[data-type=&quot;core/paragraph&quot;]; waitfor:.interface-complementary-area" region="block-inspector"`.
  **Interaction scripts must be READ-ONLY**: open panels and select things; never
  click Publish/Update/Save/Delete (a navigation tripwire aborts the shot as a
  backstop). If a click silently does nothing (overlapping buttons), use
  `focus:<sel>` + `key:Enter` instead. Failed steps write `<out>.failed.png` for
  iterating. Full reference: the tool README's "Interactions" section.
- **Two flags that prevent most capture frustration:**
  - `--reset-ui` — pins the editor's persisted UI state (sidebar open, welcome
    modal dismissed) before capturing. Gutenberg stores these in USER META, so
    one earlier run that toggled the sidebar shut breaks every later
    sidebar/inspector capture for everyone. **Use it for any sidebar shot**, and
    try it first when a region "isn't found".
  - `--anonymous` — captures logged OUT, so `/wp-login.php` shows the real login
    screen instead of redirecting to the Dashboard. Needed for sign-in docs.
  - Blocks that fetch content (listings, galleries) need `wait:4000`; shorter
    waits fail intermittently and look like selector bugs.
- **Before importing, run the consistency check:**
  `tools/bw-guide-shot/check-shots.py <project>/docs/guides-draft` — it catches shots that
  were declared and captured but never given an inline marker (so they're imported and
  never shown), duplicate markers, and markers with no image. Six images once shipped
  invisible this way across four guides. Exit 0 = consistent.
- **Verify element captures — `selectorFound:true` is not proof.** A resolved
  element can have a real bounding box yet contain the wrong thing. The result
  JSON's `capturedElement.textPreview` tells you what was actually captured: a
  block inspector reads "Page Block … Styles … Typography". If it shows your
  page's content instead, re-run with `--reset-ui`. Never import a screenshot
  you haven't looked at.

## Hidden search keywords (do this for every guide)

For each H2, list the words a user might search that are NOT in the heading
("reorder, sort, move, position" for an ordering section). Guide-level keywords
use heading `*`. These are attached as hub post meta and shipped to clients
invisibly — search matches them without displaying them.

- In the hub editor: the **"Search keywords & category"** metabox — one line per
  rule: `Heading text :: kw, kw, kw`. Also set the guide's **category** there
  (groups the client's browse page; reuse existing category names).
- Via CLI: set meta `_bw_guides_category` (string) and
  `_bw_guides_section_keywords` (array of `{heading, keywords}`), then ALWAYS
  `wp_update_post(['ID'=>$id])` — CLI meta edits don't bump `post_modified_gmt`,
  and clients only re-pull when it changes.

## The hub is the SINGLE source of truth for guide content

Edit every guide — standard or client-specific — on the hub:
<https://plugins.bowden.works/wp-admin/edit.php?post_type=bw_guide>. Guides are
read-only on client sites by design (`map_meta_cap`), so the hub is the only
place text changes.

**Never rebuild hub content from draft files.** Some projects kept draft `.html`
files while authoring a guide set in bulk. Those are retained only as a
*screenshot capture manifest* (`BWG-SHOT` markers and `_frag/*.shots.md` recipes)
and their guide bodies go stale the moment anyone edits on the hub — which is the
natural thing to do. This has already caused one near-miss: 13 of 20 guides had
drifted with the hub ahead, and a rebuild would have destroyed whole sections and
corrected instructions. If a project's drafts carry a `DO-NOT-REBUILD` marker or a
README saying so, believe it.

To re-shoot a screenshot: read the recipe from the marker, capture, import to the
hub media library, and place the image **directly in the hub guide's content**.

**Never edit a synced guide on a CLIENT site either** — and understand why this
one is especially treacherous (verified empirically, not assumed):

- The UI blocks it. `map_meta_cap` makes hub-sourced guides read-only, so a
  client admin genuinely cannot edit one. Good.
- But **wp-cli, direct DB access, or an AI session bypasses that cap** — and the
  edit then *appears to succeed*. Sync compares the STORED
  `_bw_guides_content_hash` meta against the hub's manifest, not the actual local
  content, so a local edit **survives every sync indefinitely**.
- It is then **silently destroyed the moment the hub guide next changes** — which
  could be weeks later, long after everyone concluded the edit worked.

That delayed, unpredictable loss is worse than an immediate overwrite. Client
sites are a read-only mirror; the only durable place for a text change is the hub.

## Publishing mechanics (hub = project `bw-plugins-dist`)

`srv-gw wp` **splits arguments on newlines** — multi-line content cannot be
passed inline. Stage a file:

```bash
cp guide.html /srv/apps/bw-plugins-dist/wp-content/.bwg-import.html
chmod 644 /srv/apps/bw-plugins-dist/wp-content/.bwg-import.html
srv-gw wp --project bw-plugins-dist -- post create /var/www/html/wp-content/.bwg-import.html \
  --post_type=bw_guide --post_status=publish --post_title="..." --post_excerpt="..." --porcelain
rm /srv/apps/bw-plugins-dist/wp-content/.bwg-import.html
```

Targeting (post meta on the guide): `_bw_guides_audience` = `all` | `selected`
(+ `_bw_guides_target_sites` array of site ids) | `client`
(+ `_bw_guides_target_client` slug, e.g. `brentwood`). Client-group targeting
means a site registered under that client later gets the whole backlog
automatically — target the client, not the site.

Only `publish`-status guides are served. Excerpts matter (browse cards).

## Before you call a guide done

1. **Search-test the headings.** Run realistic user questions through the client
   search on a site that has the guide:
   `srv-gw wp --project <client-project> -- eval '$all=get_posts(array("post_type"=>"bw_guide","post_status"=>"publish","numberposts"=>100)); foreach(array("q1","q2") as $q){ $r=BW_Guides_Content::search_sections($all,$q); echo $q." -> ".($r?$r[0]["heading"]["text"]:"MISS")."\n"; }'`
   A miss means: reword the heading, or add the term to that section's hidden
   keywords. Fix and re-test until the realistic queries all hit.
2. Parse check: content is valid core blocks
   (`parse_blocks` — no non-`core/` block names, no stray HTML).
3. No "contact us" phrasing anywhere (`grep -iE 'contact|get in touch|ask us|call us'`).
4. Excerpt present; category set; section keywords attached.

## Hard rules

- Never print or store site keys. Never echo credentials into chat or files.
- Never run `tools/release.sh` (owner-gated) — publishing guides ≠ releasing the
  plugin.
- The hub (`bw_guide` on bw-plugins-dist) does NOT support trash — deletion is
  permanent. Edit, don't delete; unpublish by setting status `draft`.
- Sync invariants are sacred: never bypass the sync engine to edit guides
  directly on a client site; clients' tags and notes must survive every update.
