# Brentwood — Handoff & Running Diary

This is the project's living log. Two jobs:
1. **Onboarding / handoff** — bring a team member up to speed and capture current state + what's next.
2. **Running diary** — a dated record of what changed, what was decided, and why.

Keep entries short. Update this whenever you finish a meaningful chunk, make a decision, or hand off.

---

## Start here (new team member)

**The two environments** (the WordPress TARGET moved — read this carefully):
- **Migration SOURCE (the current live site, mirrored):** Laravel at `https://brentwood.demoing.info`
  (`./laravel`). Read-only copy of live brentwood.ca; resync with `~rian/brentwood-private/sync-mirror.sh`.
- **WordPress TARGET (where everything is being built):** **`https://brentwood.demoing.info`**
  (container `brentwooddev-wordpress`, files at `/srv/apps/brentwooddev/`).
  - ⚠️ **Use `brentwood.demoing.info`, NOT `brentwooddev.demoing.info`.** Both hostnames reach the
    same container, but WordPress's `siteurl` is `brentwood.demoing.info`, so every stylesheet and
    script is emitted from that host. Browse the `brentwooddev` alias and all 25 stylesheets load
    cross-origin from a *separately gated* site: logged out, each one 302s to `auth.bowden.works`
    instead of returning CSS, and **the page renders completely unstyled**. The HTML loads because
    you are authenticated on the alias; nothing else does. Same-origin on the canonical host, so
    one session covers the page and its assets.
  - ⚠️ NOT `brentwoodwp.demoing.info` (the co-located `./wp`). That was the original target but we moved
    to the standalone `brentwooddev` site. `brentwoodwp` has a separate, unrelated ~80-page build — ignore it.
    (Note: the project `CLAUDE.md` still calls `./wp` the target — it's stale on this one point.)
  - Gate password for both demoing.info sites: `showmethesite`. Admin = `support`.

**Read in this order:**
1. **`notes/MIGRATION-DECISIONS.md`** — ⭐ the most important current doc. Every decision, gotcha, and
   "why" from the content migration, newest at top. Start here for *operational* state.
2. **`PROJECT-BRIEF.md`** — what the project is, stakeholders, constraints, schedule.
3. **`10-WEEK-PLAN.md`** (client-facing) + **`PLAN-INTERNAL.md`** + **`TASK-TRACKER.md`** /
   **`TASK-BREAKDOWN.md`** — the plan and task board you built.
4. **`notes/Scope of Work (from proposal).md`** and the April-22 discovery notes — background.

### For Claude: onboarding the lead (Adi)
When Adi starts a session (or returns after >12h away), bring him up to speed: summarise current state
from `notes/MIGRATION-DECISIONS.md` + the latest diary entry below, point him at the target site
(`brentwooddev.demoing.info`), and surface the "What's left" list. **Do not** surface financial figures
(pricing/payment/retainer) — intentionally not in the project folder. The hours/effort budget is fine.

**⚑ Tell Adi on his next login (from Rian — read by Adi 2026-06-17):**
- **Brand logos received** from Brentwood → see `notes/BRAND-ASSETS.md`. Brand red is `#c8272c`; default to
  the horizontal full-name red mark, stacked crest+BRENTWOOD when space-constrained. Relevant to the
  header/footer build now.
- **Analytics / Search Console / the calendar Google Cloud project access is now obtained** — on
  `rian@rian.ca` (the `brentwood.ca.web@gmail.com` account isn't working). See `notes/THIRD-PARTY-ACCESS.md`
  (2026-06-15 update). Unblocks the GA4/tracking + calendar-rebuild workstreams.
- **Timetable image provided** (2026-06-16): `notes/timetable.jpg` (Rian called it "timetable.png" but it
  saved as **.jpg** — JPEG, 1870×2420 portrait). This is the timetable to display as an **image** on the
  site (per the brief, the timetable is shown as an image/PDF, not a rebuilt feature). Use this file for the
  timetable page/section.
- **Dev site is now LIVE at `https://dev.brentwood.ca`** (2026-06-17) — the WordPress build moved off
  `brentwooddev.demoing.info` onto a DigitalOcean droplet on the client's dev domain. Keep developing on
  mosiah (`/srv/apps/brentwooddev`) as before, then **push to the dev URL with one command:
  `push-brentwood-dev`** (or just ask Claude to "push to dev"). **Rian suggests pushing every time there's
  significant progress.** It's password-gated (not public). Gate creds + full details:
  `brentwooddev/deploy/README.md`.

---

## Current status (2026-06-03) — CONTENT MIGRATION DONE, build phase next

**The full content migration into `brentwooddev.demoing.info` is complete.** Every content type is in:

| Content type | Count | Notes |
|---|---|---|
| **Blogs** (native posts, `/blogs/{slug}`) | **6,016** | hero image/gallery, Gutenberg-block bodies, categories, authors, dates, captions |
| **Staff** (CPT `/staff/`) | 170 | departments tax, photos; noindexed (modal popups) |
| **Livestreams** (CPT `/livestreams/{id}`) | 289 | category tax, ACF youtube/start/length/unlisted, banners |
| **Courses** (CPT `/courses/{slug}`) | 136 | category/subject/grade tax, AP/Elective/Credit ACF, body from Laravel |
| **Pages** (hierarchical) | 205 (+12 landing `/l/`) | structure + slugs + Yoast + H1 overrides only — **no body content yet** |
| **Inquiries** (Gravity Form #1) | 1,155 entries | admissions submissions (PII), archived flag preserved |
| **Redirects** (Redirection plugin) | 102 | 81 active; 21 `[NEEDS TARGET]` disabled |

**How it was built (reproducible, not hand-clicked):**
- Schema in code: `wp-content/plugins/brentwood-migration/` registers all CPTs, taxonomies, ACF fields.
- Build/import scripts: `/srv/apps/brentwood/migration/` (`build_blog_full.py`, `importers/*.php`, etc.).
- Source data: `brentwood-pages.xlsx` (the Migration Map workbook) + the Laravel mirror DB.
- Yoast SEO installed; per-page `Title Area Type` (hero) now filled from a rendered-HTML assessment.

## What's left (the build phase — your focus now)
1. **Theme / template build (Kadence)** — the visual layer. Biggest items: a **blog single template**
   (hero gallery/image *above* the title, author byline from ACF `blog_author`, red-accent offset column),
   page templates, header/footer, and the ~6–8 custom blocks (see your block-planning notes).
2. **Page BODY content** — pages are blank shells right now. The ~26k Laravel content blocks → Gutenberg
   is the core remaining content job (the extractor + block library approach in your plan).
3. **Page heroes** — build from `Title Area Type` in the sheet / `notes/page-heroes-final.csv`
   (40 videos, 24 sliders, the rest images). **21 rows are "Needs Review"** (they 500-error on the mirror —
   check those on live).
4. **Deferred page fields** (data retained in the sheet): footer image/colour, locked, exclude-from-search,
   special permissions.
5. **Cleanup/follow-ups:** the 21 `[NEEDS TARGET]` redirects (`notes/redirects-needs-target.txt`); ~1,360
   untagged blogs (categorisation pass); 8 courses with un-migrated PhotoBlocks + 14 empty course bodies;
   livestream "will-be-recorded" / "allowed-groups" (deferred — confirm with client).
6. **Media library** — ~25k photos + ~83 videos bulk import (Rian is handling separately).

## Open items / watch list
- **FinalSite discovery call** (with Tracy) — gates the tracking add-ons + the inquiry/forms strategy.
- **Livestream "the old way"** — confirm with Brentwood IT (see Livestreaming section). One open Q: does any
  stream reach the site by a path *other* than a YouTube embed or the Hudl link?
- Can BW Lead AI journey data get into FinalSite? — investigate.
- DigitalOcean staging box not yet provisioned (incl. Claude SSH access — security pass first).

---

## Livestreaming — what's actually involved (investigated 2026-06-02)

Three distinct things; only two really touch the migration.

**1. Hudl (sports, ~90%) — a LINK-OUT, not an embed.**
- The platform is **Hudl / "HUDL TV"** (`hudl.com`) — *not* "Huddle" (the meeting notes captured it
  phonetically).
- **No Hudl integration exists in the code** — no module, API, or embedded player. The only Hudl
  presence is a clickable promo block (a `photo_block`) that links out to Brentwood's Hudl org page:
  `https://fan.hudl.com/can/bc/mill-bay/organization/73025/brentwood-college-sc-high-school`
- It appears on **`/live`** and **`/athletics`** (plus a stray copy on
  `/brentwood-family/volunteer-opportunities`).
- **Migration impact: trivial** — just recreate the link/button. If Brentwood ever wants a real
  *embedded* Hudl player, that's a **new ask to scope** (not done today).

**2. YouTube livestream module (events, ~10%) — the real build.**
- The custom CMS feature on `/live`: 286 entries, each a YouTube video + start/length, automatic
  upcoming/live/completed state, banners, reusable list blocks. This is what we rebuild as a WP CPT
  (see `PROJECT-BRIEF.md` §4). RSVP (0/286 used) and chat (4/286 ever) are confirmed dead — skip them.

**3. Self-hosted *recorded* video (NOT live).**
- ~**81 uploaded video files (~9.7 GB)** served via `embed_videos` blocks — e.g. the homepage hero and
  the "Discovering Brentwood: An Inside Look" videos on `/admissions/voh-internal-source`. Marketing
  videos, not livestreams, but **self-hosted MP4s** that must migrate (part of the 34 GB media pull).

**On "the old way" / internal-infrastructure streams:** Brentwood mentioned some streams done "the old
way" / via internal infrastructure. **Nothing in the website's code or data shows a self-hosted *live*
streaming server** — no RTMP/HLS, no Wowza, no streaming subdomain. So any such setup lives *outside*
the website (most likely: produce a stream on their own gear → push to YouTube → it embeds via the
module above). Migration-wise there's probably nothing to rebuild — but **confirm with Brentwood IT**
what "the old way" is and whether any stream reaches the site by a path other than a YouTube embed or
the Hudl link. *(The original conversation where they said this is in past chat transcripts, which
weren't searchable from this session.)*

---

## Adi's next focus

Familiarisation + planning are done (the plan and task tracker exist). Content migration is done. You're
now into the **build**:

1. **Kadence template/theme + custom blocks** (per your block-planning notes) — this unblocks the juniors'
   page-assembly work. Priority: the blog single template (hero above title + ACF author byline) and the
   header/footer, then the ~6–8 custom blocks.
2. **Page body content** — stand up the extractor/block pipeline and start the tiered page-assembly pass.
3. Work the **"What's left"** list above; keep `TASK-TRACKER.md` current and the burn log going.
4. Verify a sample of the migrated content on `brentwooddev.demoing.info` (it's all there — spot-check
   heroes, blog bodies, taxonomies) and flag anything that looks off.
5. Log progress here (diary convention below); flag scope/schedule changes to Rian.

**Useful access:** you've been added to the `brentwooddev` project (`brentwooddev-dev` group) — log out/in
once for it to take effect. Manage the site with `srv-gw <cmd> --project brentwooddev` and
`srv-gw wp --project brentwooddev -- <wp-cli>`.

---

## Diary

### 2026-09-03 — Claude (for Rian) — Summer Camps: URL consolidated to /summer-camps/, two stale pages retired

**On the WordPress target (`brentwooddev`) only. Production is untouched** — `www.brentwood.ca/summer-camps/`
still serves the old #902 day-camps page, and the new page has not shipped.

Three camp pages had accumulated and were all returning 200, which is exactly how the wrong one gets
edited or shipped. Consolidated to one address:

| Page | Was | Now |
|---|---|---|
| **#39229** Adi's build | `/summer-camps-2027/` | **`/summer-camps/`** — published, the real page |
| #39226 Claude's first-pass draft | `/summer-camps-2027-draft/` published | **draft** (unpublished) |
| #902 old day-camps page | `/summer-camps/` | `/summer-camps-legacy/` — still published, kept for reference |

301s added (Brentwood Migration group) so existing bookmarks land correctly:
`/summer-camps-2027` → `/summer-camps` and `/summer-camps-2027-draft` → `/summer-camps`.

**Why `/summer-camps/`:** Stacey confirmed in writing on 1 Sept that the address should not change
("Correct to keep same address"), and Erin was asked to confirm. That page already ranks, so a new URL
would give up the history and orphan the old one. The build had drifted onto `/summer-camps-2027/`.

Only the Home page (#859) linked to `/summer-camps/`; that link follows the slug, so it now points at
the new page automatically. Verified after the change: page renders, no leaked block comments, the
brochure PDF still resolves.

**Erin has since confirmed** (relayed by Rian, 3 Sept): the address is `/summer-camps/` and the old
page is **replaced**, not kept alongside. #902 on the target is now a draft, so exactly one camps page
is published there.

**Deployment approach, settled by that decision:** on production, **overwrite #902's content** with
#39229's rather than publishing a new page. The URL never changes, so no redirect is needed, no page
competes with another, and #902 keeps its revision history for rollback. Nothing depends on post 902
structurally — it is in no nav menu and has no postmeta references — so the swap is low risk.

**One deploy detail:** Kadence `uniqueID`s in #39229's markup are prefixed `39229_`. Rewrite them to
`902_` during the copy. The ID appears in three places per block (the JSON attribute, the CSS class,
and `data-kb-block`), so a single find/replace of `39229_` → `902_` updates all three consistently.
Not strictly required, since the IDs only need to be unique, but it keeps the page coherent if anyone
later duplicates it.

**Also ships with it:** the target's homepage (#859) already carries a camps section with a
"See our camp lineup for details" button pointing at `/summer-camps`. Production's homepage has
neither the section nor the link, so the camps page currently has no entry point on live. That
section needs to go over at the same time, or the new page launches unlinked from the homepage.


*Newest first. Format: `### YYYY-MM-DD — author — short title`, then a few bullets: what changed,
decisions, next.*

### 2026-08-27 — Rian — NEW SIDE PROJECT: Summer Camps 2027 page (Adi to pick up)

Separate chargeable engagement — not the migration, not the store. Brief and drop-zone at
**`projects/summer-camps-2027/`**.

- **New page for Brentwood's first multi-night summer camps (2027)** — new format and new subjects
  (**Model UN**, **Marine Science**). Asked for by Erin Coulson and **Stacey Shrout** (Auxiliary
  Programming Manager, Camps & Events), who owns the programme and will maintain the page.
- **Deadline 13 September** — hard. Admissions travel right after and need somewhere to send
  families. Roughly two weeks from the brief.
- **The core design problem:** no programme photography exists (camps have never run, stand-in
  Brentwood images only) while a lot of reassurance text has to be front-loaded for overseas
  families — safety, insurance, food, accommodation, transportation. They know these fight each
  other and have asked for accordions/modals plus deliberate colour and imagery.
- **High visibility:** Head of School's pet project; Admissions treat it as a pipeline into the
  main school.
- `projects/summer-camps-2027/inbox/` is where incoming material lands (images, brochures, ideas)
  as it arrives — unsorted on purpose.
- **Open with Erin:** is this a NEW page or does it replace the existing Summer Camps page (#902,
  the day-camp offering)? Decides URL, nav and SEO. Also: her email arrives truncated mid-sentence,
  so a paragraph about the reader experience is missing.
- ⚠ **Calendar clash worth naming:** camps page due 13 Sept, store targeting late Sept, with Erin
  and Stacey central to both.

### 2026-08-27 — Rian — NEW WORKSTREAM: WooCommerce school store

Brentwood want an online school store built on the existing site. Full requirements filed at
**`notes/store-requirements.md`** — read that before starting any store work.

- **WooCommerce on the main site.** Credit card only, CAD only, Canada Post, plus campus pickup
  ("Collect in School Store", incl. a student collecting for a parent). Existing merchant provider
  is **Lightspeed** (brick-and-mortar — an online application is still required).
- **Target: live within a month of 25 Aug**, i.e. late September, ahead of sports seasons and
  Christmas. Small-and-iterate is acceptable; a shabby first impression is not.
- **SKU list is Part 2, not yet received.** Julie is preparing it, Ian is reviewing it.
- **Awaiting an answer from Rian:** how to handle tax for customers outside Canada. Their
  accountant should set the rules; we implement them.
- ⚠ Three things flagged in the notes: Lightspeed online approval is the likely critical path;
  payments change the site's security posture (tokenised gateway only, no raw card data);
  and the same payment path is expected to serve **summer camp registration** later, so the
  gateway choice should account for that now.
- ⚠ Their "prepaid box as its own SKU" idea is recorded as a *requirement*, but is normally a
  shipping method rather than a product — the notes explain why before anyone builds it.

### 2026-08-21 — Claude (for Rian) — Post-cutover 404 sweep: missing routes, legacy URLs, icons

Client reported calendar registration links erroring. It was a migration gap, and the 404 log
turned out to be a record of everything else the migration missed. All fixed and live.

- **`/nxt-form/{id}` — the reported bug.** A Laravel route (`PagesController@nxtForm`) rendering a
  Blackbaud BBOX form; never carried over, so every link 404'd. 8 form ids, ~99 hits, still being
  clicked. Reproduced as `inc/bw-nxt-form.php` — could not be a redirect, since a Blackbaud form
  has no address other than its id. **Hardened**: the old Blade view interpolated the id into a
  `<script>` with `{{ }}` (HTML-escaped, not JS-escaped); it is now UUID-validated and JSON-encoded.
- **Four legacy redirects** added to the existing "Brentwood Migration" group (now 1,005 rules):
  `/community`→`/brentwood-family`, `/schoolstart`→`/events/schoolstart`, `/info`→`/`, `/register`→`/`.
- **`/photos/{id}/{file}` — 1,642 distinct legacy image URLs**, many with EXTERNAL referrers, so
  unlike the rest they never decay. `inc/bw-legacy-photos.php` resolves by filename: exact, then
  `+.webp`, then whatever size survived (medium/large preferred). **~44% recover**; the rest were
  genuinely never migrated and keep their 404. Misses cache for a day — the fallback is an
  unindexed LIKE and a miss never changes its answer.
- **`/apple-touch-icon*.png` — ~1,350 hits.** iOS probes the site root regardless of link tags.
  `inc/bw-root-icons.php` serves the site icon (runs on `init`, costs a string compare). Handled in
  the theme, NOT by dropping files at the web root — only `wp-content` is bind-mounted, so root
  files vanish on container recreate (that is how wp-cli went missing).
- **Site icon replaced.** Was 64×64, while the link tag claimed `sizes="192x192"` — serving a 64px
  file. Upscaled the media library's 450×450 crest to 512 (Lanczos, flattened to white) and set it
  as the Site Icon with the site-icon size set generated properly. Now 32/180/192/270/512 each
  serve a correctly-sized file. **Interim by Rian's instruction** — the proper crest should come
  from Brentwood's Drive logo pack (not in Rian's Drive; I searched). Old icon kept as **#21968**,
  new one is **#39292**, so reverting is one Customizer change.
- **Not actionable, deliberately:** `/social-media` (378), `/load-menu` (59), old CSS and logo paths
  are all browsers running cached copies of the old Laravel site — nothing in our theme, content or
  options references them, and they decay as caches expire. Scanner noise (`.aws`, `Dockerfile`,
  `firebase-adminsdk.json` …) correctly 404s.
- ⚠ Still unexplained: **`/meta.json`, 1,709 hits**, no referrer and no bot signature. Largest
  single line in the 404 log.

### 2026-08-13 — Claude (for Rian) — Lead AI caps: adaptive switchover + IT locked out of manage

**Status correction:** the BW Lead AI capability work is written but **NOT released** — it is
1.10.0 in the hub working copy; newest published zip is 1.9.0; both staging and dev still run
**1.4.1**. **BW Guides has not started** — hub still has `READ_CAP = 'edit_posts'`. So both shims
stay for now; releasing 1.10.0 is Rian's to run.

- **The shims now retire themselves.** `bw_access_lead_ai_capability()` /
  `bw_access_guides_capability()` detect the plugin's own capability; when present the borrow
  stops being registered, the grant switches to the real capability name, our stand-in menu
  disappears, and anyone holding the old name is migrated. No second deploy, nothing to
  remember. Verified against a simulated 1.10.0 (`bw-test-switch.php`, 15 checks).
- **IT can no longer manage Lead AI.** The plugin floors BOTH capabilities to any
  `manage_options` holder — which would have handed the IT team settings, retention, purge and
  reset. Used the escape hatch the plugin documents: filtered `bw_lead_ai_manage_capability` to
  `bw_lead_ai_manage_site` and grant it **by administrator role**, which the IT grant is not.
  IT keeps `view` (they can confirm the plugin works); administrators keep everything.
  `BW_ACCESS_LEAD_AI_TAKEOVER` is where to add `'view'` if reading should be restricted too.
  Verified on dev against the real users: Shay `manage_options=1`, renamed manage `=0`; admin `=1`.
- Suites now **110 checks across 5 files**, all passing.
- ⚠ When 1.10.0 is installed it is a **six-version jump from 1.4.1** — re-check the Finalsite
  handoff/GTM tags after, since those were built against 1.4.1.

### 2026-08-12 (latest) — Claude (for Rian) — File editors off; a shim hole found and closed

- **`DISALLOW_FILE_EDIT` set on staging AND dev.** Applied live with `wp config set`, and added
  as `WORDPRESS_CONFIG_EXTRA` to both compose files — necessary because only `wp-content` is
  bind-mounted, so `wp-config.php` lives in the container filesystem and is regenerated on
  recreate. Verified: administrators now fail `edit_plugins` / `edit_themes`; `activate_plugins`
  unaffected.
- **Security fix in the guides shim (mine, caught before anyone hit it).** The capability borrow
  covered the BW Guides AJAX actions as well as the page render — but `bw_guides_save_tags`
  writes **shared** taxonomy terms and `check_updates` runs a sync that writes posts. Any logged-in
  user could have retagged the documentation site-wide. The borrow is now page-render only;
  tags, notes and the sync check stay with real `edit_posts`. Two regression tests added (A6/A7).
  Suites now **82 checks**, all passing. Shipped to dev.
- **Capability requests sent to both plugins** (Lead AI: `bw_lead_ai_view` / `_manage`; Guides:
  `bw_guides_read` / `_manage`) so both shims can be deleted. NB the Lead AI request had NOT
  actually been sent before despite an earlier diary line implying it — corrected.
- ⚠ **Two live BW Lead AI sessions** exist: `local_a7d8a23f…` (titled "BW lead AI - archived
  260811" — the one that built 1.4.0/1.4.1 and holds the context) and `local_531bde7d…` ("BW
  Lead AI"). Requests have gone to the first. Rian to confirm which is canonical.

### 2026-08-12 (later) — Claude (for Rian) — Access grants: tools, IT admin, guides for everyone

Second pass on the access feature, all released to dev by targeted rsync (2 theme files).

- **Tools grants** on Users → Site Access, offered only when the plugin is active:
  **Forms & entries** (Gravity Forms — real capabilities, clean) and **Lead AI** (admissions).
- **Site administration** grant for IT: plugins, themes, tools, settings, Yoast — and no content.
  Excludes the user roster and the PHP file editors. ⚠ Labelled in the UI as a *tidiness*
  boundary, not a security one: anyone who can install a plugin can ultimately do anything.
- **Guides readable by everyone signed in, teachers included.** BW Lead AI and BW Guides both
  hard-code their gate (`manage_options` / `edit_posts`), so there is no capability to grant.
  Bridged with a **screen-scoped capability borrow** (`bw_access_proxy_screens()`): the cap
  exists only while that plugin's own screen renders — not on save, AJAX or REST. Teachers get
  a "Help & guides" link on /my-profile and the guides screen is exempted from the wp-admin
  bounce. **This is a shim; the real fix is those plugins gating on their own capability**
  (request already with the BW Lead AI session).
- **Dropdown now switches on selection** — Rian hit the trap where choosing a name without
  pressing View saved one person's access onto another. Button also reads "Save access for X".
- Two real bugs found by testing, not by review:
  1. **Revoking Site administration was impossible** — the handler guarded on
     `user_can('manage_options')`, true the moment the grant was made. One-way door. Now
     checks the ROLE. Save logic extracted to `bw_access_apply_submission()` so it is testable
     (and usable from wp-cli for batch grants).
  2. **Forms showed an unticked box for Editors** who already hold GF caps via their role.
- Suites now **80 checks across 3 files**, all passing: `docs/access-tests/` (+ README with
  the two testing traps that cost time).
- Phase-1 demo residue on staging is gone (the suites clean up after themselves).

### 2026-08-12 — Claude (for Rian) — Individual access grants built, tested, released to dev

Erin can now give ANY user (any role) edit access to specific pages, or full access to whole
sections, with no role changes and no author-swapping. Plan: `notes/access-feature-plan.md`.

- **Per-page**: "Who can edit this" panel (editor sidebar, admins+editors see it) → those people
  can edit/update that one item. Never create, trash, or anything else. Multiple people per page.
- **Per-section**: **Users → Site Access** (admin-only) → full lifecycle for a whole post type
  (the HR-runs-Careers case). Stored as native per-user capabilities. Audit table of everyone
  holding any grant on the same screen.
- Foundation: split the shared `bw_content` capability family into per-type families
  (`bw-roles.php`, BW_ROLES_VERSION 9), distinct `create_*` caps, taxonomy caps tied to their
  types. Engine + UI in new `inc/bw-access.php`; teachers holding a grant are exempted from the
  bw-staff-access wp-admin lockout (their path: browse to page → admin-bar "Edit Page").
- Tested on staging: 27-check matrix (capabilities, REST incl. the publish-on-published edge,
  list narrowing, section grants, staff-lockout integration, editor/admin regressions) — all
  pass. Re-runnable: `docs/access-tests/` in brentwooddev. One bug found in browser QA and
  fixed (revision meta double-counted in the audit).
- **Released to dev** via targeted rsync (4 theme files only — DB, content, GF 2.10.5 all
  untouched). Verified on droplet: roles v9 applied, caps correct, site 200.
- Staging demo residue: test user `bw_access_test_user` holds a Careers grant + a page grant on
  "Purpose Values Commitments" so the Site Access screen has a populated example. Remove via
  the same screens when done.
- ⚠ Grants live in the DEV database (usermeta + postmeta). A full `push-to-dev` would wipe
  them along with content — the push script still needs a code-only mode.
- Next: Rian's list of users → pages/sections gets configured ON dev; hub guide for Erin.

### 2026-07-30 — Rian + Claude — "The font looks heavier on dev" — real, and macOS-only

Client compared live to dev and said our body text looked darker/thicker. Rian couldn't see it on his
screen, and neither could the client's screenshare.

**Both sites use the same families** — Open Sans for body, Oswald for headings. Nobody picked the wrong
font. Two genuine differences underneath:

1. **`-webkit-font-smoothing` — the one that explains the symptom.** Live sets
   `-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale` **on `body`**. Dev sets it
   **nowhere** — 0 occurrences across 21 stylesheets and 234 KB of inline CSS. On macOS that is a
   visible difference: with it, grayscale antialiasing (thinner); without it, subpixel antialiasing
   (heavier, slightly warmer). **On Windows and Linux the property does nothing at all**, which is
   exactly why it's invisible to some people and obvious to others looking at the same two pages. Fixed
   by copying live's rule verbatim into the child `style.css`.
2. **Different font FILES.** Live self-hosts **variable** fonts (Open Sans Variable 300–800, Oswald
   Variable 200–700, woff2). Dev pulls **static instances from Google Fonts** — and makes *three*
   separate requests with overlapping weights (`400,600,700,400i` + a legacy `200,300,700,700i` +
   a lone `Oswald:regular`). Same typefaces, different builds and delivery. Secondary to the above but
   worth tidying: three render-blocking font requests is two too many.

⚠ **Not verifiable from this server.** The property is a no-op off macOS, so headless Chrome here
cannot show the before/after. Needs a Mac to confirm.

Method note: the text *colour* was ruled out by measuring it — sampling the glyph cores in the client's
own screenshot gave `#3a4554` and `#3b4655` for the two windows, i.e. identical. The pixel test for
antialiasing type was inconclusive (both windows showed fringing, and the right-hand one renders larger,
so ink coverage isn't comparable) — the CSS comparison is the reliable evidence here, not the forensics.

### 2026-07-26 (later 15) — Rian + Claude — Plugin update failed on permissions (worth remembering)

Updating bw-guides to 0.3.4 in wp-admin failed with *"some files could not be copied … inconsistent
file permissions."*

**Cause: the plugin had been hand-installed as `rian`, not through WordPress.** Its directory was
`rian:bw-plugins-dev drwxrwsr-x` — and **`www-data` is not a member of `bw-plugins-dev`**, with no
write bit for "other", so WordPress physically could not write into it. Plugins that update cleanly
(instagram-feed, bit-social) are `www-data:www-data`, installed the normal way. My hand-copied
`class-bw-guides-content.php` was a second, smaller instance of the same thing — 644, so not even
group-writable.

Fixed with `srv-gw fix-permissions --project brentwooddev` (the sanctioned route — no manual
chown/chmod): wp-content is now `www-data:brentwooddev-dev` 2775 with ACLs for rian and adi. Verified
by actually writing as www-data inside the container rather than reading the mode bits and assuming.

**The general lesson:** any plugin dropped into a project by hand from a shell will refuse to
auto-update later, and the error blames "permissions" without saying whose. If a WordPress update ever
fails on a project here, check `ls -ld` on the plugin first — if it isn't `www-data`, that's the
answer, and `srv-gw fix-permissions` is the fix.

### 2026-07-26 (later 14) — Rian + Claude — Pushed to dev

`push-to-dev.sh`, 303 MB DB dump, clean run. Pre-flight first: no stray working scripts left in
`wp-content` to rsync, and the Brentwood 100 + login-background transients flushed so dev rebuilds
them rather than inheriting mosiah's cache.

Verified after: gate returns **401** externally (healthy), and behind it `/`, `/brentwood-100/`,
`/wp-login.php` and `/l/local/` all return 200. Spot-checked that the weekend's work actually landed
rather than trusting the exit code — the grid renders with the new `data-combos` attribute and number
badges, and the login page carries the branded markup.

**Carried to dev:** the pattern library and the un-tagged pages · every Brentwood 100 change (packer,
carousels, preview panel, permutation model) · the Gravity Forms styling and the review-step hidden
field fix · the branded sign-in screen and its options page · all the guide fixes (those live on the
hub and sync down independently, so dev pulls them on its own).

⚠ **Not carried:** the bw-guides search fallback is a **single edited file on mosiah's plugin copy**,
not a release. Dev runs the released plugin, so full-sentence searches still return an empty state
there. Needs `tools/release.sh` (owner-gated) to reach dev or any other client.

### 2026-07-26 (later 13) — Rian + Claude — Review step leaked hidden tracking data · login options page

**BW Lead AI data was appearing in the form's Review step.** Traced, not guessed. The plugin adds two
fields to form 2 (`bw_lead_ai_datapoint`: *Channel*, *Summary*) whose Gravity Forms visibility is
**`gfield_visibility_visible`** — as far as GF is concerned they are ordinary fields. What marks them
as not-for-the-visitor is the **`gform_hidden`** class, which the review script wasn't checking.

Two separate holes, and only the second one actually showed:
- The wrapper test looked for inline `display:none` and `gfield_visibility_hidden`, not `gform_hidden`.
- The value reader skipped `input[type=hidden]` — but *Summary* renders as a
  `<textarea class="gform_hidden" style="display:none">` (plugin line 171), and a textarea fell
  straight through a check that only looked at input types. That is why Summary leaked and Channel
  didn't.

Fixed on Gravity Forms' own convention rather than by naming this plugin: any field wrapper carrying
`gform_hidden` is excluded, and any input **or textarea** that is itself hidden is skipped whatever its
tag. Anything else following the same convention is covered for free.

**Sign-in screen options page** — Appearance → **Sign-in screen** (ACF, `edit_theme_options`), a
gallery of background photographs. **One is picked at random per page load**, which is free: wp-login
is never cached. With none chosen it falls back to the photographs the site already uses as full-bleed
page **footers** — curated for this exact job, so it's a real answer rather than a placeholder. The
pool is cached for a day and busted when any page's `footer_image_background` changes.

**Card is now centred on both axes** (flex on `body.login`; `min-height` not `height`, so the expanded
password form or a stack of error notices can still grow the page and scroll).

⚠ Footer images are only ~1152px wide, which will soften on a large monitor. Worth Marketing adding a
few larger photographs through the new options page.

### 2026-07-26 (later 12) — Rian + Claude — Branded sign-in screen

New `inc/bw-login.php`. The crest replaces the WordPress mark and links to the site rather than
wordpress.org, a Brentwood photograph (attachment 34784, the oceanfront house shot) sits behind a
darkened overlay, the language switcher is gone (`login_display_language_dropdown`), and the whole
thing is a white card with the brand red on the submit button.

**Google is the primary action; username-and-password is folded behind a disclosure.** Nearly everyone
signing in is staff with a Brentwood Google account, so the password form no longer competes with the
route almost everyone should take — but it stays one click away for support accounts and anyone
without a linked Google account. Wrapping it has to happen in the browser (WordPress prints
`#loginform` itself; no filter wraps it), and the failure mode is safe: no script, form simply
visible. It **auto-opens on a failed sign-in or a password-reset link**, so a mistyped password
doesn't leave someone hunting for the form.

Two things caught while checking rather than assumed: the divider still read *"Staff & Students — or
sign in below"* when the form was no longer below it (now just "or"), and my `#nav a` white-link rules
never applied — core's `.login #nav a` outranks them, and those links render **inside** the white card
anyway, not on the photograph. They were legible by luck; now they're muted grey by intent.

**Verified:** signed-out capture in both states, and `?action=lostpassword` — the reset form is a
different element, so the disclosure script correctly leaves it alone and password reset still works.

Not pushed to dev. The background is one constant (`BW_LOGIN_BG_ID`) if a different photo suits, and
the overlay darkness is one line if the picture should recede further.

### 2026-07-26 (later 11) — Rian + Claude — Remaining guide findings closed · Monday agenda

**All outstanding items from the test pass are done.** Users guide gained the three undocumented roles
(Contributor, SEO Manager, SEO Editor — verified against `get_editable_roles`, which returns 9) plus
the password and notification steps, and "no password to set up" was reworded so it no longer reads as
contradicted by the password field on screen · staff add-steps now include First/Last Name, with *why*
(the list sorts on Last Name, which matters for two-part surnames) · the teacher-facing and admin-facing
staff guides now cross-name their fields (self-service *Categories* = admin *Departments*, *Titles &
Departments* = *Title / Position*, both read off the live screens) · livestreams says to scroll past
Yoast · redirects disambiguates the two tools.

**New section: "What is the Meta Boxes drawer at the bottom?"** in Getting started. Every custom field
on the site lives in it, it can open over the whole editor on a new post, and its state persists per
user — so someone who collapses it loses every custom field with no idea why. No guide had ever named
it.

**A real search bug came out of testing it.** `word_in_whole()` refuses to match tokens under three
characters, but `closest_sections()` still counted them in the denominator — so filler pushed a query
below the threshold it could never reach ("where did the editor go" reduces to did/editor/go, of which
two can never match, requiring an impossible 2 of 3). Short tokens are now dropped before counting,
and `did`/`go` joined the stopword list. ⚠ Still only in the staging copy of the plugin — **needs a
release**.

**`notes/monday-agenda.md`** — 3-hour handover agenda, priority-ordered with an explicit **cut line**
marking what fits. Key judgement: the **social connections go early but hard time-boxed to 30
minutes**. Verified state — *both plugins are active and neither is connected to anything* (Instagram
Feed has 0 accounts; Bit Social has only install-level options). Erin has to click both flows herself:
these are app authorisations granted off her roles on the **Page and Instagram account**, not off
Bowden Works' partner access to the Business Portfolio. Prerequisites to check before starting are
listed, along with the instruction to stop and rebook if it stalls rather than lose the session.

### 2026-07-26 (later 10) — Rian + Claude — Documentation caught up with the rebuild

All published via the hub (`bw-plugins-dist`) and pulled down; nothing edited on the client.

**"Managing the Brentwood 100" (hub 87) rewritten.** Five of its eight sections described things that
no longer exist — the badge-number field, the three tile styles, tile width/height, the separate
gallery. New sections cover the preview panel as the way a tile's look is chosen, drag-ordering via
`Re-Order` (with the point that a number is a label, not a position, since the grid shuffles), the
two video options and how differently they behave, and why the grid changes on every visit. Draft
kept at `docs/guides-draft/guides/21-brentwood-100.html`; 9 keyword rules.

**Duplicate-a-page rewritten (hub 24, audience `all`).** Yoast Duplicate Post is installed now, so the
Copy-all-blocks workaround is gone. Verified on screen before writing — the row actions are
Edit · Quick Edit · Move to Trash · View · **Clone** · **New Draft** · Rewrite & Republish — and the
guide now points at **New Draft** (copies and opens it) with Clone as the quicker alternative.

**Remaining findings from the test pass, applied:** Table Link's link text is typed on the block, not
in the sidebar (88) · Curve Divider has no colour or size setting (88) · the Hero Card is already on
every new page, which is why it's greyed out (72) · the SEO guide now admits administrators see a
Yoast menu (91) · the date steps drop the wrong Status & visibility step and the non-existent Confirm
button (69) · categories point at the Search box first, with a caution about duplicates (69) ·
"undo / I broke it / revert" keywords added so the revisions safety net is findable at all (24).

**Verified after sync:** every stale claim is gone from all 29 guides (`Page Attributes`, `Copy all
blocks`, the B100 badge number, `Modal, Inline, Montage`, Curve Divider's colour/size), every new one
is present, and eight realistic questions all land on the right section — including *"the photo is
cropped badly on a tile"* and *"why does the grid change every time"*, which needed keyword tuning to
reach the right heading rather than merely the right guide.

**Still open from the report** (smaller, none blocking): the Users guide's password + notification
step and three undocumented roles · staff First/Last name step · the Meta Boxes drawer explainer ·
livestreams "scroll past Yoast" · two redirect tools disambiguation · the admin-side field-name
mismatch between the staff guide and the teacher self-service guide · the 150+ blog category list
needing a tidy.

### 2026-07-26 (later 9) — Rian + Claude — B100: title overlay was being painted over

Titles vanished from image tiles — intermittently, which is what made it look odd. Cause: the carousel
stacking gave the active image `z-index: 1`, and **z-index beats DOM order**, so the picture painted
over the title even though the title comes after it in the markup. It looked intermittent because the
number badge and the dots already carried `z-index: 2` and survived, and video tiles have no z-index
at all, so those kept their titles.

Fixed by raising `.bw100__media-title` and `.bw100__play` to `z-index: 2`, and the stacking order
inside `.bw100__media` is now written down in the stylesheet (0 inactive images · 1 active image · 2
everything that must read on top) so the next addition doesn't repeat it.

Worth noting for anyone checking: a tile showing media with **no** overlaid title isn't necessarily
broken — on a split layout the title correctly heads the text half instead.

### 2026-07-26 (later 8) — Rian + Claude — B100: permutations are now the control, not a cross-product

**The data model changed, and it's the right one.** A tile's allowed permutations were the
**cross-product** of two checkbox lists (shapes × layouts), which made some choices impossible to
express: you could not keep *Wide · Text | Media* while dropping *Wide · Media only*, because
unticking "Media only" also removed the square version. Rian hit this on an item whose wide crop cut
off a subject's head.

Now a tile stores an explicit list of `shape:layout` **pairs** (`hundred_combos`), and the preview
panel IS the control — one checkbox per permutation, next to a picture of it. The two ACF checkbox
fields are gone; all 89 items were migrated by taking their old cross-product, so nothing changed
behaviourally (65 items have all 10, the rest fewer).

Knock-on simplifications: the packer's `shapesFor` / `chooseLayout` read straight off the pair list,
so the square-can't-be-side-by-side rule and every unticked permutation are expressed in one place
instead of three; several editor warnings that policed the two lists became unnecessary, since the
panel only ever offers pairs the content supports and the save filters to them.

**Previews at half size, by scaling not restyling** — `transform: scale(.5)` on a real 400px cell
inside a 200px frame. Re-specifying smaller fonts would change how much of the box the text fills,
which is the one thing the panel exists to show.

**The grid's own scripts now load on the edit screen**, so previews cross-fade and videos play there
exactly as on the page. `carousels()` and `videos()` moved from per-grid to document-wide, because the
preview panel is not a `.bw100__grid` — that's why nothing animated there before. It also fixes "The
Fire": its MP4 is fine, but the tile renders with `preload="none"` and no poster, so without the
script it showed an empty box.

### 2026-07-26 (later 7) — Rian + Claude — B100: carousels auto-advance · split tiles were mirrored

**Arrows replaced by a slow auto cross-fade**, each tile on its own interval of **3–7 seconds, chosen
once per tile**. One shared timer would flip all 58 carousels in unison, which reads as the page
glitching rather than as photographs changing. Only on-screen tiles advance (IntersectionObserver), so
58 timers never run at once, and `prefers-reduced-motion` leaves the first image in place.

**The dots moved inside `.bw100__media`.** They were on the wrong half of every split tile, and the
cause was structural: the old controls were *buttons*, a `<button>` may not contain other buttons, so
they had to live outside the tile and be inset by CSS to guess where the media was. Now they're a
plain indicator, they sit inside the media element and are over the picture by construction — no
insets, no guessing, and the whole `.bw100__nav` layer is gone.

⚠ **Split tiles were mirrored, and the labels lied.** The face is emitted **media-first**, so plain
`flex-direction: row` puts the MEDIA on the left — but that was the rule attached to `text_media`
("Text | Media"), and `media_text` got `row-reverse`. Both layouts rendered the opposite way round to
their own names. It only surfaced because the carousel dots landed on the text side, which is what
rian spotted. Corrected: `text_media` is the reversed one.

**Preview panel now renders at true desktop size** — 400px per cell, 808px for a double. The point of
the panel is to answer "how much of the box will this text fill?" and "where does this photo crop?",
and a scaled-down preview can't answer either. It scrolls inside 70vh so the settings stay reachable.
(Its carousels don't cycle — the grid's view.js isn't loaded in admin — so it shows each tile's first
image. Fine for judging crop on the cover; worth knowing if you're checking a later image.)

### 2026-07-26 (later 6) — Rian + Claude — B100: tile preview panel · image carousels · text padding

**Text padding — not the padding.** The bottom inset was set correctly all along; `overflow: hidden`
clips at the **padding edge**, so long text was drawn straight through the padding and sliced at the
card boundary. Moved the clipping onto `.bw100__body` (`flex: 0 1 auto` + `min-height: 0`, which lets
a flex child be shorter than its content) so the cut lands on its own edge and the parent's padding
survives underneath. The fade moved to `bottom: 3rem` to sit on the new cut line.

**"How this tile can look" panel** (`inc/bw-hundred-preview.php`). Every shape × layout the item's
content can produce — up to 10 — rendered at true proportions on the edit screen. It uses the same
markup and stylesheet as the real grid (the face builder was extracted to `bw_hundred_tile_face()` for
exactly this reason: a preview that can drift from the thing it previews is worse than none).
Currently-unticked permutations are shown **dimmed and labelled rather than hidden** — the job is
deciding what to switch off, which you can't do if the option vanishes the moment you untick it. A
small inline script re-dims live as the checkboxes change, so there's no save-and-reload loop.

**The panel immediately earned its keep** — it showed that item locked to *Text only*, meaning its
photographs could never appear. Cause: the migration mapped legacy `inline` tiles to `text_only`, but
an inline tile on the old grid showed its cover image **and** its text. **14 items** that have both
were freed to all four layouts; the 17 with only one kind of content were left alone, since the render
rules derive their layout from what they actually have.

**Image carousels** on the 58 tiles with more than one picture (2–7 images each), so a visitor can look
through them without opening the pop-up. Two things shaped the implementation:
- The controls are a **sibling** of the tile button, not inside it — a `<button>` may not contain
  other buttons; the markup is invalid and keyboard users can't reach the inner ones. Being siblings
  also means an arrow click can't open the pop-up, with no event plumbing.
- The nav covers the cell and is **inset to match whichever half holds the media** (and hidden
  entirely on text-only tiles). It can't live inside `.bw100__media` for the reason above, and without
  the insets the arrows floated over the text on split tiles — which is how the first version looked.

Badge in the preview shows the item's real position number, not a placeholder.

⚠ **Post Types Order shows "must be configured"** on the edit screen — it needs its settings page
saved once before drag-ordering works.

### 2026-07-26 (later 5) — Rian + Claude — B100: order randomised · legacy fields gone · editor warnings

**The order wasn't changing — a bug I introduced.** Fixing the performance problem (later 3) replaced
"try every tile" with "try the most-constrained tile", implemented as a *sorted* list. That made tile
choice fully deterministic: only the shapes varied between loads, so the same items landed in the same
slots every visit. Fix: shuffle each pool **before** sorting by constraint count — `Array.sort` is
stable, so equally-constrained tiles keep their shuffled order. The search still tries the most
constrained first (which is what keeps it fast) but picks at random among equals. **8/8 distinct
orderings across 8 loads**; still 0 failures in 200 runs.

**"Auto" removed from both checkbox sets.** All options are simply ticked by default and the algorithm
picks among what's ticked — rian's suggestion, and better: "auto" was a fifth choice that meant
"ignore the other four", which is exactly the kind of control that needs explaining. Stored `auto`
values were expanded to the full set first (**82 orientation, 59 content**) or 83 items would have
opened with nothing ticked. An empty set is still treated as "any" — that can only be a slip.

**Legacy fields and the legacy grid removed.** `hundred_type` / `span` / `vspan` / `caption` /
`gallery` gone from the field group, `render-legacy.php` + `style-legacy.css` and the `?b100=legacy`
branch deleted with them (the old renderer read exactly those fields, so it would only have drawn
empty tiles). Post type supports cut to `title` + `page-attributes` — no editor, thumbnail, excerpt or
custom-fields box — and Yoast filtered off the type via `wpseo_accessible_post_types`. **All legacy
meta is still in the database** and in `.snapshots/b100-pre-migration-20260726.json`.

**Editor warnings** (`inc/bw-hundred-admin.php`). A notice above the editor and an "issues" column in
the list, whenever the settings can't be honoured — media-only with no media, square with only
side-by-side layouts, video type with no video, nothing ticked. It never blocks a save: the render
already falls back safely, and interrupting an author over a cosmetic mismatch is worse than telling
them plainly. All 89 current items come up clean; verified against four deliberately broken cases on a
throwaway item.

⚠ **Testing note:** the first run of that check reported the *same* warning for all four cases —
**ACF memoises field values per post for the life of a request**, so every case after the first read
case one's values. `acf_get_store('values')->reset()` between cases. Worth remembering for any test
that mutates meta and re-reads it in one process.

**Badges** are rounded squares rather than circles.

### 2026-07-26 (later 4) — Rian + Claude — B100: square-clustering fixed · badges from drag order · card styling

**Why the bottom rows were all squares.** The shape at each cell was drawn uniformly from
square/wide/tall, so a double was chosen **two times in three** — far above the ~42% the grid can
actually afford. The doubles were all spent in the upper rows, and once `cellsLeft == tilesLeft` the
pruning rule left squares as the only legal move for the whole tail. Not a bias toward squares: a
bias *against* them, early, which the arithmetic then paid back at the bottom.

The fix is to take the probability from what's left rather than guessing: every tile covers 1 or 2
cells, so the doubles still owed are exactly `(cellsLeft − tilesLeft)` out of `tilesLeft`. Measured
over 40 packs, doubles per quarter of the grid went from front-loaded to **42% / 44% / 40% / 40%**,
top to bottom. Still 0 failures in 200 runs, still 0.2 ms.

**Badge number is now the drag position.** `bw_hundred_query()` already orders by `menu_order`, which
is what Post Types Order writes, so the badge is simply the index — first in the admin list is #1. The
`hundred_number` ACF field is removed (its data stays in the database and in the snapshot). One trap
worth knowing: **Post Types Order rewrites `menu_order` over AJAX without saving any post**, so
`save_post` never fires and the cached grid would show every tile the wrong number after a reorder —
hooked `PTO/order_update_complete` to flush it.

**Card styling** as specified: `1px solid #fefefe`, `box-shadow: 3px 3px 5px #ccc`, `border-radius:
5px`. The badge sits at the tile's top-left corner rather than inside either half, so it lands in the
same place whichever way a split tile faces. Text tiles carry `3rem` padding top **and bottom** — the
top clears the badge, and the bottom has to match it exactly or the vertical centring is thrown off by
half the difference.

### 2026-07-26 (later 3) — Rian + Claude — Brentwood 100: packer + renderer built (step 3)

New files in `blocks/hundred-grid/`: **packer.js** (the algorithm), rewritten **render.php**,
**view.js**, **style.css**. The previous grid is preserved as `render-legacy.php` + `style-legacy.css`
and still reachable at **`?b100=legacy`** for side-by-side comparison; delete both once signed off.

**The packer.** 3 columns of square slots; a tile is 1 cell, 2 stacked or 2 side by side. It fixes the
grid to an exact R×3 rectangle first — R chosen so total area divides by 3 — which is what makes "no
holes" checkable: place every tile and you have necessarily filled every cell. Then depth-first with
backtracking. **200 randomised runs against the real data: 0 failures, 0.3 ms per pack**, averaging
52 square / 20 wide / 17 tall in 42 rows.

Three things had to be got right, all found by testing rather than reasoning:

1. **Branching over tiles blows the budget.** The first version tried every unused tile at every cell;
   all 200 runs exhausted the node budget and fell back to a plain square grid. *Which* tile fills a
   slot barely affects solvability — only its shape does — so it now buckets tiles by shape, tries the
   most-constrained first, and stops at 2 candidates.
2. **The pruning rule is what makes it fast.** Every remaining tile covers 1 or 2 cells, so cells-left
   must sit between tiles-left and 2×tiles-left. That one check kills nearly every dead branch,
   including the "only wides left, one cell free" dead-end.
3. **`mark()` wrote the tile index, and index 0 is falsy** — so tile 0's cells read as empty and the
   next tile overlapped them. Occupancy is a boolean now. The validator caught it; nothing in the
   rendered page would have.

**Row height is set in JS, not CSS.** A row must be exactly as tall as a column is wide. Percentage
padding resolves against width and would do it for one element, but a row is shared between tiles and
spanned by talls — there's no single element to hang the ratio on. `view.js` measures the rendered
column and writes `grid-auto-rows` in px, re-measuring on resize (a style write; the packing is
unaffected by width). The first attempt used a `::before` padding hack and produced rows roughly twice
their correct height.

**Layout choice is weighted, not uniform** — media_only 4, splits 3, text_only 1. Picking evenly meant
a tile with a strong photograph rendered as a block of text about a quarter of the time, and the
mosaic came out text-heavy on some loads. The Brentwood 100 is a picture wall.

**Contradiction rules live in `bw_hundred_tile_data()`**, resolved once, in order: what the tile HAS
wins → then the author's layouts → then orientation (a tile that can't do text-only or media-only
can't be square). An empty intersection falls back to the content-derived answer rather than dropping
the tile — a tile in an unintended shape beats one that silently vanishes from a grid of 89.

**Also:** every tile opens the pop-up · title over the media, or heading the text when text shows
(both rendered, CSS picks; the media copy is `aria-hidden` since the button already carries the title)
· MP4 tiles autoplay muted via IntersectionObserver and pause off-screen, YouTube becomes a
`youtube-nocookie` embed in the pop-up only · mobile collapses to squares by overriding the
coordinates, no re-solve · `prefers-reduced-motion` leaves videos on their poster frame.

**Not done:** admin-side warning when an author picks a contradictory combination (handled at render,
but they get no feedback while editing) · Post Types Order not installed yet · step 4 (switching off
editor/thumbnail/excerpt/Yoast and retiring the legacy fields) deliberately still pending.

### 2026-07-26 (later 2) — Rian + Claude — Brentwood 100 rebuild: new tile model + migration

Redesigning the B100 grid: 3 columns of square slots, each tile occupying one cell (square), two
stacked (tall) or two side by side (wide), packed by an algorithm that picks the next tile from those
whose allowed shapes fit the remaining slot.

**What the existing data actually was** (89 items): 79 have body text, 81 a featured image, 58 a
gallery (1–6), 68 a caption, 82 a number. 83 square + 6 wide, and **no tall tiles at all**.

**Caption solved.** It is not a mystery field — it's the label painted on the tile face, while
`post_title` is the popup heading and accessible name. 62 are identical to the title; **21 are blank
and 20 of those are `inline` tiles**, which already render the title as an `<h3>` above their text.
So rian's rule — *title over the media, unless text is showing, then at the top of the text* — is a
formalisation of what the content already does, not a change. **No body anywhere starts with a
heading** (checked all 89), so rendering the title above the text can't duplicate one.

⚠ **Correction worth keeping:** I first reported the 6 divergent pairs with the columns swapped. The
**title** is the fuller label ("Queen Alexandra Solarium"); the **caption** is the SHORTER tile label
("Solarium"). Those 6 were deliberately shortened for the tile face. Dropping caption therefore makes
those 6 tiles show *more* text, not less — titles were left untouched so it stays rian's call.
The dry-run caught this (0 replacements where 6 were predicted); it would have silently overwritten
6 titles otherwise. **Always dry-run a content migration.**

**Decisions taken:** number becomes `menu_order` (Post Types Order, drag to set) · shuffle stays
client-side so the page still caches · every tile opens the popup (removes the overflow-detection
problem and the inconsistent affordance) · mobile collapses everything to square · MP4 autoplays
muted in the tile, YouTube becomes an embed in the popup rather than 89 potential iframes in a grid.

**Step 1 done** — 9 new fields added to `group_bwm_hundred` ALONGSIDE the old ones (nothing switched
off): Text (wysiwyg, basic toolbar), Media type, Images, Video file/URL/poster, Tile orientation
(checkbox set), Tile content layout (checkbox set). A message field marks where the new model starts.

**Step 2 done** — migration applied after a field-level restore point
(`.snapshots/b100-pre-migration-20260726.json`, restores per field, not a whole DB). body → Text
(79, rendered through `do_blocks` then `wp_kses` down to simple formatting) · featured + gallery →
Images with **the featured image first** (81), since it is the current tile face · orientation
auto ×83 / wide ×6 · content auto ×58 / media_only ×10 / text_only ×21. **0 tiles are configured in a
way they can't render.** `menu_order` already equalled the badge number, so ordering needed no change
and **the 6 numbering gaps (11, 44, 59, 72, 78, 86) are preserved** — they only disappear if someone
drags in Post Types Order, which renumbers contiguously.

**Still to decide:** whether those 6 gaps matter · whether the 6 long titles want shortening for the
tile face · one item is literally called "Untitled item" and would render a blank label.

**Next (step 3):** the packer + renderer, then step 4 switches off editor/thumbnail/excerpt/Yoast and
retires the legacy fields. Two constraints for the packer, both found by hand-checking the geometry:
**a tall tile in the middle column splits the row below it** (A and C are left non-adjacent, so no
wide can ever go there), and **greedy first-free-cell placement can dead-end** — so choose the auto
tiles' shapes to make total area a multiple of 3, fill with randomised backtracking, retry on a
dead-end, and emit explicit grid coordinates rather than trusting CSS auto-placement.

### 2026-07-26 (later) — Rian + Claude — Guide fixes published via the hub · search fallback · site fixes

Acting on the fresh-eyes test pass (`notes/guide-test-findings.md`). **Everything guide-related is
published on the hub (`bw-plugins-dist`) and pulled down by the client — never edited on
brentwooddev.** The sync overwrites client content from the hub payload on every run, so a local
edit is invisible by the next sync; that invariant is now the documented workflow.

**Verification first — 4 of the report's conclusions were wrong.** Three were **false positives from
testing as an administrator**: the Yoast menu (`bw-roles.php` already strips it from anyone without
`wpseo_manage_options`), the `/my-profile` debug hint (already gated on `bw_profile_is_manager()`),
and by extension any other "leaks to end users" claim in that report — re-test those as a Blog
Manager. The fourth was mine: I told the tester WP 7.0's built-in **Duplicate** action made their
plugin recommendation unnecessary. It doesn't — `duplicatePost` belongs to the **DataViews** screens,
which need a block theme; Kadence is classic, so the Pages row actions are only Edit · Quick Edit ·
Move to Trash · Preview. Their finding stood. Corrected in the findings file.

**The Order blocker: right symptom, wrong cause, bigger scope.** Staff/Careers *do* support
`page-attributes`. WordPress 7.0 moved the control — the sidebar's "Page attributes" panel now renders
only the Parent row, and ordering lives in **Quick Edit** on the list screen (`WP_Posts_List_Table`
renders a `menu_order` input labelled "Order", gated on that same support). So the instruction was
wrong on *every* content type, not the two that were found. Both rewritten; **0 guides still say
"Page Attributes"**. Worth noting: my first rewrite sent people to a "⋮ → Order" action, which I then
checked on screen and found is the *preferences* menu. Verify before writing, every time.

**Published:** new guide **"Building a page from Brentwood sections"** (hub 299, `client:brentwood`,
9 keyword rules, 2 screenshots) — the one Monday's training needs · three missing everyday jobs added
as H2s on the guides that own them (start a page from an existing one → guide 24 via **Copy all
blocks**, since there's no Duplicate; upload/link a PDF → 72; video inside a post → 69) · menu-label
sweep (Add New Posting / Livestream / Item / Landing Page / Add User) · Preview → **View** in guide 24
· landing-page field-list correction.

**Guide 223 "Using Patterns" is `audience: all`** — it reaches promptvictoria too, so it can't be
rewritten around Brentwood's library. Its claim "Nothing is locked" is now *true here* (last night's
`disableContentOnlyForUnsyncedPatterns`) and *false on default WP 7.0*; rewritten to cover both, and
to warn that **Edit pattern** changes the saved pattern rather than the page.

**Search fallback** (`class-bw-guides-content.php`): the all-words rule returned an empty state for
any natural question — one unmatched word discarded every other signal, so the longer and more
natural the question, the more likely it failed. Added `closest_sections()`, which runs **only when
the strict pass finds nothing** and scores by proportion of matched words (majority required). Needed
a second matcher: `word_in()` is substring-based, which is safe when every word must match but lets
"at" match "th**at**"/"cre**at**e" when counting proportionally — `word_in_whole()` requires whole
words under 5 characters and ignores tokens under 3. Verified: *"how do I write a news story for the
school blog"* (the report's headline failure) now lands on "How do I write a new blog post?", short
queries score identically to before.

⚠ **The search fix is only deployed as a single file on brentwooddev staging** — the source is
updated in `bw-plugins`, but it needs a proper release to persist and to reach other clients.

**Site fixes (theme, not guides):** Timetable "How this works" rendered raw Markdown (ACF `message`
fields only run `wpautop`) → converted to `<strong>`/`<hr>` · Override-H1's allowed-HTML list rendered
as ", , , ." because ACF *interprets* instruction HTML → tag names escaped · the block inserter's
"Available to install" third-party plugins now hidden from anyone without `install_plugins`.

**Not done from the report:** Table Link's link-text location, the Hero Card greyed-out explanation,
the Users guide's password/notification step and 3 undocumented roles, the SEO one-clause caveat,
staff First/Last name step, the date-picker steps, the 150+ category list, the Meta Boxes drawer
explainer. All are text edits on the hub.

### 2026-07-26 — Rian + Claude — Gravity Forms styled to match the site

Forms were rendering in Gravity Forms 2.10's **orbital** theme, which is a self-contained design
system and ignores the site's: near-black labels (`#112337`), slate borders (`#686e77`), a **40px** gap
between every field row, and `font-family: initial` — so the form did **not** inherit Open Sans and
fell back to the browser default. That last one is why the type looked wrong rather than just dark.

New `assets/bw-gf-form.css` + `inc/bw-gf-style.php` (front end) — orbital is token-driven, so this
**re-points its CSS custom properties** on `.gform_wrapper.gform-theme--framework` instead of
overriding rules. Almost no `!important`, field types the form doesn't use yet inherit the same look,
and a GF update that restyles a control keeps working. Row gap 40→22px, label gap 12→8px, borders
`#d8dadd` (same as the review step), labels `--global-palette3`, sublabels/help `--global-palette4`,
fonts `inherit`, focus ring in brand red, textarea 220→132px. Colours reference Kadence palette
variables, so a Customizer palette change carries through.

Also added `bw-gf-form` to the handle list in `inc/bw-gf-editor.php` so the block-editor preview
matches the front end.

**Judgment call to review:** labels are `palette3` (#4b5563) — one step darker than body copy so they
still read as labels. If you want them to match body text exactly, change `--gf-ctrl-label-color-primary`
and the `.gfield_label` colour to `--global-palette4`.

### 2026-07-25 (overnight) — Claude — Pattern library rebuilt from the site's REAL sections

**The problem, restated.** The patterns built the day before were generic skeletons — a heading, some
text, a grey image box. Nothing in them looked like Brentwood, so every insert was followed by twenty
minutes of rebuilding, which is worse than starting from scratch. Rebuilding one real designed row took
rian 30 minutes. Two additional things made it worse, both of them mine: converted pages were locked
into content-only editing, and inserted patterns were locked too.

**What actually fixed it: stop authoring patterns, start extracting them.** Page **Template (23960)** is
a catalogue of the site's designed sections, and the live pages hold ~860 more. Every pattern is now a
copy of a real row — colours, curves, red rules, column widths, shadows, photos all intact — with only
the *wording* swapped for placeholders. That is the whole difference: a pattern that arrives looking
like the site needs text typed into it, not a design rebuilt into it.

**The library: 21 patterns in 3 categories.** `Brentwood — sections` (9), `— photos and links` (7),
`— basics` (5). Measured against the published pages: **85% of real sections** (637 of 750) match a
pattern's shape. The 110 "offset text" rows are excluded — that family is the leftover of the removed
red rule and is being converted to plain one-column rows, not reproduced.

**Three editor changes, each removing a lock rather than adding a feature:**

1. **Pages are unlocked again.** The conversion put two things on all 851 rows: `metadata.patternName`
   (which is what forces content-only editing and shows an "Edit pattern" button that opens the *shared
   pattern*, not the page) and `metadata.name` (a plain label). **Dropped the first, kept the second** —
   pages edit exactly as they did before, and List View still reads "Dark panel + wide photo" instead of
   "Row Layout". Verified by an inverse check on every page: re-add the tag, and the content must come
   back byte-identical. 851 rows, 180 pages, 0 skipped.
2. **Inserted patterns are unlocked** — `disableContentOnlyForUnsyncedPatterns`. A pattern is now a
   starting point you can change anything about. One flag flips it back to guard-railed if that turns
   out to be the better call for the client; both modes work, which is what the content-block work below
   is for.
3. **Content blocks: the list was far too short.** An audit of all 1,132 rows found the blocks that were
   inert inside a pattern — `kadence/image` (479 uses), `advancedbtn`/`singlebtn` (269), the three table
   blocks (269), `spacer`, `bw/course-table`, `bw/team-modal`, and `rowlayout` itself. **A photo-led
   pattern whose photo could not be swapped** was the sharpest edge of this. All declared now, from one
   shared list handed from PHP to JS so the two halves can't drift.

**"Swap sides" (new toolbar button, two-column rows).** 125 rows are photo-then-text and 71 are
text-then-photo — the same section facing the other way. That is a button, not two patterns. It also
mirrors `colLayout` (`right-golden` ⇄ `left-golden`) and the explicit percentages, because the *widths*
live on the row: reordering the columns alone would leave the photo in the narrow slot.

**The bug that made this session long — stripping `uniqueID` silently breaks Kadence rows.** The first
extraction removed `uniqueID` from every block, reasoning that two copies sharing one would fight over
the same generated stylesheet. But `kadence/rowlayout` renders **server-side** and builds its wrapper —
the element carrying the grid, padding and background — out of that very ID. Without it the wrapper is
never emitted, the columns fall out as loose divs, and a three-across row stacks into one full-width
column. It looks fine in the editor and breaks only on the front end. **Keeping the ID is also safe:**
Kadence re-mints duplicates on insert, and until something is edited two copies of one pattern generate
identical CSS anyway. Every pattern is now checked at build time — it must render with a
`kb-row-layout-wrap` wrapper or it isn't saved. 21/21 pass.

**Things kept out of the patterns on purpose:** a student's testimonial naming them and their
university, three boarding-house staff members' names on photo cards, a real "Learn more about Boarding"
link, and a Google Maps screenshot of an event venue that had become the default photo of the
most-used pattern. Patterns are shipped defaults — real people don't belong in them as filler.

**Removed:** the 5 hand-built skeletons, plus 3 that were only another pattern swapped or re-coloured
(one toolbar click each), plus "Full-width section" (Kadence's own Row Layout block already does it).

**Before pushing to dev:** 15 patterns hold **40 absolute `brentwooddev.demoing.info` URLs** (photo
`imageUrl` attributes). The usual search-replace on migration covers it — just don't skip it, or every
pattern's photo will point back at staging.

**Verified:** 21/21 render with a row wrapper · 0 page rows still locked · the synced Admissions Block
(38673) intact · no personal names left in any pattern · `disableContentOnlyForUnsyncedPatterns` true
in the editor settings · the three categories appear in the inserter · the photo inside a pattern shows
a **Replace** button. Working scripts were deleted from `wp-content/` (they were PHP in the web root).

**To look at first:** draft page **TEST — pattern library** (`/test-real-patterns`) renders all 21 on
one page. Suggested training plan in `notes/monday-training-outline.md`.

**Still open:** the Gravity Forms editor-gradient bug (cosmetic, editor-only). `photo+text` in a single
column (34 rows, 4.5%) has no pattern — build from "Text section" plus an image. Sidebar-content
conversions (`page-links`, `table-link` per-item URLs, `media-grid` header/body) still not started.

### 2026-07-25 — Rian + Claude — The content-block fix needed BOTH halves (server + client)

**Symptom rian hit:** inside a pattern the `+` only ever added a paragraph — no block options — even
after `kadence/column` was declared a content block. Adding other blocks still required "Edit pattern".

**Cause:** the fix was only half applied. `inc/bw-pattern-editing.php` declares `supports.contentRole`
through the PHP `register_block_type_args` filter, but **Kadence registers its blocks in JavaScript, and
the editor's `isContentBlock()` reads the CLIENT-side registration.** With only the server half, a column
inside a pattern still resolved to editing mode `disabled` — and WP core (block-editor.js ~line 12216)
allows only the DEFAULT block (a paragraph) to be inserted into a disabled container. Exactly the symptom.

**Fix:** added the client half — a `blocks.registerBlockType` filter in `assets/bw-pattern-editing.js`
setting `supports.contentRole` on `kadence/column` and `kadence/advancedheading`. The script's enqueue
moved to **priority 1**, because a `registerBlockType` filter only affects blocks registered *after* it
and Kadence registers from its own bundle (same reason `bw-redborder-editor.js` loads early).
**Verified in the editor:** typing `/list` inside a pattern now opens the block menu (List, Page List,
Categories, Departments…), and the columns appear as "Section" entries in the Content panel — both were
absent before. **Rule to remember: a block registered in JS needs BOTH halves; the PHP filter alone is
invisible to the editor's own decisions.**

**Still open (cosmetic, not started):** adding a Gravity Form in the editor makes the page-background
gradient stop short — the form renders below where the gradient ends. Ruled out: the injected form CSS
(`bw-gf-review.css` / `bw-gf-dots.css` set no page background) and the Customizer CSS (no gradient).
Working theory: the editor canvas background doesn't extend to full content height, and a tall form is
simply the first thing that exceeds it. Needs the actual selector confirmed from the editor DOM before a
targeted override.

### 2026-07-24 (later 10) — Rian + Claude — Toolbar controls collapse the pattern set to 7 · site converted

**Rian's insight drove this and it was right: expose the varying settings, don't multiply patterns.**
Colour was about to become a pattern dimension (white/blue/none × every layout). Instead it became a
toolbar control, and the set SHRANK from a projected 15 to **7** while coverage rose to **97%**.

**The rule, read from WP 7.0.2 source** (`blocks.js` / `block-editor.js`) — worth keeping:
```
isContentBlock(X)   = X has supports.contentRole OR any attribute role:"content"
canInsert(X into R) = R is the page root  OR  ( R is a content block AND X is a content block )
```
**`kadence/column` was not a content block — that is why nothing could be added inside a pattern.**
No button, no list, no extra paragraph. `kadence/advancedheading` had the same gap. Both are now declared
via `register_block_type_args` in **`inc/bw-pattern-editing.php`** (filter, not a plugin edit — survives
Kadence updates). Everything an editor would add (core paragraph/heading/list/buttons/image,
bw/card-gallery) already qualified.

**New toolbar controls** (`assets/bw-pattern-editing.js`, added via the `editor.BlockEdit` filter):
- **Row background** — a 5-swatch picker (None / White / Light blue / Light grey / Dark grey) setting
  `bgColor` + `bgColorClass` together. Leaves a custom hex alone rather than mislabelling it.
- **Card style** — one toggle for `bwShadow` + `bwRadius`, the site's raised-section look.
- **Curved edge** — toggles a column's `bwBulge` between `none` and `right` (direction stays in the
  sidebar; in practice the site always curves right).
Verified in a real pattern: the row toolbar shows the palette next to "Edit pattern".

**Pattern set now 7** (was 10): Text section · Text with wide image · Image beside text · Two columns ·
Three images across · Full-width image · Full-width section. Deleted **Offset text** (red-line legacy —
those rows should become full width, not be enshrined), **Highlight card** and **White card** (now just
a colour + card-style click). **Coverage: 1,155 / 1,192 rows = 97%.** The 37 misses are genuinely odd
column counts (4, 5, 6, 8, 9, 12 col).

**Site converted in place — 189 pages, 910 of 954 rows tagged (95%).** No duplicate pages: dev holds the
pre-conversion baseline, and `wp_update_post` (with `wp_slash`) was used so **178 pages have revision
history** for per-page rollback. Trial duplicates deleted.

**⚠️ Method note — the render-diff safety gate was WRONG and gave false positives.** Comparing rendered
HTML before/after flagged 14 innocent pages, because **rendering is non-deterministic**: the search block
emits incrementing ids (`bw-search-1`, then `bw-search-2`), so identical content renders differently
twice. Replaced with an **exact inverse check** — strip the added metadata back off, re-serialize, require
a byte-identical match to the original. Round-trip fidelity was confirmed lossless on all 222 pages
first, so nothing gets reformatted. Under the correct gate only **Home (859)** is skipped, because it has
pre-existing row metadata the tag would overwrite — left untouched deliberately.

**Rollback paths, in order:** per-page revision history · dev (pre-pattern baseline) ·
`.snapshots/brentwooddev-pre-patterns-20260724-2241.sql.gz`.

### 2026-07-24 (later 9) — Rian + Claude — Core pattern set (10) + a real coverage measurement

**Safety first:** DB snapshot at `.snapshots/brentwooddev-pre-patterns-20260724-2241.sql.gz` (30MB) and a
push to dev, so dev holds the pre-pattern baseline for side-by-side comparison.

**Pattern library completed to 10** (all unsynced, category "Brentwood sections"). Added the 5 the data
said were missing: **Full-width section** (the single most common row shape on the site — a correctly
spaced container to drop a Brentwood block into), **Full-width image**, **Offset text** (empty narrow
column + text in the wider one), **Image beside text**, **Three images across**. Built by extracting real
row attributes from live pages, so the Kadence markup is valid and the spacing matches. Verified: all 10
insert, parse to exactly one rowlayout each, and render correctly on the front end.

**⚠️ THE NUMBER THAT MATTERS — measured, not estimated: the 10 patterns cover 67% of rows site-wide**
(798/1192). The earlier "96%" was containers only and did NOT survive contact with reality. On the three
trial pages coverage was lower still (43%), because those pages skew to uncovered shapes.

**Coverage trial method** (`PATTERN — <title>` drafts, ids 38697–38699, kept for rian to compare):
duplicated 3 pages and tagged every row whose shape matches a pattern with the same
`metadata.name`/`patternName` WordPress writes on insert. **Rendered output before vs after is byte-identical
on all three** — proving the tagging approach is appearance-safe. Rebuilding by hand would produce the
same blocks anyway (an inserted pattern *becomes* ordinary blocks), so coverage — not appearance — was
the real question.

**The gaps are systematic and cheap to close.** Five shapes account for most of the miss:
`2col equal bg:white` (81 rows) · `1col equal bg:white` (59) · `2col left-golden bg:none` (42) ·
`3col equal bg:white` (36) · `2col left-golden bg:white` (28). **Adding those 5 → ~88% coverage (15
patterns).** Note **left-golden** (the mirror layout) has no pattern at all today. Custom-hex background
rows (~43) are colour one-offs and should stay bespoke, or be handled as "insert a pattern, then change
the background".

### 2026-07-24 (later 8) — Rian + Claude — Data-loss bug fixed + link controls made consistent

**FIXED: the `table-accordion` data-loss bug.** Switching "Layout style" to *Revealed panel* deleted every
child item — all labels and panel content — in one click, no confirmation, nothing to undo from. Now it
only clears items that are still the **untouched seed** (no label AND no panel content); anything with
work in it is left alone (the revealed layout simply doesn't render items, and switching back restores
them). `blocks/table-accordion/editor.js`.

**Link destinations now reachable from the toolbar** (they were sidebar-only, so unreachable inside a
pattern): added the established dropdown convention — `admin-links` ToolbarButton → URL + "open in new
tab" — to **`card-gallery`** and **`hero-card`** (whose dropdown also carries the CTA show-toggle and
button text, since enabling a CTA without text renders nothing). Matches `interlinking`, `video-text`,
`library-search` and the accordion items. Six blocks now share one link idiom.

**`table-link` deliberately NOT converted:** its links are per-item in a repeater and a block-level
toolbar can't target an individual item. Needs either inline-editable pill labels or a per-item UI —
same decision as `page-links`, parked with the other sidebar-content work.

**Verified:** all three edited files pass `node --check`, the inserter still lists every Brentwood block,
front end 200.

### 2026-07-24 (later 7) — Rian + Claude — Fleet-wide block sweep for pattern editing (24 blocks)

**Swept every custom block so it behaves properly inside a pattern** (see the previous entry for the
mechanism). Four parallel agents, one shared brief (`scratchpad/BLOCK-TOOLBAR-BRIEF.md`).
**Result: 23 of 28 custom blocks now expose content in pattern mode** (was 1). The 5 still hidden are
hidden *by design* — `course-table`, `hundred-grid`, `team-modal`, `timetable`, `curve` build themselves
from a CPT or are pure decoration, so there is nothing per-instance to edit.

**Toolbars added where they earn it** — image add/replace (card-gallery idiom), show/hide toggles for
visible sub-elements, and link popovers. Deliberately NOT promoted anywhere: colours, padding, ratios,
fonts, column counts, query settings. Keeping styling sidebar-only is what stops patterns drifting.

**A third mechanism, discovered mid-sweep and verified against WP 7.0.2's own `isContentBlock()`:**
`"supports": { "contentRole": true }` in block.json. It is how core's `core/accordion` marks a
**container** whose content lives in child blocks rather than attributes. Without it a container is
dropped from List View AND `isContainerInsertableToInContentOnlyMode()` blocks adding/removing child
items inside a pattern. Applied to `faq-accordion/text`, `table-accordion`, and `calendar` (whose
InnerBlocks note was otherwise unreachable). **Use this for containers; use `role: content` for
attributes.**

**Child blocks matter too** — `faq-item` and `accordion-item` needed their own roles, else the items
inside an accordion are invisible in a pattern. Both also got a toolbar **link popover** (URL +
open-in-new-tab), which sets the convention for reaching a link destination when the sidebar is hidden.

**Two real bugs found by the sweep (one fixed, one NOT):**
1. **FIXED** — `media-grid`'s editor script was registered without `wp-components` while using
   `ToolbarGroup`/`ToolbarButton`; it worked only because `wp-block-editor` pulls it in transitively.
   Made explicit in `inc/brentwood-blocks.php`.
2. **NOT FIXED — data-loss footgun, needs rian's call.** `table-accordion/editor.js` (~lines 93–101):
   switching "Layout style" to *Revealed panel* runs a `useEffect` that **permanently deletes every
   child item** — all labels and panel content — in one click, no confirmation. Deliberate and
   commented, but silent and unrecoverable.

**Also fixed (rian spotted it):** `card-gallery` rendered a **stray chevron with no label** when the
label was off/empty — the chevron is an affordance *on* the label. Fixed in `render.php` and mirrored in
the editor preview. Its toolbar also moved from text buttons to dashicon + tooltip.

**Known limitation, unresolved by design:** several blocks keep genuinely editable *content* in sidebar
text fields (`blog`/`youtube-list` heading, `careers-grid` button label, `search` placeholder,
`page-links` entirely, `media-grid` header/body). Marking them content makes the block selectable, but
reaching the value still needs the sidebar. The durable fix is converting those `TextControl`s to canvas
`RichText` — a separate, larger piece of work.

**Verified:** every `editor.js` parses (`node --check`), every `block.json` is valid JSON, PHP lints,
the registry reports the expected roles, the block inserter still lists all Brentwood blocks, and the
front end returns 200. Additive only — no `save()`, render output, default, attribute name or type
changed anywhere.

### 2026-07-24 (later 6) — Rian + Claude — Making blocks editable INSIDE patterns (two-part fix)

**Problem rian hit:** in an inserted pattern he could edit the heading and paragraph inline, but changing
the image forced him into "Edit pattern" mode (which edits the source pattern, not the page).

**The rule — this is the thing to remember.** WordPress puts an inserted pattern into **content-only
editing**. In that mode it shows *only* blocks that declare content, and it **hides the Settings sidebar**
for them — **the block toolbar is the only surviving control surface**. A block declares content by
marking attributes `"role": "content"` in `block.json` (that is exactly why `core/heading` and
`core/paragraph` worked and ours didn't — verified against core's own block.json files).

**Fix, part 1 — visibility (`blocks/card-gallery/block.json`):** marked 8 attributes `role: content`
(`imageUrl, imageId, imageAlt, images, number, label, caption, linkUrl`). The block now appears in List
View inside a pattern and its label/caption/number are editable inline. No attribute added, renamed or
removed → existing pages unaffected.

**Fix, part 2 — controls (`blocks/card-gallery/editor.js`):** every control on this block lived in
`InspectorControls`, which content-only mode hides — so the image still couldn't be changed. Added a
**block toolbar** (`BlockControls`): an **Add image / Replace image** button (MediaUpload, same pattern
already used in the sidebar) plus **Number / Label / Caption** toggle buttons, each shown only for the
layouts that use them. Verified in the real scenario: with the sidebar showing "Pattern · Edit pattern",
the selected card's toolbar offers Add image + Number + Label. Purely additive — works the same outside
patterns.

**Still deliberately sidebar-only:** colours, sizing, ratio, behaviour. Those are *styling*, and keeping
them out of content-only mode is what stops patterns drifting apart visually.

**⚠️ The same gap exists in all ~24 other custom blocks** — none declare content roles, and most put
their controls in the sidebar only. Any of them dropped inside a pattern will behave the way this one
did. Sweeping them is offered, not yet done.

### 2026-07-24 (later 5) — Rian + Claude — Patterns moved to editable (unsynced) + a deletion incident

**⚠️ INCIDENT + FIX: deleting two "unused" reusable blocks silently broke Home and Inquiry.** Rian
deleted the two duplicate `wp_block` posts titled "Admissions Block" (31490/31491) as cleanup. They were
**SYNCED** (reusable) blocks — pages don't contain that content, they contain a *live pointer* to it.
Both **Home (859)** and **Inquiry** referenced 31491, so the section vanished from both with **no warning,
no error, and nothing in the trash** (permanently deleted, not trashed). Caught by a proactive check
after the deletion, not by any site-facing symptom.
**Restored:** dev.brentwood.ca still held the pre-deletion DB (pushed earlier that day), so the block's
content was pulled back over SSH (read-only), recreated on staging as **38673**, and every
`{"ref":31491}` repointed to it via `$wpdb->update` (column-only — `wp_update_post` would unslash and
corrupt the block JSON). Verified: zero broken refs remain and Home renders the section again.
**Lesson, now written into `inc/bw-patterns.php`:** SYNCED = live link (deleting breaks every page using
it); UNSYNCED = independent copy on insert (safe). Never assume a reusable block is unused — grep
`post_content` for `wp:block {"ref":<id>}` first.

**Patterns are now DATABASE patterns, editable in wp-admin — the file-based ones are gone.** Rian's
reasoning was right and better than the original build: the whole point of patterns is that whoever
builds pages can add one whenever a new standard row appears. If patterns live in theme files, every
addition needs a developer and that loop dies. Coded patterns earn their keep for something shipped to
many sites; `kadence-child` is Brentwood-only and the deploy copies both files and DB, so they bought
nothing here.
- The five are now `wp_block` posts, **unsynced**, in a **"Brentwood sections"** `wp_pattern_category`
  (ids 38674–38678). Edit them like any post; inserting one drops an independent copy.
- `patterns/*.php` and the auto-registration were **deleted**; `inc/bw-patterns.php` now only (a) removes
  WordPress's generic core/remote patterns so everything in the Patterns tab is safe to use, and (b) adds
  the **Appearance → Patterns** menu item, which WordPress omits (`wp_block` is registered with
  `show_in_menu = false`, so the screen exists but is linked from nowhere).
- Verified: `code-registered patterns: 0 (core 0, theme-file 0)`, `editable DB patterns: 6 (unsynced 5)`,
  inserter shows the "Brentwood sections" category, admin list shows all five with their category.

**Next:** rian explores/reworks these five, then the remaining five to reach 96% coverage, then the docs
split (patterns → Brentwood guides; copy-an-existing-row → BW Guides standard library).

### 2026-07-24 (later 4) — Rian + Claude — Section PATTERNS (the real page-building gap)

**Rian tried to build a simple thank-you page and found it genuinely hard — he was right, and it was a
real gap.** Diagnosis, with evidence:
- **Zero Brentwood patterns existed.** The 11 registered were all WordPress core defaults (`source=core`),
  which look nothing like this site. Only stray artefacts otherwise: "Admissions Block" saved twice as a
  reusable block — someone reaching for this instinct and abandoning it.
- A new page seeds **one empty hero card** and nothing else. Below that you hand-build Kadence rows.
- Real rows carry **11–22 attributes each** (column ratio, gutters, radius, background, shadow, padding).
  Reproducing that consistently is expert work — a plain paragraph just sits on the page background.

**But the layouts were never the problem — they weren't reusable.** Analysis of the whole site:
**1,192 rows across 214 pages resolve to only 27 distinct shapes, and the top 10 cover 96%.**

**Built (step 1 of 2 — rian reviews before the remaining 5):** `inc/bw-patterns.php` + `patterns/*.php`.
WordPress auto-registers any file in a theme's `patterns/` dir from its header, so adding one later is
just adding a file. The five most-used shapes, lifted from real pages with the copy replaced by
placeholders: **Text section · Text with wide image · White card — text with image · Highlight card
(light blue) · Two columns**, all under a **"Brentwood sections"** category.
**Core patterns are switched off** (`remove_theme_support('core-block-patterns')` +
`should_load_remote_block_patterns` false) so everything in the Patterns tab is safe to use —
verified: `total registered: 5, core-bundled: 0`.

Technique worth reusing: patterns were **extracted from real rows**, not hand-written, so the Kadence
markup is guaranteed valid. Image slots use `bw/card-gallery` with no image (self-closing + dynamic →
shows an upload placeholder, ships no stale photo). uniqueIDs are kept — Kadence's `isUniqueID` check
regenerates them on insert. Verified end to end: inserted all five into a draft page, **no
"unexpected or invalid content" warnings**, and the front end renders the white card with its shadow
and rounding correctly. Test page deleted afterwards.

**GOTCHA (documented in the file):** the theme's pattern list is **cached** —
`WP_Theme::get_block_patterns()`. A newly added pattern file will NOT appear until you run
`$t=wp_get_theme(); $t->delete_pattern_cache(); wp_clean_themes_cache();`. Cost 20 minutes of "why is
this registering as zero". Editing an existing pattern file is picked up immediately.

**Still to do:** rian reviews these 5 → then the remaining 5 to reach 96% coverage → then document.
Documentation splits two ways (rian's call): the **patterns themselves** belong in the Brentwood guides
(like the blocks guides); the **copy-an-existing-row trick** is a general technique that belongs in the
BW Guides standard library, since it works on any site with no patterns at all.

### 2026-07-24 (later 3) — Rian + Claude — 6 screenshots were captured but never displayed

**Found via a mislabel.** The platform session described guide 00 as "intentionally text-only"; the name
didn't match the guide known to be text-only (19, the blocks index), so I checked. Guide 00 wasn't
intentional — it was a defect, and a systematic one: **4 guides declared shots in their `_frag` file but
carried no inline `BWG-SHOT` marker in the guide body**, so 6 images were captured, verified, imported —
and never shown to a reader:
`00` dashboard-menu + block-inserter · `08` page-settings-hero-type + page-settings-focus ·
`13` **user-role-column** (the PII-redacted users list) · `15` **my-profile-view** (the sample-record profile).
All four were guides I hand-wrote — the subagent-written ones were correct, because their brief said to
inline the marker. I wrote fragments and bodies as separate steps and dropped the link. All six markers
now inserted; a dangling marker for the never-captured `signin-wrong-domain` removed from guide 14.

**Invariant now machine-checked** (was my manual check, now a tool the platform session built + negative-
tested): `tools/bw-guide-shot/check-shots.py <project>/docs/guides-draft` — exit 0/1, gates an import.
Verifies: every declared shot has exactly one inline marker · no duplicate markers · every marker has a
PNG · **no PNG without a marker** (the silent direction that hid these 6) · no marker for a shot flagged
NOT CAPTURED. Run it after ANY guide edit. Current: `guides=20 declared=61 (not-captured=1) markers=60
pngs=60 — OK`. The `⚠️ NOT CAPTURED` convention in the fragments is what makes this possible: it
distinguishes "deliberately absent, reason recorded" from "someone forgot".

**Final library state:** brentwooddev **28 guides / 81 images / 0 without images**, 0 orphaned keyword
rows, 0 slug mismatches, all 28 hub-sourced. Drafts: 20 guides, 60 markers, 60 PNGs, perfect 1:1.

**Still rian's calls:** (1) upgrade the `guide-shots` fixture 6.8.3 → 7.0.2 and re-shoot the standard-guide
screenshots (platform session executes); (2) release bw-guides client 0.3.3; (3) push the red block icons
to dev; (4) run the guide test session (`notes/guide-test-plan.md`).

### 2026-07-24 (later 2) — Rian + Claude — Screenshot crops fixed (real tool bug) · 2 findings

**Rian flagged strangely-cropped guide images. Root cause was a genuine bug, not framing.** Two parts:
1. *Mine:* the corrected shots from the previous pass were parked in a `shots-fixed/` sibling and never
   reached canonical `shots/`, so the importer kept re-importing the originals. Corrections now go
   straight into `shots/`; staging dirs deleted.
2. *The tool's:* `--selector`/`--region` element capture **races layout shifts**. The bbox is measured,
   ACF metaboxes finish loading and move the page, and the pixels are grabbed from stale coordinates —
   while `capturedElement` truthfully reports `x:1320, w:280, visible` and `warnings:null`, because the
   measurement WAS right when taken. Produced crops straddling canvas+sidebar, or pure canvas.
   **This is why "verify by diagnostics" failed: the check validates the measurement, not the pixels.**

**Fix — deterministic capture:** full viewport (no selector), then ImageMagick crop. Sidebar is always
the right-hand 280px: `-crop 280x640+1320+97` on 1600x1200. Race-free by construction. **Coordinates are
per-site** (the fixture's sidebar starts at y=61, not 97) — measure once, then clip. All 14 inspector/
sidebar shots re-captured this way and **verified by opening every PNG**, not by trusting JSON.

**Also fixed the shape** (rian's other point): even correct captures were 280x1078 (1:3.85) — a spindly
strip. Now 280x640 for block inspectors; taxonomy panels get their own measured rectangles (a fixed 640
cap cut off Departments/Categories, which sit at y≈700+). Rule adopted: *a screenshot orients, it
doesn't inventory.* Side effects: `blog-status-date` is now its own crop (was a duplicate of
`blog-categories`); course/livestream taxonomy captions corrected (those panels render CLOSED);
`livestream-categories` **dropped** — closed panel + whitespace, nothing actionable.

**Finding 1 — a wrong selector rule was about to ship.** The standard-guides session reported
`.editor-block-list-item-<slug>` "does not exist in WP 7.0.2" and was replacing it with positional
matching. It DOES exist — generated in `wp-includes/js/dist/blocks.min.js` by
`getBlockMenuDefaultClassName`: `"editor-block-list-item-" + name.replace(/\//,"-").replace(/^core-/,"")`.
Two traps explain the false negative: the `^core-` strip (so `core/paragraph` → `…-paragraph`, NOT
`…-core-paragraph`) and the class living in `blocks.min.js`, not `block-editor.min.js`. Decisive
evidence was empirical: 14/14 captures used those classes and each produced the intended block — a
`click:` on an unresolvable selector hard-fails. README reverted to exact-class primary.

**Finding 2 — ⚠️ THE SCREENSHOT FIXTURE IS A VERSION BEHIND THE FLEET.** Verified directly:
`guide-shots` = **WP 6.8.3**, while `brentwooddev` / `bw-plugins-dist` / `template` = **7.0.2**. The
standard guides are shot on that fixture and ship to every client — **all 28 guides on brentwooddev come
from the hub**, so Brentwood staff would be shown an older admin UI than the one in front of them. Our
own Brentwood guides are unaffected (shot on brentwooddev itself). **Open decision for rian:** upgrade
the fixture and re-shoot the standard-guide screenshots. Owned by the BW Guides platform session.

### 2026-07-24 (later) — Rian + Claude — Google auto-provisioning removed · guide 20 · test brief

**🔒 SECURITY — Google sign-in no longer auto-creates accounts** (rian's call, after the behaviour was
found while writing guide 14). `inc/bw-google-login.php` previously did find-**or-create**: any verified
`@brentwood.ca` Google account that signed in got a fresh `bw_staff` WP user. Since the school issues a
Workspace account to **every student and every new hire**, that silently equated "has a school email"
with "has a login here". Now: **find-only** — no match, no sign-in. New failure reason `nouser` →
*"That Brentwood account does not have access to this website yet. Ask your Marketing team to set one up
for you."* (deliberately identical whether or not an account exists, so it doesn't confirm addresses).
`wp_insert_user` is gone from the file; the domain allowlist, CSRF state, and server-side ID-token
validation are unchanged, and no role is ever modified. Linted, front end + `/wp-login.php` both 200.
Guide 14 updated to match the new message. **Access is now granted deliberately: Marketing creates the
account (Users → Add New), Google then authenticates it.** *(Audit: 148 `bw_staff` users, 151 profiles,
1 unlinked — `raymond.pacuribot`, created during our own bulk provisioning, not a Google auto-create.)*
**Inquiry-form notifications: rian is setting those up himself — leave alone.**

**Guide 20 added — `19-blocks-at-a-glance.html` ("Brentwood blocks at a glance").** An index of all 25
custom blocks grouped by purpose (people · auto-populating · images & video · text & layout · search),
one line each plus which guide explains it, so staff can start from "what I want to build" rather than
needing the block's name. Includes the two things people hunt for and can't find: **Media Grid** (exists
in the inserter but is migration-only, nothing to configure) and **heroes** (not a block at all — they
come from Page Settings). Ties into the red-icon work: "ours are the red ones".

**Test brief for a fresh session: `notes/guide-test-plan.md`.** 46 tasks across five groups (Marketing
everyday · page building · structure & maintenance · people & access · deliberate gap-hunting), with
rules of engagement (search first, follow the guide literally, don't read the code), safety rules
(brentwooddev only, `TEST — ` prefix, never submit the live forms, delete test content), a findings
format, and a list of known-subtle things to probe. Purpose: find inaccuracies/gaps/unclear spots with
fresh eyes.

**Gotcha for future edits: `SHOTS.md` and `KEYWORDS.md` are GENERATED** from `_frag/*.{shots,keywords}.md`
by `scratchpad/assemble_companions.py`. Editing them directly is silently lost on the next assembly —
happened once here. Edit the fragments.

### 2026-07-24 — Rian + Claude — Guide screenshots captured (59/61) + search-landing cross-refs

**Screenshots.** The BW Guides capture tool gained interactions, so the 61 pending `BWG-SHOT` markers
were captured against brentwooddev via `/srv/apps/bw-plugins/tools/bw-guide-shot.sh` (MITM-fulfill,
host-only, gate never touched, short-lived minted cookie). Output: `docs/guides-draft/shots/*.png`
(59 files, 8.9 MB), named by marker `id`. Recipes that worked, for whoever repeats this:
- plain screens → `--full-page --trim`; list tables → `--region list-table`
- ACF boxes → `--selector '#acf-<group_key>'` (e.g. `#acf-group_bwm_page_hero`); single fields →
  `.acf-field[data-name="hero_type"]`; the custom Sub Nav box is `#bw-subnav`
- **block settings** → insert via the inserter, then open Settings, then crop `--region block-inspector`:
  `click:.editor-document-tools__inserter-toggle; waitfor:.block-editor-inserter__search input;
   type:.block-editor-inserter__search input|<Block Name>; wait:1500;
   click:.editor-block-list-item-<slug>; wait:1500; click:button[aria-label='Settings']; wait:1200;
   waitfor:.interface-complementary-area`  (query-heavy blocks need wait 2000/4000/2500)
- taxonomy panels / status+date → open the sidebar, crop `--region settings-sidebar`

**Privacy (per rian's policy update).** Two screens hold real personal data. Handled by the
escalation ladder, not by discarding: **`my-profile-*`** was shot from a **sample draft staff record**
("Sample Person", created then deleted — id 38576); **`user-role-column`** and **`forms-entries`** were
re-shot as tight viewports and **redacted** with `redact.sh` (solid boxes over name/email/secondary-name
columns; role counts, bulk "Change role to…", star/unread filters all still visible). Every image was
viewed before being kept.

**Not captured (2) — documented in `SHOTS.md` with the reason:** `signin-google-button` and
`signin-wrong-domain`. The capture tool always mints a session cookie, so `/wp-login.php` redirects to
the Dashboard (verified). Capturing a logged-out screen needs a no-cookie mode; the second also needs a
real rejected Google round-trip, which the tool aborts by design. Guide 14 covers both in words.

**Search-landing cross-references (rian's UX feedback).** Sections were reading as if the guide had been
read top-to-bottom, but people arrive mid-guide from search. Added short orienting lines + real
in-guide anchor links (`#bw-` + slugified heading, matching `BW_Guides_Content::slugify`), e.g. the video
hero section now says to set **Hero type** in **Page Settings** first and links to that section. Also
removed every "(see above)" back-reference (12-forms ×2, 17-site-menus) in favour of named links. All 6
anchors verified to resolve against generated slugs; all 19 guides still pass the block validator.

**⚠️ Correction pass — 14 shots were silently wrong.** Every shot cropped to a settings sidebar or
block inspector had straddled the editor canvas instead of landing on the sidebar, while still
reporting `selectorFound: true` — nothing in the logs flagged it; only opening the PNGs revealed it.
Cause: Gutenberg persists sidebar visibility in **user meta**, so one capture that toggles it shut
silently breaks every later inspector capture, for every caller, indefinitely. All 14 re-captured with
the tool's new `--reset-ui` (pins the sidebar open) into `docs/guides-draft/shots-fixed/`, each verified
via the tool's new `capturedElement` diagnostics (x=1320, w=280, visible, sensible textPreview) plus
visual checks. **Recipe correction: with `--reset-ui` you must DROP the `click:button[aria-label='Settings']`
step** — with the sidebar already open that click closes it again.

Two durable findings from that pass, now in the tool's README + shipped as warnings:
- **Straddle detector:** on a 1600px viewport the sidebar is always `x=1320`; any inspector capture where
  `x + width != viewport width` straddled the canvas. Now emits `CAPTURE_WARNING`.
- **Server-rendered blocks can't be selected by clicking.** Opening the Unique Timetable page (1047) and
  clicking `[data-type='bw/timetable']` never selects it (inspector stays "No block selected"). **Insert
  the block on a new page instead — insertion auto-selects it**, which is why the other 9 block shots worked.
- Audit heuristic: old-vs-new pixel diff of 11–20% (or changed dimensions) = a straddled crop; <7% = two
  correct captures differing only on transient text.

**`signin-google-button` captured** via the tool's new `--anonymous` mode (no cookie minted, so
`/wp-login.php` renders instead of redirecting to the Dashboard). `signin-wrong-domain` stays uncaptured
by agreement — it needs a genuinely rejected Google round-trip; guide 14 covers it in prose using the
real error string from `bw-google-login.php`. **60 of 61 shots.**

**Drafts are the source of truth** — the hub rebuilds guides from `docs/guides-draft/`, so a hub-side
content edit gets silently reverted on the next rebuild. Change content in the draft first. (Learned when
a hub-side rename of guide 00's first H2 to "Logging in for the first time" removed the words "sign in"
from the only place search could find them; the words now live in that section's hidden keywords.)

**Final state (guides).** 19 guides, 60/61 shots, all imported and live on brentwooddev's Guides screen
(26 guides incl. the standard library, 59 images, 0 orphaned keyword rows, 0 slug mismatches, 9 categories).
Two durable additions to the drafts on the last pass:
- **Capture recipes encoded into the markers.** All 9 block-inspector `BWG-SHOT` markers now carry
  `actions="…"` (the inserter script + the 2000/4000ms waits). Re-shooting them no longer requires
  rediscovering the timing — this is the BWG-SHOT contract working as designed.
- **Cold-landing pass finished.** People arrive mid-guide from search, so every cross-section
  "(see above)"/"(see below)" is now a real anchor link (`#bw-` + slugified heading): **15 links, all
  verified to resolve**. *Spatial* references ("below the description", "further down the same tab") were
  deliberately LEFT — they describe the screen in front of the reader; linking them would send someone
  to another section to learn where a field is.

**Standing checks for any future guide edit** (all three run clean today):
`scratchpad/validate_guides.py` (core-blocks markup) · anchor-resolution check · **keyword-drift check**
(every H2 has a keyword row and vice versa — this is what caught a heading rename that had silently
removed the words "sign in" from the only place search could find them).

**Division of labour with the BW Guides platform session:** the 5 generic standard guides + their
fixture screenshots are theirs (their product surface, shot from `guide-shots`, no Brentwood context
transfers); the Brentwood client set is ours. **Optional, not started:** an "Our blocks at a glance"
index — one short guide listing the 25 Brentwood blocks with a line each, linking into guides 10/11.
Awaiting rian's call.

**Also this session:** the custom Brentwood block icons now render brand-red (#c8272c) in the editor —
`inc/bw-block-icons.php` + `assets/bw-block-icons.{js,css}`, a central `blocks.registerBlockType` filter
over the `bw/*` + `brentwood/*` namespaces (no per-block edits). Visible in the `blocks-inserter` shot.
STAGING ONLY — not yet pushed to dev.

### 2026-07-23 (later 4) — Rian + Claude — Client documentation prep (BW Guides) — COMPLETE (drafts)

**What & why:** Rian is building a **BW Guides** plugin + `write-guides` skill in a parallel session
(overnight). This session is prepping the Brentwood **client documentation** ahead of it — publish-ready
DRAFT guides covering every custom feature we built, so the client can self-serve. **Nothing is published**
(the pipeline isn't ready yet); a second prompt will come to finalize/hand off. Read the house standard from
`/srv/apps/bw-plugins/wp-content/plugins/bw-guides/docs/{SPEC,PLATFORM-ROADMAP}.md` + `CLAUDE.md` +
`assets/css/bw-guides-admin.css` + the hub authoring patterns.

**Where the work lives:** `/srv/apps/brentwooddev/docs/guides-draft/`
- `INVENTORY.md` — coverage map: every client-facing feature → which guide (+ a "no guide / internal" list).
- `guides/NN-slug.html` — one draft guide per feature-area (19 planned, 00–18).
- `_frag/NN-slug.{shots,keywords}.md` — per-guide screenshot manifest + hidden search keywords, to be
  assembled into top-level `SHOTS.md` + `KEYWORDS.md`.

**House standard (locked):** guides render INSIDE wp-admin via `do_blocks()` with no theme → **core WP
blocks ONLY** + the design kit (`bw-callout`/`-warning`/`-success`/`-danger`, `bw-callout-label`, `bw-steps`).
One guide per feature-area, one task per H2, every H2 phrased as the question a staff member would ask; each
H2 becomes a search anchor (hence KEYWORDS). No hardcoded site URLs in content. Screenshots are only *marked*
(`<!-- BWG-SHOT … -->`), not taken.

**Approach:** wrote INVENTORY + a hand-crafted exemplar (`01-staff-profiles`) + the shared subagent brief
(`scratchpad/SUBAGENT-BRIEF.md`), then fanned out **14 subagents** (one per remaining guide, each verifying
exact field labels from `acf-json/` + block behaviour from `blocks/*/render.php`). Hand-wrote the
cross-cutting/security ones myself: `00-getting-started`, `08-page-settings`, `13-users-roles`,
`15-your-staff-profile`. A structural validator (`scratchpad/validate_guides.py`) checks every guide for
balanced/allowed block markup — all clean so far.

**Feature surface documented:** 6 CPTs (Staff, Courses, Livestreams, Brentwood 100, Careers, Landing Pages),
Page Settings (hero/title/footer), Blog posts, the Timetable options page, 2 block-library guides (~17 custom
blocks), Gravity Forms, Users & Roles (Editor/Site Editor/Blog Manager/Author/Staff), Signing in (Google),
teacher self-profile (`/my-profile`), Page SEO (Yoast per-page), Site menus (Kadence Elements), Redirects.

**STATUS — COMPLETE (all 19 guides drafted + validated).** The parallel BW Guides platform session shipped
the pipeline + a user-global `write-guides` skill (`~/.claude/skills/write-guides/SKILL.md` — the official
house standard; supersedes the ad-hoc brief, and these drafts conform to it). That session is **publishing**
the drafts to the hub (`bw-guides` client plugin 0.3.x syncing on brentwooddev) + converting `_frag/` → post
meta + capturing `BWG-SHOT` screenshots; **this session only wrote draft files** (never published, never
touched the plugin/hub). Deliverables in `docs/guides-draft/`: `INVENTORY.md`, guides `00–18` (19),
`SHOTS.md` (61 shots), `KEYWORDS.md` (120 heading-keyword lines), `_frag/` per-guide fragments. All 19 pass
the block-markup validator (`scratchpad/validate_guides.py`); each guide was verified against live code/admin
by its author, not memory. **One block intentionally undocumented:** Brentwood Media Grid (migration-generated,
no client editor UI).

**Two operational findings surfaced during verification — FOR RIAN, not acted on, both on brentwooddev:**
1. **Sign-in auto-provisioning.** `inc/bw-google-login.php` (`bw_goog_callback`) **auto-creates a bare
   `bw_staff` account for ANY `@brentwood.ca` Google sign-in** — it does NOT reject unknown accounts, contra
   the documented "matches existing users, no auto-create". Low-risk (auto-created accounts are profile-only
   with no linked profile → can do nothing), but a policy discrepancy to decide on before launch.
2. **Admissions form sends no email.** Gravity Forms **"Admissions Inquiries (migrated)" (id 1) has zero
   notifications configured** — submissions aren't emailed to anyone. Almost certainly dropped in the WPE
   migration; re-add a notification before launch or inquiries vanish silently.

NOT for dev push — these are internal prep docs under `docs/`, never rsynced to the site.

### 2026-07-23 (later 3) — Rian + Claude — Pushed the day's work to dev + finishing blog-hero images

**✅ PUSHED TO DEV** (`push-brentwood-dev`) at Rian's request ("push this to dev for tonight"). This
carries **everything staged today** onto https://dev.brentwood.ca (still behind the access gate → 401
externally is correct/healthy). So the two entries below that say "⚠️ STAGING ONLY" are now **live on
dev** as of this push: the roles/permissions model, 151 staff accounts, Google login config, page/CPT
authorship reassignment (Erin + Jim Ganley for blogs), Author-scoped-to-blogs, careers CPT, blog layout
fixes, footer/hero polish. Push was a `mysqldump --single-transaction` (294M, consistent snapshot) so it
was safe despite other work in flight.

**Background image job status (from the earlier "send out another session" dispatch):**
- **Redirects: DONE** — 1001 redirect rows in place.
- **Blog-hero retina upgrade: was STALLED at ~36%** — that background agent had ended (no task, no
  process running) with only **2040 / 3132** targeted images metadata-regenerated. Diagnosed and found
  the risky half was already complete: the **retina source-swap is 100% done** (all 3132 originals
  backed up in `migration/blog-hero-retina-backup/originals/`; every remaining image already has its
  2304px source on disk). The only remainder was pure metadata regen for **1092** images.
- **✅ DONE + re-pushed.** Built `wp-content/_imp_hero/chunk_remaining` (the 1092 ids whose metadata
  width was still ≤1152) and ran `regen_hero.php` over it as a detached `docker exec` process →
  `CHUNK ok=1092 fail=0 maxw=2304`. **All 3132/3132 targeted blog-hero images now upgraded (0 remaining).**
  Then re-pushed to dev (2nd push tonight) so dev has the finished images. *(regen only rebuilds WP
  sub-size metadata from the already-present retina source — no source-swap, no block-content edits,
  zero corruption risk.)* dev.brentwood.ca returns 401 externally = gate healthy.

**Still deferred to tomorrow (the one open item from the hero cross-check):** the **3-gallery blog-hero
fix** — posts **38421** (Magical/Unicorn, 3 photos), **38443** (Crew of '72, 3), **38450** (Still in the
Game, 2) should be `hero_type=gallery` with the extra photos imported as `hero_gallery_rows` repeater
rows (row 1 = existing featured image; sub-field `image` = attach id, key `field_bwm_hero_gal_image`);
also clear the stray `hero_type` on **38426** (Lifers' Dinner, text-first). Left for a careful daytime
pass rather than rushing it late — cosmetic, 3 posts.

**bw-lead-ai:** plugin installed + active (v1.0.1, confirmed latest), settings at Settings → BW Lead AI,
**not yet configured** — Rian is doing the tracking config tomorrow.

### 2026-07-23 (later) — Rian + Claude — User roles & permissions model built + tested end-to-end

**⚠️ STAGING ONLY — not pushed to dev.** Builds on the "Google login CONFIGURED" kickoff below (which
also covers the 151 staff accounts + admin cleanup). Rian tested every role live via `rian@rian.ca`.

**The role model** (all custom code in `inc/bw-roles.php`, versioned by `BW_ROLES_VERSION`):

| Role | Who | What they get |
|---|---|---|
| Administrator | IT/dev; **support** (backup) | everything |
| Editor | **Erin Coulson (78)**, **Tracy McAllister (157)** | all content + the 6 CPTs + Kadence Elements + Gravity Forms + Redirection + **user management** — "admin without the clutter" |
| **bw_site_editor** ("Site Editor") | delegated staff | create + publish ANY type, but only content they **author** (Erin delegates a page by setting its author) |
| **bw_blog_manager** ("Blog Manager") | **Jim Ganley (32)** | ALL blog posts incl. others', categories, comments; no pages/CPTs |
| Author | blog contributors | publish their **own** posts only |
| bw_staff | 151 teachers | log in → their staff-profile editor → nothing else (see `inc/bw-staff-access.php`) |
| Subscriber | generic logged-in viewer | read only |

**Key build decisions & the gotchas behind them (read before touching roles again):**
- **Brentwood CPTs leaked to Author.** `staff`/`landing`/`course`/`livestream`/`bw_hundred`/`bw_career`
  ship with `capability_type => post`, so any post-capable role could create/publish them. Fixed by
  re-registering them under a shared **`bw_content`** capability type (`register_post_type_args` filter)
  and granting those caps to Editor/Admin only. Does NOT affect `/my-profile` (that authorises by
  ownership, not caps).
- **Kadence grants caps via a `user_has_cap` FILTER, not the role.** So `remove_cap()` is a silent no-op
  for `edit_kadence_*` / kadence forms/queries — you must revoke at the `user_has_cap` layer (priority
  999, admins exempt). Also: **`kadence_query` AND `kadence_query_card` both map to `edit_kadence_queries`**
  — cap names are NOT guessable, so read them off the registered post type.
- **Kadence Elements lives under Appearance → Kadence → Elements** (`add_theme_page`), which needs
  `edit_theme_options` (also opens Widgets + full Customizer — we don't grant it). So Editors held
  `edit_kadence_elements` but had no menu. Fix: re-add an Elements submenu under the Kadence menu they
  already see.
- **Yoast menu was stubborn.** The parent menu shows if ANY submenu is permitted (Editors have
  `wpseo_edit_advanced_metadata`/`wpseo_bulk_edit`), and the slug is version-dependent
  (`wpseo_page_*` now, `wpseo_dashboard` before). Fix: scan the built menu late and drop any top-level
  `wpseo*` entry for non-`wpseo_manage_options` users — but **keep the per-post SEO metabox** (that's the
  keyword/meta box Rian wants; only the settings/upsell menu is removed).
- **bw_site_editor is NOT a draft/approval role.** Core has no native "revise a live page, submit for
  approval": without `edit_published_pages` a live page is read-only; with it, edits go live instantly.
  So we made it "own-content, full-lifecycle" instead. A true approval queue needs **Edit Flow /
  PublishPress** — parked pending Erin's call. (An earlier `bw_draft_editor` role was built then replaced;
  users auto-migrate and the old role self-removes.)

**Teacher access** (`inc/bw-staff-access.php`): a `bw_staff`-only user lands on `/my-profile` after login
(incl. Google), any wp-admin hit bounces back there, the admin bar is hidden, and a Log out link is added
to the profile page (the admin bar was the only exit). Restricts ONLY when `bw_staff` is the user's sole
role, so promoting a teacher lifts it cleanly. `/my-profile` edits the **staff post** (bio/credentials/
photo), not the WP user profile.

**Content authorship reassigned** (column-only `$wpdb->update`, never `wp_update_post`):
- Pages + all CPTs → **Erin Coulson** (~910). Blogs → matched author (448) else **Jim Ganley** (~5,581).
- **Bug fixed in passing:** 5,579 blogs had `post_author = 0` (the blog importer never set an author) —
  breaks author archives; all assigned to Jim.
- All **151 staff profiles are now authored by their own teacher**, and a new **author-sync**
  (`acf/update_value/key=field_bw_staff_user` → set `post_author`) keeps that true for future links — so
  the "promote a teacher to Site Editor and they can still edit their own profile" path works for new
  hires too. Byline credits are unaffected everywhere (front end renders `blog_author`, not `post_author`).

**Delegated user management** (`inc/bw-user-management.php`): granted to the **Editor role** ("Editor =
admin-lite", Rian's call), fenced by two guardrails that apply to ALL non-admins:
1. `editable_roles` — a non-admin can assign any role **except administrator** (enforced on save, not just
   the dropdown), so they can never create/promote an admin.
2. `map_meta_cap` — a non-admin can never **edit/delete/re-role an administrator** account.
Verified live as Erin: can create users + assign teacher/site-editor roles; **cannot** see administrator
in the role list, **cannot** edit/delete admin accounts. Consequence to know: editors CAN manage each
other (non-admin-on-non-admin isn't blocked) — acceptable under the admin-lite model.

**Open items:** (1) the 3-gallery blog-hero fix (below); (2) the 4 internal login-gated pages — still
recommend NOT migrating; (3) approval workflow → Edit Flow/PublishPress if Erin wants it.

### 2026-07-23 (later 2) — Rian + Claude — Blog hero cross-check: 3 new posts should be galleries

**⚠️ STAGING ONLY — queued, not yet executed.** Rian noticed a migrated post's hero showed 1 image where
live shows 2 ("Still in the Game"). Root cause: `import-blog-full.php` **doesn't set `hero_type`** (noted
2026-07-21), so the 13 top-up posts were blanket-set to `image` — wrong for the multi-photo ones.
Cross-checked all 13 against the Laravel source (first content element + photo count):
- **3 should be `gallery`:** Magical/Unicorn (38421, 3 photos), Crew of '72 (38443, 3), Still in the Game
  (38450, 2). **9 are correctly single `image`.** Lifers' Dinner (38426) is text-first → no hero (its
  stray `hero_type=image` is harmless but will be cleared).
- **Fix (ready, deferred):** set `hero_type=gallery`, reuse the featured image as row 1, import the extra
  photos at **retina/2304px** as rows 2–3 of the `hero_gallery_rows` repeater (sub-field `image` = attach
  ID). **Deferred until the background image agent finishes** — running two image-import jobs in the 1 GB
  container at once risks OOM.
- **Ops gotcha:** while the background agent held the gateway's per-project WP lock, `srv-gw wp/db-query`
  returned empty output / exit 7. Workaround: run `wp` via **`docker exec brentwooddev-wordpress wp … --allow-root`**
  (exactly what the gateway does internally) to bypass the lock for small writes.

### 2026-07-23 — Rian + Claude — Auth workstream kickoff: Google login CONFIGURED

**⚠️ STAGING ONLY — not pushed.** Start of the login/permissions workstream (see the scope review below).

- **Google login is now live-configured on staging.** Rian created a **new** OAuth client
  (`Brentwood WordPress Login`, Web application) in Brentwood's existing GCP project
  **`brentwood-cms-274415`** — deliberately NOT reusing `Brentwood CMS - Frontend`, which powers the
  **live Laravel login** and must stay untouched. Credentials entered in-browser via
  **Settings → Google Login** (stored in the `bw_google_oauth` option, autoload=false) so the secret
  never passed through chat/shell/a file.
- **Redirect URIs registered:** staging + dev + `brentwood.ca` (added early for launch). The code's
  expected URI is `https://<host>/wp-admin/admin-ajax.php?action=bw_google_callback` —
  host-derived, so the **credentials propagate to dev on the next DB push** and just work.
- **Verified:** configured=YES, valid client-id format, secret present, redirect URI matches, domain
  allowlist = `brentwood.ca`, and the "Sign in with Google" button renders on `wp-login.php`.
- **⚑ Not yet usable — 0 WP users have a `@brentwood.ca` email.** `bw-google-login` matches EXISTING
  users by email and never auto-creates (deliberate: auto-provision would mint WP accounts for all
  ~1,300 students). So Google sign-in can't succeed until staff accounts exist → next task.
- **Also note:** password login is unaffected — rian/Tracy keep using username+password regardless of
  email domain. Google is an additional method for `@brentwood.ca` staff only. And the consent screen is
  **Internal to brentwood.ca**, so only a Brentwood account can complete the flow (rian@rian.ca can't
  test the button — that needs Erin or a `@brentwood.ca` account).

**Staff accounts provisioned + admin cleanup (same day, after the Google config above):**
- **151 staff accounts created** (`bw_staff` role) from the Laravel `@brentwood.ca` roster, each
  **auto-linked to its WP staff profile** (`staff_user_id` + the `field_bw_staff_user` ACF ref) — 0 errors,
  0 collisions. That switches on the **self-service `/my-profile` editor** (verified: `blake.gage` →
  profile 115, `bw_profile_can_edit` = YES). Split was 98 matching a published staff post + 53 matching a
  draft one (all active employees — draft = unfinished profile content, not departed); 2 had no WP post
  (Ciana Della Siega, Olex Savenko) and were skipped.
- **Safeguards used — repeat these for any bulk user run:** `pre_wp_mail` hard-blocked for the whole
  operation so **151 real Brentwood staff got zero surprise emails**; each account got a long random
  password that was never displayed or stored (Google sign-in is the intended path; "Lost your password?"
  is the fallback); `bw_staff` is `read`-only.
- **Rian's account:** username `rian` was already his (ID 8), so it was **updated, not duplicated** →
  `rian.rian@brentwood.ca`, "Rian Bowden", still administrator. Password login is unchanged; this just
  adds Google sign-in and lets him test as a Brentwood user.
- **Adi's team admins deleted** (`ij`, `rq`, `zr`, `ym`, `ss`) with content **reassigned to `support`** —
  9,413 posts moved (2,276 → 11,689), **0 orphaned**. Front-end blog bylines are unaffected (they render
  from the migrated `blog_author` field, not `post_author`). **Administrators: 7 → 2** (`support`, `rian`),
  which also closes the "too many admins / 5 sharing one gmail inbox" finding from the code review.
  - ⚠️ Note for the record: **`zr` was still active** — logged in and edited the **Footer** element twice
    that morning — so Adi's team was not finished when the accounts were removed. Rian chose to delete all
    five anyway. zr's Footer edit was checked and did **not** break the footer work (row uniqueID,
    padding and the dynamic image binding all intact).

**Scope findings for this workstream** (from the proposal + Laravel review):
- SOW committed: roles collapsed to **Admin / Marketing / Teachers**, the **7–8 admissions pages locked
  to Marketing+Admin**, a **pending-review approval workflow**, and **Google Login = yes**. Marked **"no"**
  in the SOW: the teacher self-service editor (but it's already BUILT — Rian's call: finish it and give it
  as a bonus) and login-gated member content.
- Laravel has **3,020 users** across **~130 roles**, but only **~103 are content editors** — the rest are
  students (1,291), guardians, alumni, org/department roles. **Do NOT migrate all 3,020.**
- **152 staff have `@brentwood.ca` emails; 150 name-match a WP staff post** → provision those as
  `bw_staff` + auto-link to their profile (currently only 1 of 118 linked).
- **Login-gated pages are a tiny set**: only 4, all internal (Staff directory, Library, Databases,
  Assembly), gated to staff+students via the `permissions` table (`view` action). Recommendation: they're
  internal tools, probably shouldn't come to the public marketing site — and gating them to *students*
  would require the student accounts we're deliberately not creating.
- **`accounts.brentwood.ca` is NOT ours** — a separate Laravel "Account Portal" run by Brentwood IT for
  managing the Google Workspace "Brentwood Account". The website only links to it. Out of scope.
  Course selection is likewise an external Google Forms workflow — just a page that links out.

### 2026-07-21 (later 3) — Rian + Claude — Full code review: security + cleanliness (pre-launch pass)

**Verdict: clean.** Confirmed ALL customizations are confined to the child theme + two versioned Bowden
product plugins (`bw-dev`, `bw-lead-ai`) — parent Kadence is stock (single update-cluster mtimes, no
edits), `wp core verify-checksums` passes, no mu-plugins, `WP_DEBUG` off.
- **Security:** no eval/exec, no hardcoded secrets, SQL prepared/static, every AJAX handler nonce+cap
  gated, profile editing has a sound ownership model, uploads filetype-restricted, inquiry thank-you uses
  HMAC-signed URLs, Google OAuth has state-CSRF + `wp_safe_redirect` (and is unconfigured on staging —
  button hides), bw-lead-ai REST is admin-gated, bw-cron REST is constant-time secret-compared.
- **Cleanliness:** every `inc/` module referenced, every block registered, zero TODO/FIXME debt, zero
  junk files, zero console.log/var_dump, one commented-out hook (the red line — intentional, dated),
  all 19 acf-json files map to live CPTs/groups, all files www-data-readable.
- **Cleanup EXECUTED (Rian approved, same day):** deleted `wp-content/_imp_*` (42.5MB); purged all 210
  auto-draft/trash posts (0 remain); moved **41** stray files (Adi's screenshots, ADI-UPDATES notes,
  PDFs, border-migration backup) out of the brentwooddev root →
  `/srv/apps/brentwood/notes/brentwooddev-root-files/`; deleted the two brentwood-100 files there after
  verifying byte-identical copies already in `notes/`. Root now holds only infrastructure
  (compose/.env/deploy/CLAUDE.md/.claude/.project.json). Site verified healthy after.
- **⚠️ Admin-account pruning HELD at the last second.** Rian approved deleting the 5 `dojogakure+*`
  admins based on the review calling them "test/alias accounts" — but the bw-dev **login log
  contradicts that**: `rq` and `ij` logged in the same day (ij: 79 lifetime logins), `zr` the day
  before; together they author ~9,700 posts. They look like the build team's working accounts
  (initials?). Awaiting Rian's reconfirmation; if deleted, content must be reassigned
  (`--reassign` to `support`), never dropped.
- **Still open:** DISALLOW_FILE_EDIT hardening (compose change + redeploy), wordpress-seo update.
  Minor code nits (not urgent): bw-cron `?secret=` URL fallback logs secrets; login-log purge
  registered nopriv (handler safe); my-profile bio double-unslash strips backslashes.
- **Architecture decision (Rian):** blocks/design STAY in the child theme; no wholesale plugin
  extraction. Post-launch candidates if ever wanted: google-login + blog auto-hide → bw-dev modules;
  CPT registrations → a `brentwood-core` plugin only if theme-switch resilience ever matters.

### 2026-07-21 (later 2) — Rian + Claude — Blog date fix + 13 missing posts migrated from live

**Pushed to dev** 2026-07-21 via `push-brentwood-dev`.

- **"Hitting the Ice, Hitting Your Couch" (wp 7248) had a corrupted date** — `2026-06-04`, which made a
  2013 post the newest on the blog. Laravel's `published_at` is **2013-01-24 02:33:00**, confirmed by the
  ID neighbourhood (7245 = 2013-01-23 19:57, 7253 = 2013-01-24 19:27). Fixed with a **direct
  `$wpdb->update`** of `post_date`/`post_date_gmt` — deliberately NOT `wp_update_post()`, which re-saves
  `post_content` through an unslash and can corrupt block JSON (see memory
  `wp-update-post-must-wp-slash`). Content verified byte-identical afterwards.
- **13 posts were missing, not 1.** Rian spotted one Jun 2 post; staging actually had **nothing published
  after 2026-06-01** (everything later was WordPress auto-drafts). Pulled a fresh mirror first
  (`sync-mirror.sh`, live backup `2026-07-21_07-00`) — which was worth it: the stale mirror ended at
  Jul 9, the fresh one reached **Jul 18**, adding a 13th post. Range migrated: **2026-06-02 → 2026-07-18**.
- **New reusable capability:** `migration/build_blog_full.py` now accepts **Laravel blog ids as arguments**
  to build only those (`ONLY_IDS`); with no arguments its behaviour is unchanged. So an incremental
  top-up is now just:
  `python3 migration/build_blog_full.py <ids…>` then run `import-blog-full.php` (already idempotent —
  it keys on `_bw_src_blog` and skips `_bw_done`). Use `build_blog_full.py`, **not** `build_blog_recent.py`,
  for new posts: the "recent" builder is spreadsheet-driven and new posts aren't in `brentwood-pages.xlsx`.
- **Result:** 13 imported / 0 skipped / 19 images. Verified: 0 block-JSON corruption, all categorised,
  correct dates and ordering, posts render (e.g. `/blogs/allard-house-captain/` → 200 with images).
  Two posts (Food for Thought, Allard House Captain) are **Uncategorized** because they carry no tags on
  live, and **Lifers' Dinner 2026 has no featured image** — also true on live, not an import fault.
- **Ops note:** the importer must be reachable *inside* the container. `srv-gw wp eval-file` rejected both
  host and container paths, so it was staged into `wp-content/_imp_blog_full/` and run via
  `wp eval 'require "…"'`, then **deleted from the webroot** afterwards.

### 2026-07-21 (later) — Rian + Claude — Careers CPT + grid block, populated from live

**Pushed to dev** 2026-07-21 via `push-brentwood-dev`. Careers moved from hand-built page content to a proper post type.

- **CPT `bw_career`** ("Careers") — `acf-json/post_type_bwm_career.json`. Admin-only (public/queryable
  false, no archive, no permalinks) — postings exist only to be rendered by the grid, same pattern as
  `bw_hundred`. Body = the posting description.
- **Fields** — `acf-json/group_bwm_career.json`: **Open Message** (`career_open_message`, e.g. "Open until
  filled" / "Apply by August 7, 2026") and **Button Link** (`career_button_link`, accepts a URL or a
  `mailto:`). Blank Button Link hides the button; blank Open Message shows the button alone.
- **Block `bw/careers-grid`** ("Brentwood Careers") — `blocks/careers-grid/`, registered in
  `inc/brentwood-blocks.php`. Renders each posting as **description → Open Message + Apply Now button →
  divider**, matching brentwood.ca/careers. Options: columns (1–4, default 1 = the live stacked look),
  limit, button label, divider on/off, and an empty-state message. Ordered by `menu_order` then
  newest-first, so postings can be hand-ordered via Page Attributes. Body renders through `the_content`,
  so it works whether the posting was imported as HTML or later edited as blocks.
- **Populated from live** — 5 postings imported from `brentwood.ca/careers` (Learning Support Teacher,
  Houseparent (Allard House), Campus Support Team, Office Manager – Student Services & Reception, Food
  Services Steward Dishwashers), each with its Open Message, `mailto:hr@brentwood.ca` link, and the Google
  Drive job-description links preserved. Word `MsoNormal` cruft stripped; `wp_slash` used, corruption
  scan clean. Import is idempotent (keyed on slug).

**Block now live on the Careers page** (wp 871 — Rian removed the hand-built listings). Follow-up round:
- **⚠️ Gotcha worth remembering: never call `apply_filters( 'the_content', … )` inside a block that
  renders during the host page's main loop.** A duplicate campus photo was appearing above every
  posting. Cause: `inc/brentwood-hero.php:468` hooks `the_content` and injects the **page hero**, gated on
  `in_the_loop() && is_main_query()` — both still true while a block renders inside the page's loop, so it
  fired once per posting and injected the *page's* hero into each. (Invisible to `wp eval` testing, which
  has no main loop — it only showed up in a real page render.) Fixed by applying WordPress's own content
  transforms directly instead: `do_blocks` → `wptexturize` → `convert_smilies` → `wpautop` →
  `shortcode_unautop` → `do_shortcode` → `wp_filter_content_tags`. Images inside the block: 5 → 0.
- **Divider** thickened 1px → **4px** (matches the Kadence section divider above the block) and
  **top padding** added — 2rem above the list, 2.25rem between a divider and the next posting.
- **Layout settings removed** — the list is deliberately fixed (single stacked column, always divided,
  all postings) to mirror live, so `columns`/`limit`/`showDivider` are gone from the block entirely. Only
  Button label + empty-state message remain configurable.
- **Button Link auto-detects the scheme** — an editor can type `hr@brentwood.ca` and it renders as
  `mailto:`; a bare domain becomes `https://`. Existing values with an explicit scheme, root-relative
  paths and anchors are left untouched. All 5 postings re-saved as bare emails to prove it end-to-end.

### 2026-07-21 — Rian + Claude — Faculty reconciled against live + all staff photos upgraded to retina

**Pushed to dev** 2026-07-21 via `push-brentwood-dev`.

**Faculty `/academics/faculty/` now matches live exactly (54 = 54).** Compared staging vs
`brentwood.ca/academics/faculty`. **Gotcha for future syncs:** `_bw_src_staff` holds a STALE id
snapshot — Laravel reassigned `staff_profiles.id` after the migration (staging Angela=8, live=210), so
**never diff staff by that id; match by name** against the mirror DB.
- **Removed (confirmed `deleted_at` on live — left the school):** Rick Rodrigues (225), Eagle KC Chan (69).
  Set to **draft**, not deleted — the CPT has trash disabled and draft mirrors Laravel's soft-delete
  (and is reversible). This matches the existing convention (56 staff already drafted).
- **Ken Snow (247): NOT deleted** — he's still *active* on live, just off this page. Only the "Academics"
  department term was removed, so he survives everywhere else he appears.
- **Added:** Jessica Beausoliel — already existed (wp 40), just missing the Academics dept term.
  **Sara Lowes** — genuinely missing, created (wp **38407**) from mirror profile 307: bio (3 paras,
  cp1252-decoded → clean UTF-8), `staff_position`="Math Teacher", `staff_credentials`, Academics term.
  She has no photo on live either, so none was set.

**Blurry faculty photos — fixed.** Root cause: the migration imported Laravel's **`large`** size
(capped **1152px** wide); live serves the **`retina`** variant (**2304px**) — literally half the pixels.
Re-imported the retina file for **84 of 107** staff photos (11 were already ≥ retina; 12 had no mirror
match — staging-only/departed people, left untouched). Originals backed up before overwrite. Files were
copied in place (same attachment IDs, so nothing breaks), format-aware (4 PNG→JPG + 1 WebP converted),
then `wp media regenerate`. Page srcset now offers up to 2048px (was capped at 1152).
- **Ops note:** regenerating 84 images spiked the gateway to ~13 GB RSS and left `srv-gw` unresponsive
  (site itself stayed up). Fixed with `sudo systemctl restart srv-gateway`. If doing bulk media work
  again, batch it.

**Pixelated staff photos — the SECOND (and bigger) cause.** After the retina re-import the cards were
*still* pixelated, because the templates requested WordPress's **`medium`** size — which for a 3:4
portrait resolves to just **225×300**. The cards render ~220px CSS wide, so on any 2x/3x display a
225px file is visibly soft; source resolution was never the whole story. Changed `medium` → **`large`**
(768×1024 for these portraits) in **three** places, all still lazy-loaded:
`blocks/team-modal/render.php` (the card, + a `sizes` hint), `inc/bw-staff-profile.php` (modal photo),
`inc/brentwood-staff.php` (staff block photo). Served widths went 225 → **682–1024**.
**Takeaway for any new staff/photo template: never use `medium` for a portrait card.**
- **Genuinely low-res at source (nothing we can do — live is identical):** Michelle Legassicke,
  Sue Cheung Robinson, Scott Doehler are **640×800** in Laravel too. They need new photos from the
  school. Sara Lowes has no photo on live either.

**Staff sorted by real surname (was sorting on the last word of the title).** `bw_staff_last_name()`
used `end( explode( ' ', $title ) )`, so anyone with a two-word surname sorted under the wrong letter —
5 people site-wide: Bequer Hernández (sorted "Hernández"), Cheung Robinson ("Robinson"), Collis Handford
("Handford"), Day Reynolds ("Reynolds"), Hacker Teper ("Teper").
- The staff CPT **already had** First/Last Name fields — ACF `staff_first_name` / `staff_last_name` in the
  "Profile (self-service)" box (`inc/bw-my-profile.php`). **Backfilled all 176 staff**: 162 from the
  mirror's authoritative `first_name`/`last_name` columns, 14 staging-only people via the last-word guess
  (all verified single-word surnames, so safe).
- `bw_staff_last_name()` now takes the post and **prefers the `staff_last_name` field, falling back to the
  last word** when blank. Resolved **live at sort time** — no cached sort key, no save hook, nothing to go
  stale: edit the Last Name field and the order follows immediately. Fixes every staff listing at once
  (faculty pages, instructor block) since they share the helper.

**Team-modal columns now go to 6 (was capped at 5).** No CSS work was needed — the grid already used
`repeat( var(--bw-tm-cols), 1fr )`, so it renders any count. Only three hardcoded `5`s needed bumping
(two in `blocks/team-modal/editor.js`, one in `render.php`).

**Block-editor post title — ACTUAL root cause (found by inspecting the live editor DOM).** The two CSS
fixes below were both real problems, but **neither was causing the title symptom.** Diagnosing from CSS
payloads was the mistake; reading the editor's computed styles settled it in minutes:
- Kadence paints the page's background behind `.edit-post-visual-editor__post-title-wrapper` (so the
  editor previews the real page background). That wrapper spans the **full canvas** and has **no
  horizontal padding**, and the title sits inside a plugin-injected `.ai-title-toolbar-wrapper` that is
  width-limited but **not centred**. Net effect: the title sat hard against the left edge (x=8) inside a
  full-width tinted band while every content block below was centred (x=261) — the "broken" look.
- **Fix** (`inc/bw-editor-customizer-css.php`): editor-only CSS giving the title wrapper the theme's
  content edge padding and centring `.ai-title-toolbar-wrapper`. `!important` is required — Kadence's rule
  uses a higher-specificity `:not(.specificity)` selector. Verified live: title, its wrapper and the first
  content block now share the same left edge and width (aligned on both a page and the Careers page).
- **Separately fixed (latent bug, not this symptom):** `blocks/video-text/style.css` contained a GLOBAL
  `.entry-content-wrap{padding:0 !important}` override. A block's `style` sheet is also loaded inside the
  editor canvas, so page-layout rules in one escape the block. Moved to the child theme's `style.css`
  (front-end only — it's enqueued on `wp_enqueue_scripts`, never via `add_editor_style`), so front-end
  rendering is unchanged. **Rule for future work: never put page-layout overrides in a block stylesheet.**

**Block-editor chrome — customizer CSS (real fix, but NOT the title cause).** The post title still
looked broken after the `bw-gf-editor` fix, because **WordPress core itself injects the Customizer
"Additional CSS" into the editor canvas** by design — `wp-includes/block-editor.php`: *"Get any additional
css from the customizer"*. So the site-chrome rules were still reaching the editor from core, not from us.
- New `inc/bw-editor-customizer-css.php` hooks `block_editor_settings_all` at priority 9999, finds core's
  entry (matched on content — its css equals `wp_get_custom_css()`, since array position isn't
  guaranteed) and strips **only site-chrome rules**: `entry-content-wrap`, `site-container`,
  `site-header/footer-row`, `entry-hero`, `content-bg`, `content-title-style`, `masthead`, nav/menu,
  `bw-phero`, `single-entry`. Content-level styles are **kept** so the preview stays faithful
  (`bw-gallery-logos`, `figcaption`, `kb-row-layout-wrap`, buttons, font sizes, and the GF rules).
  5822 → 3412 bytes. Front end untouched — core still outputs the full sheet there.
- Consequently the earlier `bw-gf-editor.php` mirroring of the Customizer sheet was **redundant and
  removed** (it was duplicating what core already injects); that file now only injects the theme's own
  `assets/bw-gf-*.css` plus its editor-only step-dot CSS.

**Block-editor chrome, first (partial) fix.** Root cause:
`inc/bw-gf-editor.php` injected the **entire** Customizer "Additional CSS" into the editor canvas via
`wp_get_custom_css()`, though it only ever wanted the Gravity Forms radio/label rules. That sheet is
site-wide, so its layout rules (`.entry-content-wrap{padding:0}`,
`.site-container,…,.entry-hero-layout-contained{padding:0}`, `body.page .content-bg{box-shadow:…}`,
header/menu/footer) landed on the **editor's own wrappers** and wrecked the title area. Only 13 of 45
rules were GF-related. Added `bw_gf_editor_gf_rules_only()`, which strips comments and keeps only
GF-matching rules (recursing into `@media` so GF rules there survive while non-GF ones in the same
at-rule are dropped): 5822 → 2249 bytes injected. **The front end is unchanged** — core still loads the
full Customizer sheet there; only the editor injection is filtered.

### 2026-07-20 (later) — Rian + Claude — Blog-block category picker fix + meta-box consolidation

**Pushed to dev** via `push-brentwood-dev`. Includes the volleyball blog block now filtered to the
Volleyball category (`categories:[211]`, set in-editor once the picker was fixed).

- **Blog block ("Volleyball News" etc.) category picker fixed.** Client noticed the volleyball page
  showed generic (site-wide) news, not Volleyball news. Root cause: the `brentwood/blog` block's
  `editor.js` loaded only the first **100 of 124** categories (`/wp/v2/categories?per_page=100`, no
  pagination), so categories past ~#100 alphabetically (incl. "Volleyball") never appeared in the picker
  and couldn't be selected. Added a `fetchAll()` paginator (categories/tags/authors). **Note:** tags were
  NOT missed in migration — the Laravel tag tree came across as WP **categories** (e.g. "Volleyball" =
  cat 211, 222 posts). The volleyball block still needs its category set to `[211]` (do in-editor now that
  the picker works, or ask Claude to script it). Several sport pages (soccer, tennis, field-hockey, …)
  have **no** blog block at all — separate follow-up.
- **Consolidated the three page meta boxes into ONE "Page Settings" box.** Was: "Page Title Override
  (BW Dev)" (plugin box) + "Hero" (ACF) + "Page Settings" (ACF, footer image). Now one ACF box
  "Page Settings" containing: H1 title override (now a **single-line** field), all hero settings, then
  footer background.
  - Merged the two ACF groups into `group_bwm_page_hero` (renamed title → "Page Settings"); deleted
    `group_6a2967c17a264.json`. Field keys/names preserved → **no data migration**, footer element
    binding intact.
  - The H1 override is a new ACF text field `page_h1_override` **bridged** to the existing bw-dev meta
    `_bw_dev_title_override` via `inc/bw-title-override-acf.php` (acf/load_value + acf/update_value; ACF
    stores no copy of its own). So all existing overrides + the front-end `the_title` rendering are
    unchanged. Verified end-to-end (loads, saves w/ kses, renders).
  - The bw-dev plugin's own box is removed via `inc/brentwood-editor-layout.php` (plugin otherwise
    untouched — its `the_title` filter still renders). Field is gated to the plugin's enabled post types
    (default pages). `blocks/hero-card/editor.js` sync selector extended to the ACF field so the
    in-canvas H1 ↔ field two-way sync (and clobber-protection on save) still works.

### 2026-07-20 — Rian + Claude — Client-requested visual fixes: hero height, remove body red line, footer polish

All on staging (`brentwooddev`); theme-only CSS/PHP, no DB/content changes. **Pushed to dev** via
`push-brentwood-dev`.

- **Hero aspect ratio — matched live.** Client said page-top heroes/videos were "too tall." Live uses
  `pb-[42.5%]` (~2.35:1); our build was `56.25%` (16:9). Changed `.bw-phero--frame .bw-phero__media`
  → `42.5%` in `assets/page-hero.css`, with a **mobile override keeping 16:9** (≤768px) so phone heroes
  don't become a thin strip. YouTube heroes left at 16:9 (own `--youtube` rule). Carousel / fixed-height /
  full-bleed heroes unaffected.
- **⚠️ Removed the body red accent line — DELIBERATE, at CLIENT REQUEST. Do NOT re-add.** The vertical
  red line down the content (`.border-primary-line`, at the ~⅓ mark on desktop; a `border-left` on
  `.entry-content` on mobile) is gone. **Commented out, not deleted, so it's a 2-line uncomment if the
  client reverses course:** (1) the `add_action` for `brentwood_border_primary_line()` in `functions.php`,
  and (2) the mobile `border-left` in `style.css` (~line 87). The `.border-primary-line` CSS block is left
  intact. **@Adi:** if you see the red line "missing," that's intentional — don't restore it without checking
  with Rian. The subnav's small red left-accent (`bw-subnav`) was left as-is (matches live).
- **Footer polish (two client asks)** — new `inc/bw-footer.php` (required in `functions.php`):
  1. **Gap collapse:** the footer (Kadence element #22041) reserves 350px bottom padding for the per-page
     footer image (ACF `footer_image_background`). Pages with no image showed a big empty gap → now
     collapsed to 40px. A `bw-footer-noimg` / `bw-footer-img` body class drives it (image presence read
     from the DB directly to dodge a post-meta object-cache flake).
  2. **Readability overlay:** on pages WITH a footer image, the previously-disabled Kadence row overlay is
     enabled as a palette7 (`#dcf2fa`) blue→transparent gradient across the top, so nav/contact/social text
     stays legible where a tall image rises into it.

### 2026-07-13 — Rian + Claude — Image Alt Text Migration (all 205 pages) + "Auto Hide Old Blog Posts" + polish progress

**1) Image Alt Text Migration — COMPLETE across all 205 pages.** (Full strategy/log:
`notes/image-alt-text-migration.md`; per-batch old→new logs in `notes/alt-batch-logs/`.)
- **Result:** every page's content images now carry alt text — a full-site render scan shows **0
  non-decorative empty-alt** on all 205 pages. Media-library coverage **8,585 → 10,346 / 10,660 (97%)**.
  The remaining 314 empty are **orphan attachments not shown on any page** (optional to fill later).
- **The rule (from Rian, now a standing rule + memory `brentwood-alt-prefer-live-original`):** when the
  **live site already has alt**, use that **verbatim** — only write new alt where live is blank. Live's
  own captions/labels are authoritative (and often deliberate SEO strings).
- **Method:** (a) harvested live originals site-wide from `www.brentwood.ca` photo JSON and applied by
  filename → the bulk, zero-hallucination; (b) for the live-blank residue, **viewed each image on disk**
  and wrote alt in the site's voice (SEO-lead + visible scene, decorative left empty); (c) the two big
  galleries (**trips** 180, **brentwood-100** 166) got the site's own photo **captions** where present,
  else **page-aware contextual** alt (per-trip / "historical archive").
- **New mechanism (child theme):** `inc/bw-img-alt.php` — a `render_block` filter that injects an image's
  attachment alt into any empty `<img>` at render (covers the custom `bw/interlinking` cards + blog
  thumbnails + Kadence images). So **attachment meta is the single source of truth** — set alt once on the
  media item and it renders everywhere. `brentwood-100` grid tiles use `get_the_post_thumbnail`, so after
  setting attachment alt we flushed the `bw_hundred_grid_c*` transients to repopulate them.
- **Staging only** (`brentwooddev`), not pushed to dev/live.
- **⚑ For Adi (verifying tomorrow):** hard-refresh pages and spot-check. Two things to eyeball: (i) **staff
  portrait alts** are "Name, Title" pulled from filenames (e.g. "James Boswell, Associate Director of
  Admissions") — confirm names/titles are right; (ii) the **trips + brentwood-100 galleries** got
  caption/contextual alt, not a unique hand-written line per photo — flag any gallery you want upgraded.
  Note: staging has `blog_public = 0` so Yoast noindexes the whole staging site — that's expected; the
  "search-reachable" benefit only lands on production (`blog_public = 1`).

**2) "Auto Hide Old Blog Posts" (blog auto-archiving, brief §6 / SoW / task E6-9) — built + verified.**
- New `inc/bw-blog-archive.php` + a **Settings → Reading → "Auto-hide old blog posts"** field (default **3
  years**, `0` = off). A `pre_get_posts` date filter drops posts older than N years out of **on-site
  listings** (archives, search, recent-posts blocks) **while keeping them `publish`, reachable at their own
  `/blogs/…` link, and in the sitemap** (no noindex added). wp-admin, feeds, singular views, curated
  `post__in` blocks, and the sitemap are exempt. Verified: at N=3, listings trimmed 6,016 → 459 visible; a
  2019 post is hidden from listings but its permalink still 200s; N=0 restores all. Staging only.

**3) Progress on other parts.** Manual page-polish pass continues — **junior dev doing the per-page manual
  polish, tracked page-by-page in the build-tracker spreadsheet** (the Google Sheet is the source of truth
  for X/217 build status; see memory `brentwood-page-build-tracker`). Nothing else pushed to dev today.

### 2026-07-10 — Adi + Claude — Timetable block: phantom scrollbars on card hover fixed

- **Bug: hovering a Timetable card spawned an unneeded scrollbar** on `/why-brentwood/unique-timetable/`.
  Hovering a **bottom-edge** card (Dinner, Academic Prep) pushed a **vertical** scrollbar / scroll area at
  the bottom; hovering a **right-most-column** card (e.g. Evening Activity) added a small **horizontal**
  scroll on the right. Only some cards triggered it — edge cards, not interior ones.
- **Cause:** the hover effect applies `transform: translateX(4px) scale(1.01)` (slide-right + slight grow),
  and the track is `overflow:visible` so the lift can show. But `.bw-tt__scroll` sets `overflow-x: auto`,
  and per the CSS spec that **forces `overflow-y` to compute to `auto` too**. So an edge card's transformed
  box crossed the scrollport edge → the (now scrollable) container grew a phantom scrollbar. Interior cards
  stay within the grid bounds, hence "not on every card."
- **Fix (CSS-only):** gave `.bw-tt__scroll` an interior **"moat" of padding** (`padding: 8px 14px 24px`).
  The hover transform now expands *into* that padding instead of past the scrollport's edge, so no
  scrollable overflow is ever added — the card still lifts/slides/casts its shadow (bottom is roomier so
  the last row's drop-shadow isn't clipped), but no scrollbars appear. Preserves the author's intent (the
  track is deliberately `overflow:visible` for the lift).
  - File: `blocks/timetable/style.css` (the `.bw-tt__scroll` rule only). Style handle is versioned with
    `filemtime()`, so the edit auto-cache-busts — **no PHP restart / cache clear needed** (CSS change).
  - Minor benign side effect: a few px of extra breathing room around the grid in the split "inline" panel
    view. Cosmetic only.
  - **Verified** by Adi: hover on Dinner / Evening Activity no longer creates bottom/right scroll; the
    hover lift is intact.

### 2026-07-09 — Adi + Claude — Image (Adv) "Always HD" fix; page-polish pass underway

- **Bug: Image (Adv) blocks were serving a blurry 300w thumbnail.** On pages like `/academics/`,
  Kadence `kadence/image` photos rendered blurry — the browser was loading the `-300x230.webp`
  candidate and object-fit:cover upscaling it. **Cause:** the block saves the full-size URL in `src`,
  but WordPress core injects a responsive `srcset` **and** `sizes="auto, …"` at render time (the WP 6.7
  auto-sizes feature for lazy images). Inside narrow ratio / "Cover (fill column)" columns, `sizes=auto`
  measured small and the browser picked the 300w file.
- **Fix (default ON, site-wide, no per-block toggle):** new `inc/bw-image-hd.php` — a `render_block`
  hook on `kadence/image` that pins each `<img>` to a **single, full-size `srcset` candidate**. A
  one-URL srcset (no descriptor) is always the browser's pick → HD always loads; and its presence makes
  core skip re-adding the small candidates. Other images keep normal responsive behaviour.
  - Files: `inc/bw-image-hd.php` (new) + the `require_once` in `functions.php`. Needs a PHP restart
    (opcache) — done.
  - **Verified live** on `/academics/`: the flagged image (`wp-image-23238`) now emits
    `srcset="…jpg.webp"` (full) with no resized candidate and no `sizes=auto`; across all 21 Image (Adv)
    blocks on that page, 0 still offer a downscaled candidate.
- **Team status — page polishing in progress.** Adi + the junior team are doing a page-by-page polish
  pass: **responsive checks** (mobile/tablet layout) and **modal checks**. Some **click-to-open modals
  are missing** (a link that should open a modal on click renders nothing / doesn't trigger) — being
  catalogued as they're found. (Related infra already in place: the `#staff-<slug>` anchor + `[bw_staff_modal]`
  shortcode, and the team-modal `[hidden]` click-blocking fix from earlier this session.)
- **Pushed to DO** — this and the batched updates (YouTube hero poster, Table Link "open in new tab",
  Team Modal List/Grid layouts + typography, staff-anchor modal, mobile red accent line) were pushed to
  `https://dev.brentwood.ca` via `push-brentwood-dev` (239M DB dump; still behind the access gate). The
  Image (Adv) HD fix above landed on staging after that push — **push again** to carry it to dev.

### 2026-07-02 — Adi + Claude — "Brentwood Images" fit-frame + nested-row auto class

- **"Brentwood Images" (bw/card-gallery) — new "Show whole image" size option.** In the block's **Size**
  panel: a **"Show whole image (fit inside frame)"** toggle switches the image from crop-to-fill (cover)
  to **contain**, so the image keeps its OWN ratio inside the frame — e.g. mixed-ratio artwork in a tidy
  4:3 grid, like the live Arts Gallery. Added a **Frame background** colour (fills the letterbox/pillarbox
  area) and a **Frame background (hover)** colour (fades on hover). In this mode the frame defaults to
  **white** (not the block's dark `#1a1a1a`) so it's never "dark mode" unless the author picks a colour.
  Works with any ratio; existing cover cards are unchanged.
  - Files (`blocks/card-gallery/`): `block.json` (`imageFit`, `frameBg`, `frameBgHover`), `editor.js`
    (controls + live preview), `render.php` (`--bw-cg-fit` / `--bw-cg-frame-bg` / `--bw-cg-frame-bg-hover`),
    `style.css` (object-fit + box background + `:hover`, all via CSS vars).
- **Nested Row Layout → auto `.bw-inside-row` class (no admin option).** When a Kadence Row Layout is
  nested inside another Row Layout (complex layouts: Row → Column → Row), the INNER row automatically gets
  a `bw-inside-row` class, so the Customizer rule that drops the inner row's left/right padding can target
  it. **No editor option — detected on render.** Implemented DOM-accurately: after `the_content` renders,
  `WP_HTML_Tag_Processor` (HTML5-safe, lossless) walks the HTML once, tracks real `<div>` nesting, and
  tags any `.kb-row-layout-wrap` that has a `.kb-row-layout-wrap` ancestor. (A first attempt with a
  positional `render_block` pass missed some nested rows depending on render path; the tag-processor
  method is reliable.) Verified: tagged count matches the nested-row count exactly across pages
  (908 = 7/7, 866 = 4/4, 956 = 4/4, 912 = 1/1); top-level rows untouched; no fatals. Scope = page content;
  header/footer nav elements are intentionally left alone (so nav mega-menus aren't affected).
  - Files: `inc/bw-inside-row.php` (new) + the `require_once` in `functions.php`.
- **Still on `brentwooddev.demoing.info` — not pushed to DO** (batched with the held minor updates).

### 2026-07-01 — Adi + Claude — `/landing/local` inquiry form has optional account creation

Checked the Laravel-source inquiry form (`brentwood.demoing.info/landing/local`, form id 204):
it has an **optional "create an account" path** — an internal account, not a 3rd-party integration.

- Form 204 has `create_password=false`, so after a valid, not-yet-registered email is entered a
  checkbox **"I would like to create an account"** appears (`inquiry.vue:51`). Checking it reveals
  Create/Confirm Password; submitting with a password sets `activated_at` (login-able account) and
  sends an `EmailVerification`.
- **Every** submit already creates a `users` row via `findOrCreateByEmail` (needed to own the
  inquiry) — the checkbox only decides whether that account is activated + has a usable password.
  Same internal `users` table as livestream registration; no SSO/CRM. **For the WP rebuild:**
  replicate this optional "create an account" step. Details in
  [`notes/Contact Information Check.md`](./notes/Contact%20Information%20Check.md).

### 2026-07-01 — Adi + Claude — "Brentwood Images" lightbox polish (front-end)

- **Lightbox layout — image no longer lifts on hover.** The card hover-lift (`translateY(-2px)` + shadow)
  applied to every interactive card, including Lightbox; on Lightbox the image should stand still since
  the magnifier icon (opacity 0.5→1) + `zoom-in` cursor already signal it's clickable. Excluded the
  Lightbox layout from the hover-lift so the image stays put.
- **Lightbox viewer overlay: black → white**, matching the live site (`rgba(255,255,255,0.95)`). Paired
  change: darkened the close/nav buttons (default `rgba(0,0,0,0.55)`, hover `0.78` — were 0.35/0.6) so
  their white glyphs stay legible on the white overlay (dark circular controls read cleanly over both the
  overlay and the image).
- **File:** `blocks/card-gallery/style.css` only (the "Brentwood Images" block, `bw/card-gallery`).
  Front-end CSS, cache-busted by filemtime — no container restart. **Still on `brentwooddev.demoing.info`;
  not pushed to DO** (batched with the held minor updates).

### 2026-06-30 — Adi + Claude — Focus: client-friendly Gutenberg page-building (+ progress)

- **Focus today: improving the page build in the Gutenberg editor for client-friendly editing** — making
  the building blocks consistent and easy for non-technical admins to use. The updates below are part of
  that theme (none is a headline).
- **Column Curve — editor preview fixed** (admin side). While editing, the curve was spilling over the
  rows above/below the section. Cause: the preview ellipse is tall (height 150%, centred → 25% overshoot
  top & bottom) and the bulging column is raised (`z-index`) with `overflow:visible` so it can bulge
  sideways — so the rounded top/bottom rendered over the neighbouring rows. Fix: clip the preview ellipse
  to the column's own height with `clip-path: inset(16.667% 0 16.667% 0)` in
  `assets/bw-curve-editor.css`, trimming top/bottom to the section bounds while keeping the sideways
  bulge — the same result the front end gets from the image column's `overflow:hidden`. Editor-only;
  front end unchanged. (The 16.667% is calibrated for the default 150% curve height.)
- **Featured YouTube hero now mimics the live site** (`brentwood.ca/events/schoolstart/alex`). Previously
  the hero card overlapped and covered the YouTube video. Now: on **play**, the overlapping hero card is
  **pushed down** so the whole clip shows; on **pause/end** it **slides back over** the video (the
  negative-margin overlap returns). Added a red **"▶ Play Video"** button inside the hero card that
  appears only when **Hero Type = Featured YouTube** (and a video URL is set); it plays the same video
  inline. Idle detection uses the **YouTube IFrame API, loaded only after the first play** (the privacy
  facade — no YouTube JS until the visitor opts in — is preserved).
  - **File changes (child theme `kadence-child`):**
    - `assets/page-hero.js` — play handler now toggles `is-yt-playing` on the hero, loads the YT IFrame
      API, tracks play/pause/end, and wires the `.bw-hero-playvideo` button.
    - `assets/page-hero.css` — card drop-on-play override (beats the overlap at every breakpoint) +
      `.bw-hero-playvideo` button styling.
    - `blocks/hero-card/render.php` — outputs the "Play Video" button when the page's hero type is
      youtube and a valid video id is set.
  - Verified on page 1052 (`/events/schoolstart/alex/`): facade + button render, no fatals. Interactive
    playback (drop/restore) to be eyeballed in-browser.
  - **Push to `dev.brentwood.ca`: holding for now** — these are only minor updates, so we'll wait and
    batch the push once there's more significant progress (later today or tomorrow). Until then the
    changes stay on `brentwooddev.demoing.info`.
- **📊 Progress: 159 of 217 pages done** (up from 147). Main-menu pages ready for **desktop** review;
  inner pages still in progress. (Tracker sheet is the source of the count.)
- **⏳ Reduced hours this period:** scheduled power **blackouts in Indonesia** (Adi's location) are cutting
  some working hours — factor this into milestone pacing.

### 2026-06-29 — Adi + Claude — Pushed to DO + Brentwood 100 paused + progress snapshot

- **Pushed latest to DigitalOcean** (`dev.brentwood.ca`) via `push-brentwood-dev` — verified live
  (HTTP 200, behind the gate). Today's work included in the push:
  - **Standardising the build on our Kadence child-theme HOOKS** — Adi's main focus. The hooks we
    added (Row **Shadow**, Row **Border Radius / box border**, Column **Curve**, Section **Red Border**)
    let authors flip a simple ON/OFF toggle instead of hand-entering values (colour / width / side /
    shadow / radius) that previously confused admins. Much easier now — **but NOT finished; still
    converting existing pages and verifying.**
  - **Section "Red Border" → toggle migration.** Bulk-converted manual Kadence column borders to the
    `bwRedBorder` toggle, **colour-aware** (only red — `palette1 #c8272c`, `palette2 #a32024`,
    `palette13 #b82105`, red hex/rgb; white `palette9`/gray box outlines left alone). 112 toggles,
    originals backed up at `brentwooddev/_border-migration-backup-2026-06-29/` (per-page revert recipe
    in its README). ⚠️ **Removing the OLD manual right-border before applying the hook seems to work,
    but needs eyeball verification: DONE for the main-menu pages, NOT yet verified for inner pages.**
  - **Row Layout "box border / radius" + "shadow" toggles** — same hook approach (one-click instead of
    manual values). **Still in progress** — not all rows converted/verified.
  - **Hero Card CTA** — new optional red CTA button (top-right, beside the title) on the
    `brentwood/hero-card` block: Settings → Call to Action (text + link + open-in-new-tab).
  - **Column Curve editor preview** — the curve now shows in the Gutenberg canvas (admin), like the
    Red Border preview; front end unchanged.
- **Team:** Adi wrote a **brief for the junior developers** to follow the new hook standard (use the
  ON/OFF toggles, not manual border/shadow/radius/curve values). **Adi will review the juniors' work**
  for compliance with the standard.
- **⏸ Brentwood 100 page — PAUSED, needs Rian's decision.** `brentwooddev.demoing.info/brentwood-100`
  isn't showing 100 entries and its order differs week-to-week. Investigation (findings in
  `brentwooddev/brentwood-100-content-inventory.md` + `brentwood-100-db-dump.json`) shows **the LIVE
  site is now 87 items (not 100), and the live page randomizes display order on every visit** — so the
  changing order is expected, not a bug (see memory `project-brentwood-100-randomized-order`).
  **Question for Rian:** should the WP build just **follow the live site** (87 items, randomized /
  non-chronological), or do we want a fixed count / chronological order? Holding build until confirmed.
- **📊 Progress snapshot: 147 of 217 pages done.** **Main-menu (top-level) pages are ready for review —
  DESKTOP version only (mobile NOT ready to review yet).** **Inner-menu (sub) pages still in progress**
  (includes finishing the Kadence-hook conversion + the inner-page border verification above).
  (Use this when Rian asks "what's the progress.")
- **Page build tracking:** the page-by-page build status is tracked in this Google Sheet —
  https://docs.google.com/spreadsheets/d/1deQhwwBx8WIP-BeHiJ2XzBaDs_R9JdLfG5Kf6q8oB4Q/edit?gid=0#gid=0
  (the 147/217 count comes from here; juniors update it as they build).
- **🐞 Known bugs to polish (desktop):** collecting visual bugs for a polish pass. Known so far:
  - `https://dev.brentwood.ca/why-brentwood/` — the **"Unrivalled opportunities in academics, arts,
    and athletics."** section is **not filling its height** (column/row equal-height issue).
  - Likely others in similar sections elsewhere — to be found and fixed during the polish pass.
- **📅 Milestones (Rian) — status check vs THIS week, assessed 2026-06-29:**
  - ✅ **Wk Jun 1** — kickoff + FinalSite discovery call.
  - ✅ **Wk Jun 15** — staging accessible, early page previews (now `dev.brentwood.ca`).
  - 🟡 **Wk Jun 29 (NOW)** — *"review ALL pages incl. core admissions/brochure + custom calendar,
    livestream, video; not polished, Brentwood gives critical feedback."*
    - ✅ Core admissions/brochure pages reviewable on **desktop** (admissions, apply, academics,
      athletics, arts, student-life all load on dev).
    - ✅ Custom features live on dev: **calendar** (`/calendar`), **livestream** (`/live`,
      `/livestreams`), **video** hero (home, why-brentwood).
    - 🟡 **Not yet "ALL" pages** — 147/217 done; ~70 inner-menu pages still in progress.
    - 🟡 **Desktop only** — mobile not ready to review. (Polish bugs are expected/acceptable — the
      milestone itself says "will not be polished".)
  - ⬜ **Wk Jul 8** — feature-complete site ready for review.
  - ⬜ **Wk Jul 27** — final site for review + recorded editor training + admin docs delivered;
    Brentwood team can start editing content on staging.
  - ⬜ **Wk Aug 8** — pick a go-live date and cut over.
  - **Catch-up plan for this week:** keep the core pages + the 3 named features presentable on desktop
    (largely there), and push the remaining inner pages forward (juniors → hook standard, Adi reviews).
    Be upfront with Rian/Brentwood that ALL-pages review is **partial** today and **mobile + polish are
    deferred** (consistent with the milestone wording).

### 2026-06-23 — Rian + Claude — push-brentwood-dev lock + symlink bugs FIXED

- **Both issues from Adi's "stale push-lock" note below are fixed in `deploy/push-to-dev.sh`:**
  1. **Lock:** moved from `/tmp/bwdev-push.lock` (owned by whoever ran first → broke for everyone
     else) to a **shared group-writable lock** `deploy/.push.lock` (664 `brentwooddev-dev`). Works for
     rian/adi/Claude and still enforces single-runner. Stale `/tmp` lock removed.
  2. **Symlink:** `DEPLOY` now uses `readlink -f "$0"`, so the `/usr/local/bin/push-brentwood-dev`
     symlink resolves to the real deploy dir (was resolving to `/usr/local/bin` → `deploy_key` not found).
- Validated: symlink→real script, DEPLOY correct, deploy_key found, lock acquired + group-writable.
- **@Adi:** the canonical `push-brentwood-dev` works for you now — no workaround needed.

### 2026-06-23 — Adi + Claude — Pushed to dev.brentwood.ca + ⚠️ stale push-lock (FOR RIAN)

Pushed today's work (search sidebar filter, boxed Page layout, footer element, lead-icon fix, plus
the timetable/calendar block updates) to the DO droplet. **Verified live (behind the gate):**
homepage 200, `?s=regatta` 200, 0 PHP fatals; sidebar facets, boxed layout, and footer element 22041
all present on dev.

**⚠️ FOR RIAN — `push-brentwood-dev` is broken for non-rian users (stale lock):**
- The script does `exec 9>/tmp/bwdev-push.lock` then `flock`. That lock file exists as
  `-rw-rw-r-- rian rian /tmp/bwdev-push.lock` (created Jun 19 14:47, size 0). `/tmp` is sticky, so
  **adi can neither write to it (open-for-write → "Permission denied") nor delete it** (not the
  owner, no sudo). Net effect: the canonical `push-brentwood-dev` command **fails immediately for
  adi/Claude** with `…/bwdev-push.lock: Permission denied`.
- **Workaround used this run** (so today's deploy still went out): ran a copy of
  `deploy/push-to-dev.sh` with only two lines changed — `DEPLOY` pinned to
  `/srv/apps/brentwooddev/deploy`, and the lock redirected to an adi-writable path. Byte-identical
  otherwise. The real script was **not** modified (it's `rian`-owned, group has no write).
- **Permanent fixes (pick one, owner action):**
  1. `rm /tmp/bwdev-push.lock` — clears it, but it'll just reappear owned by whoever pushes next,
     re-breaking it for everyone else. (Quick, not durable.)
  2. **Better:** edit `deploy/push-to-dev.sh` line 34 to a **per-user** lock path, e.g.
     `exec 9>"/tmp/bwdev-push.$(id -u).lock"` (or `${XDG_RUNTIME_DIR:-/tmp}/bwdev-push.lock`). Then
     the lock never collides across users and the canonical command works for adi + Claude again.

### 2026-06-23 — Adi + Claude — Search results: sidebar post-type filter + boxed Page layout + footer element

Built on yesterday's search work. All child-theme (`kadence-child`); no infra/security changes.
Touched: `inc/brentwood-search.php`, `search.php`, `assets/bw-searchpage.css`, new
`assets/bw-searchpage.js`.

1. **Sidebar post-type filter** (mirrors Laravel `global-search` sidebar) on the results page.
   Facets: **Pages** (`page`+`landing`), **Livestreams** (`livestream`), **Courses** (`course`),
   **Blogs** (`post`). "**N Results**" heading + custom round checkboxes + a **Per Page** stepper
   (number input + `+`/`−`, default 10, clamped 1–100).
   - URL-driven GET form: `?s=…&type[]=pages&per_page=20` — bookmarkable, paginated, SEO-clean.
     `pre_get_posts` maps the selected facets → `post_type` and applies per-page. `paginate_links`
     auto-preserves the query string.
   - `bw-searchpage.js` = progressive enhancement: toggling a facet / clicking the stepper
     auto-submits. No-JS fallback = a red **"Apply filters"** button (hidden once JS adds
     `.is-enhanced`).
   - **Laravel has 6 facets; we built 4.** *Announcements* and *Photos* have no WP content type
     here (no `announcement` post type; "photos" = media attachments, out of search scope). The
     facet map (`bw_search_facets()`) is filter-driven — add them in ~3 lines once those types exist.
   - Verified live: `?s=test` → 977 (all), `type[]=pages` → 25, `type[]=courses` → 3, `per_page=5`
     → 5 rows; checked-state + per_page render correctly; no PHP errors.

2. **Results page now matches a regular Page** (boxed layout). Previously `.bw-searchpage` was a
   bare centred div — no boxed card, no gradient, no site-width. `search.php` now wraps the content
   in Kadence's real chain: `.content-container.site-container → #main.site-main → .content-wrap →
   article.entry.content-bg.bw-search-entry → .entry-content-wrap`, so **width + boxed card come from
   the customizer**. Kadence's Additional CSS scopes the **white→`#dcf2fa`→white gradient** and the
   **card box-shadow** to `body.page .content-bg`; the search body has no `.page` class, so we
   re-assert both in `bw-searchpage.css` (`body.search-results .bw-search-entry.content-bg`) using
   the same `--global-palette9/7` vars (still tracks the customizer palette). Inner padding added on
   `.bw-searchpage--filtered` (`.entry-content-wrap` padding is 0 site-wide here).
   - Verified **pixel-identical** to `/admissions/` via headless-Chrome measurement: `.content-container`
     1280px/left 73, `.content-bg` left 73 (aligns w/ header), gradient + shadow byte-for-byte equal.

3. **Footer Kadence element** added on the results page: `echo do_shortcode('[kadence_element
   id="22041"]')` (the published "Footer" element), placed **inside** `.bw-searchpage.bw-searchpage--filtered`
   (after the results layout), per Adi. The theme's normal `get_footer()` still follows after the card.

4. **Bug fix:** removed the left lead magnifying-glass icon (`.bw-searchform__lead`) from the
   results-page search bar and reclaimed its 3rem left padding (CSS-only, scoped to
   `.bw-searchpage--filtered`; the 404 bar keeps its lead icon).

**Also updated this work period (per Adi):** the **Brentwood Timetable** and **Brentwood Calendar**
blocks (`inc/brentwood-timetable.php`, `inc/brentwood-calendar.php` + their `blocks/` assets). Those
edits were made outside this search-focused session — specifics not captured here; see the block
files. (Calendar = aggregates Brentwood's 5 public Google Calendar **ICS** feeds, no API/credentials,
two-tier transient cache; Timetable = static weekly grid + a "Timetable Management" admin page for
editing copy/images.)

**State:** all verified live on `brentwooddev`; CSS/JS apply immediately, PHP changes applied via
`srv-gw restart --project brentwooddev`.

**Still NOT pushed to dev.brentwood.ca** (carries over from 2026-06-22). Run `push-brentwood-dev`
when ready — this will include all of today's search work plus the timetable/calendar block updates.

### 2026-06-22 (evening) — Adi + Claude — Header global search + Laravel-style results page

Mirrored the Laravel `global-search` UX in WordPress. All child-theme (`kadence-child`); no infra/
security changes. New files: `inc/brentwood-search.php`, `assets/bw-search.{css,js}`,
`assets/bw-searchpage.css`, `search.php`, `404.php`.

1. **`[bw_search_icon]` shortcode** — magnifying-glass icon (FA path, Brentwood-red header) that
   expands a **centred dropdown** below the header: large grey search icon, italic "Search Brentwood…"
   placeholder, attached clear (×) cell, **live results as you type** via the WP REST search endpoint
   (`/wp-json/wp/v2/search`). Esc closes, backtick opens, click-outside closes. Enter → full results
   page. Placed in the Kadence header HTML element (`theme_mods_kadence-child → header_html_content`).
   - REST quirk noted: this install **ignores the `subtypes` param** (returns every post type incl.
     `kadence_element`), so the JS filters to user-facing types client-side (allowlist).
2. **Removed the old green search.** The first-party `bw-dev` plugin's *Inline Search* module was
   auto-injecting a second, teal (`#4bcac0`) search into `.site-header`. Per Adi ("keep new, drop
   old") it's **disabled on this site only** via the plugin's own per-site setting
   (`bw_dev_settings → modules → inline_search = false`) — NOT a code edit. ⚠️ If anyone opens the
   bw-dev plugin settings page and hits Save, the checkbox may re-enable it; untick "Inline Search".
   (A theme `bw_dev_module_enabled` filter does NOT work — the plugin boots on `plugins_loaded`,
   before the theme loads.)
3. **Search results page = `search.php`** (Kadence has none; it fell through to `index.php`). Rich rows
   reverse-engineered from Laravel's `global-search-row` + `/global-search/search` endpoint: **type
   icon** (blog/file/youtube/bullhorn) + **title** + **highlighted preview excerpt** (`<strong>` on
   matched words >2 chars) + **breadcrumb › + date** (dashed top rule) + **thumbnail** (1/4 width,
   hidden on mobile, fills row height). Count line + `paginate_links` (10/page, red current).
   Searchable types restricted via `pre_get_posts` to `post, page, livestream, course, landing` —
   **`staff` excluded** (honours the noindex/exclude-from-search note). "grad" → ~1,977 results
   (Laravel ≈2,121, comparable).
4. **`404.php`** — "Page not found" + the inline search bar (mirrors Laravel's 404).

**Intentional deviation:** Laravel's inline 404/results search is live-as-you-type; the WP **results
page is server-rendered + paginated** (shareable URLs / SEO / real pagination), while the **header
dropdown stays live**. If Rian/Adi want the results page itself to update live without reload, that's a
follow-up.

**State:** verified live — search page HTTP 200, 404 correct, no PHP errors; layout confirmed via
headless-Chrome render of the real output (matches Laravel). CSS/JS apply immediately; PHP changes
applied via `srv-gw restart --project brentwooddev`.

**Not done yet (Adi went offline):** **NOT pushed to dev.brentwood.ca.** Run `push-brentwood-dev`
when ready. Optional polish offered but not done: live-update on the results page; enriching the header
dropdown rows with thumbnails/excerpts like the results page.

### 2026-06-22 — Session wrap-up (Adi → afternoon break, Indonesia)

Day's work on `brentwooddev` (all child-theme + ACF; no infra/security changes). Details in the
entries below; quick recap:

1. **Hero frame → 16:9** so the Featured Image "horizontal focus" can actually pan (was a no-op in
   the old 2.35:1 banner). All framed image/video heroes are a bit taller now.
2. **Fixed the focus picker not showing on POST editors** — root cause was 7 stale duplicate ACF DB
   copies of the hero group (page-only) shadowing the acf-json. Hardened `bw_hero_post_types()` to
   read the JSON file, and cleaned the dupes.
3. **ACF DB audit + cleanup** — removed stale `group_bwm_blog` duplicates; migrated the DB-only
   "Page Settings"/`footer_image_background` group into version-controlled acf-json (keys preserved,
   416+ values intact, now editable across all post types; retired the runtime extender file).
4. **Featured Image height control** — new "Featured image — height" field (presets + custom),
   mirroring the carousel via a shared `bw_hero_frame_height()` helper. Default keeps 16:9.

**State:** everything verified working on the live editor + front end. `acf-field-group` DB table is
now free of stale dupes (only course/staff/livestream remain, in sync). No outstanding breakage.

**Open / nice-to-have (not blocking):**
- Editor focus-picker preview is fixed 16:9 — won't mirror a custom Featured-Image height (cosmetic).
- The 21→ remaining non-hero `acf-field` rows are the 3 in-sync CPT groups; fine, but other groups
  weren't deep-audited for dupes.
- Page **22088** editor returns a `403` at the WAF layer (only that page) — unrelated to our work.

### 2026-06-22 — Adi + Claude — Featured Image hero: optional height control (like carousel)

- **Want:** on hero type = Featured Image, control how tall the image shows on the front end —
  the same kind of height setting the Carousel already has.
- **Added** two ACF fields to the hero group (`group_bwm_page_hero`, conditional on `hero_type==image`):
  `hero_image_height` (select: Default — fit 16:9 / 320 / 380 / 460 / 560 / 680 / 768 / Custom) and
  `hero_image_height_custom` (number px). Default = "fit (16:9)" so **existing pages are unchanged**.
- **DRY:** replaced `bw_hero_carousel_height()` with a generic `bw_hero_frame_height($post_id,
  $base_field, $fallback)` shared by carousel AND image (carousel passes fallback 380 and never picks
  "default"; image passes 0 → "default"/empty returns 0 = keep the aspect frame). When a height is
  chosen, `bw_render_hero()` adds `bw-phero__media--fixed-h` + `--bw-phero-h:<px>` and CSS
  (`assets/page-hero.css`) switches that frame from the 16:9 padding to a fixed height (image still
  `object-fit:cover`, focus point + zoom still honoured). Mobile scales ×0.68 like the carousel.
- **Files:** `acf-json/group_bwm_page_hero.json` (+2 fields, bumped `modified`), `inc/brentwood-hero.php`
  (generic height helper + `img_h` in `bw_hero_data` + image branch in `bw_render_hero`),
  `assets/page-hero.css` (`--fixed-h` rule). PHP change → needed `srv-gw restart`.
- **Verified:** field renders on the editor; setting 560px → front end emits
  `bw-phero__media--fixed-h; --bw-phero-h:560px`; "Default" → plain `bw-phero__media` (16:9, unchanged);
  carousel still returns its height (no regression). Minor: the editor focus-picker preview is still
  fixed 16:9, so it won't mirror a custom height exactly — cosmetic, left for later.

### 2026-06-22 — Adi + Claude — ACF DB audit cleanup: blog dupes removed, footer group → Local JSON

Followed the hero-group fix (below) with a full audit of every `acf-field-group` DB row vs the
`acf-json` source of truth. Adi took an UpdraftPlus theme+DB checkpoint first.

- **Audit result:** course/staff/livestream each had ONE DB row, field counts matching their JSON →
  in sync, left untouched. `group_bwm_blog` had **2 stale dupes**. `group_6a2967c17a264` ("Page
  Settings" → `footer_image_background`) was **DB-only with no acf-json** — live (rendered by the
  Footer Kadence Element, 400+ pages have values) but unprotected. No orphaned fields.
- **`group_bwm_blog` — deleted 2 stale dupe DB rows + 10 child fields (12 posts).** Both were the
  pre-unification blog group (still carried the old `hero_type`/`hero_gallery`/`hero_video*` fields
  that moved into `group_bwm_page_hero` on 2026-06-19). Current JSON blog group = `blog_author` only.
  Now JSON-only. (Same `wp post delete --force` caveat as the hero entry: re-check row counts.)
- **Footer "Page Settings" → migrated to Local JSON** (`acf-json/group_6a2967c17a264.json`), keeping
  the group key `group_6a2967c17a264` and field key `field_6a2967cd34b0c` **verbatim** so the saved
  `footer_image_background` values keep resolving. Location is now baked into the JSON for all hero
  post types (page, post, staff, livestream, course, landing) — so the runtime
  `acf/load_field_group` extender is redundant: **removed `inc/brentwood-footer-image.php`** and its
  `require_once` in functions.php. Then deleted the DB group row + child field. Verified end-to-end:
  `acf_get_field_group` source=json, 6 location types from JSON alone, `get_field('footer_image_background')`
  on a real page returns the image, and the field renders on page (24145) + post (7248) + staff (288)
  editors. Field values intact (~420 rows). acf-field-group DB now holds only course/staff/livestream.
- **acf-field-group DB end state:** group_bwm_course, group_bwm_livestream, group_bwm_staff (1 row each,
  in sync). hero / blog / page-settings are Local-JSON-only. The 21 non-hero `acf-field` rows from the
  earlier note were these 3 in-sync groups (3+5+2) + the blog (10, now gone) + footer (1, now gone).

### 2026-06-21 — Adi + Claude — Focus picker missing on POST editor (stale duplicate ACF DB groups)

- **Symptom:** the visual "Hero focus" picker (image preview + draggable dot) shows on the
  **Page** editor but not the **Post** editor — posts only get the plain numeric sliders.
- **Diagnosis (verified on the live admin):** on the post-editor request, the picker assets
  (`hero-focus-admin.js`/`.css`) weren't enqueued at all. The enqueue gate keys off
  `bw_hero_post_types()`, which derived the list from `acf_get_field_group('group_bwm_page_hero')`.
  In the **web admin** that returned a **page-only** location → list was just `[page]` → gate
  excluded `post`. The CLI saw all 6 types (it read the acf-json). Root cause: **7 stale, duplicate
  DB copies** of the hero field group (`wp_posts`, post_type `acf-field-group`, all dated
  2026-06-12/13, *before* the 2026-06-19 unification), every one with location = `page` only. They
  shadow the up-to-date `acf-json/group_bwm_page_hero.json` (6 types) when ACF resolves the group by
  key in the admin. The hero *fields* still render on posts (ACF displays them from the local JSON),
  but `acf_get_field_group()['location']` returned the stale page-only set.
- **Fix (code):** `bw_hero_post_types()` now reads the **acf-json file** as the source of truth
  (timing-independent, immune to stale/duplicate DB rows), falling back to `acf_get_field_group()`
  then the hardcoded list. Added helper `bw_hero_loc_post_types()`. Verified: picker assets now
  enqueue on the post editor; `bw_hero_post_types()` = page,post,staff,course,livestream,landing.
  File: `inc/brentwood-hero.php`. PHP change → needed `srv-gw restart` (opcache).
- **Data cleanup DONE (2026-06-21, after Adi took an UpdraftPlus theme+DB checkpoint):** hard-deleted
  the **7 duplicate `acf-field-group` DB rows** for `group_bwm_page_hero` (IDs 22607/22614/22631/22697/
  23024/23034/23045 — partial 3–8-field imports, page-only location) **+ their 46 orphaned child
  `acf-field` rows** (53 posts total). The 21 `acf-field` rows belonging to OTHER groups were left
  untouched. Verified: 0 rows named `group_bwm_page_hero` remain; post editor still renders the hero
  fields (group now resolves cleanly from acf-json), picker assets enqueue, no "sync available" nag.
  Note: a combined `wp post delete groups+children` reported success but the GROUP rows survived the
  first pass (cache/txn quirk) — re-running delete on the 7 group IDs alone removed them. If verifying
  a bulk acf-field-group delete, re-check the row count, don't trust the success lines alone.
- **Latent (broader):** there are still 21 DB `acf-field` rows for other groups — some may be stale
  dupes too (blog/course/etc.). Not investigated; the JSON-first read in `bw_hero_post_types()` only
  defends the hero group. Worth an audit if other field groups show editor oddities. This whole desync
  pattern can reappear after UpdraftPlus/import restores.

### 2026-06-21 — Adi + Claude — Hero frame → 16:9 so "horizontal focus" actually pans

- **Symptom:** "Featured image — horizontal focus" (ACF `hero_image_focus_x`, in the Hero group)
  showed in the editor but seemed to do nothing on single posts.
- **Diagnosis (not a bug — geometry):** the whole pipeline was already wired and correct —
  picker (`hero-focus-admin.js`) → ACF save → `bw_hero_focus_style()` emits `object-position` on
  the front end (verified: setting fx=10 rendered `object-position:10% 50%` on the single post).
  It looked dead because (a) every post still had the default `50` (at 50/50/zoom100 we emit no
  `object-position`), and (b) the desktop hero frame was a wide **2.35:1** banner (`padding-bottom:42.5%`).
  With `object-fit:cover`, a normal landscape photo (post 7248 img is 950×476 ≈ 2:1) fills the frame
  *width* and only overflows vertically → no horizontal slack → horizontal focus is a visual no-op
  on desktop (only vertical pans). Editor preview used the same 2.35:1, so it looked dead there too.
- **Decision (Adi):** make the frame taller. Desktop framed hero is now **16:9** (removed the
  `@media(min-width:768px){…42.5%}` override; base was already `56.25%`). A 2:1 image now overflows
  the 16:9 frame *horizontally*, so horizontal focus pans (verified: fx=12 → visible `object-position:12% 50%`).
  Trade-off accepted: heroes are a bit taller on every page/post and less like the live cinematic banner.
- **Files:** `assets/page-hero.css` (frame ratio + comment); `assets/hero-focus-admin.css`
  (`.bw-focuspick--image` preview crop 1152/490 → `16/9` to match). CSS-only — no opcache restart.
- **Affects:** image + video framed heroes (now 16:9). Carousel keeps its fixed height; full-bleed
  home video unchanged. Note both focus axes are only ever meaningful relative to each image's aspect
  vs the frame; 16:9 keeps both useful for typical photos but a panorama still pans mostly horizontally.

### 2026-06-20 — Adi + Claude — Print: desktop-only `[bw_print]` + dedicated print stylesheet

Theme work on `brentwooddev.demoing.info` (kadence-child only; nothing pushed to the DO droplet).
This closes the "print picks up the mobile layout" bug noted yesterday.

- **`[bw_print]` now desktop-only.** The Print button is hidden on tablet (≤1024px) and phone, and
  shows only on desktop (≥1025px) — printing only makes sense from a real screen. Pure CSS in the
  shortcode's inline `<style>` (`@media (max-width:1024px){.bw-print{display:none!important}}`), so
  it works no matter where `[bw_print]` is placed and survives caching/resize (no device sniffing).
  Existing `@media print` hide (button never appears *in* the printout) kept. `functions.php`.
- **New global print stylesheet — `assets/print.css`.** Enqueued site-wide with `media="print"`
  (in `kadence_child_enqueue_styles`), so it's only fetched/applied when printing — **zero cost on
  screen**. This is now the single source of truth for print.
- **Approach: clean *document* print (not pixel-faithful).** First attempt kept a "compact hero
  image" + minimal resets — but a real print of the homepage showed it was still ugly (Adi sent a
  PDF). Root causes found by inspecting the live DOM: the homepage is built from **full-bleed CSS
  `background-image` sections** (don't print, left a **blank page-1** where the hero was), Kadence
  rows with large **min-heights/padding** (whole pages of whitespace), and a **CSS-grid blog** using
  an aspect-ratio padding hack with **absolutely-positioned images** (overlapping titles/body +
  right-column clipping across page breaks). Decorative logos (centennial torch, accreditation
  badges) printed full-size, one per page.
- **What `print.css` now does:** `@page` 1.6cm; black-on-white, backgrounds/decoration dropped;
  `min-height:0` + trimmed Kadence row padding to kill reserved whitespace; **hero media hidden**
  (keep just the text card); **blog grid → wrapping 2-up flex** with the aspect-hack/absolute image
  neutralised so cards flow, don't overlap, don't clip, and break cleanly (`break-inside:avoid`);
  content images/logos capped (≤6cm) so none eats a page; hides header, main/mega/mobile nav,
  `.bw-subnav`, Kadence footer **and** the custom `.bw-footer` block, curve, scroll-to-top, the print
  button, comments/author box, blog search+pagination; page-break hygiene + orphan/widow control.
  Link URLs not expanded inline (easy to flip on per request).
- **Result: 7 ugly pages → ~3 clean ones.** Blank hero page gone; blog cards no longer overlap or
  clip; logos tamed.
- **VERIFIED with a real render**, not just the enqueue: rendered the homepage to PDF via headless
  Chrome + DevTools (auth cookie injected, print media emulated) and eyeballed it. Test PDF at
  `/tmp/bw_print_test.pdf`.
- **Component print rules left in place** (page-hero.css, bw-curve.css, subnav) — harmless and
  co-located; `print.css` is the global layer on top.
- **Known minor leftovers (cosmetic):** a small stray red vertical bar on the homepage print; blog
  thumbnails may drop if lazy-loaded images haven't loaded; some section whitespace + a possible
  trailing blank page. None are blocking — tighten on request.
- **Note:** the `[bw_print]` (PHP shortcode) change needed `srv-gw restart --project brentwooddev`
  (php-fpm opcache) — done. The CSS (print.css) is live immediately, no restart.

### 2026-06-20 — Rian + Claude — Disabled hero video loading spinner (CSS)

- **@Adi FYI:** Rian found the hero video's loading overlay (the `.bw-phero__loader` spinner +
  logo) annoying — it lingered over a video that was already playing. **Hidden via CSS only:**
  `.bw-phero__loader{display:none !important}` appended to `assets/page-hero.css`. Fully reversible
  (delete that one rule); your `bw_hero_loader()` markup + `page-hero.js` are untouched. If you'd
  rather *keep* the loader but make it vanish the instant the video plays, that's a `page-hero.js`
  fix instead — shout if you want to go that route.

### 2026-06-19 — Adi + Claude — Hero unified into ONE ACF group across all post types

Theme + data work on `brentwooddev.demoing.info` (kadence-child only; nothing pushed to the DO
droplet). **Adi took an UpdraftPlus backup before this.**

- **Problem:** the hero existed as two divergent ACF groups — rich "Page Hero" (focus, youtube,
  home_video, repeater galleries) on **pages only**, and an older simpler "Blog Details" hero
  (no focus, flat gallery, different field keys) on **posts**; CPTs had none. Same concept,
  maintained twice, guaranteed to drift.
- **Fix — one source of truth.** `group_bwm_page_hero` renamed **"Hero"** and now attaches to
  **page, post, staff, course, livestream, landing**. Hero fields removed from `group_bwm_blog`
  (now just `blog_author`). Blog posts now get the **full Page-grade hero UI**.
- **Defaults:** `hero_type` stays `image` for page/post; **forced to `none` for
  staff/course/livestream/landing** via `acf/load_value` in `inc/brentwood-hero.php`, so those
  show no hero unless explicitly set (per Adi's ask: Course/Staff default off).
- **Data migration:** all **123 gallery posts** converted from the legacy flat `hero_gallery`
  field → the new `hero_gallery_rows` repeater (centered focus). Idempotent, non-destructive
  (legacy meta kept as fallback). The 5,893 `image` posts needed **zero** data change (values
  read by field name). Verified: 0 unmigrated, renderer draws galleries from the repeater.
- **Rendering:** `landing` now renders a hero like a page; staff/course/livestream prepend the
  hero inside their own renderers (no-op when off — verified). One renderer (`bw_render_hero`),
  one style handle (`bw-page-hero`) — unchanged.
- **Admin focus picker now everywhere:** the visual "Featured Image — focus point" picker (and
  the per-slide gallery/carousel focus pickers) were page-editor-only; now they load on every
  hero-enabled editor (post, landing, staff, course, livestream). The post-type list is derived
  from the Hero group's own location rules via `bw_hero_post_types()`, so it auto-tracks the group
  (add a location rule → picker + default-off logic follow, no extra code).
- **⚠ STATUS: featured-media consistency is IN PROGRESS, not done.** Known open bug: the focus-point
  **preview** (the draggable dot over the image) is **still not showing on Adi's end** for single
  blog post / landing, even though the focus *sliders* render. The enqueue gate was the cause
  (php-fpm opcache served the old page-only code); the container was restarted to clear opcache.
  **Adi will retest online and continue this later.** If it's still missing on a post that *has* a
  featured image after a hard-reload, it's a browser/JS-DOM issue in the post editor, not the enqueue.
- **Note re php-fpm opcache (this site):** PHP code edits do **not** go live until the container is
  restarted (`srv-gw restart --project brentwooddev`) — opcache isn't revalidating timestamps. ACF
  JSON, CSS and JS changes apply immediately. This is why the sliders (JSON) appeared but the picker
  (PHP enqueue) didn't until the restart.
- **Docs:** `acf-json/README.md` updated (group table, consolidation note, gotchas).
- **Note for Rian (low priority):** there are duplicate stale `acf-field-group` rows in the DB
  (7× "Page Hero", 2× "Blog Details") from past syncs. They're **inert** — ACF uses the local
  JSON (`local: json`, `ID: 0`) — but worth a one-time cleanup if you ever re-sync ACF to the DB.

**Also today — Adi / team (not visible in theme file diffs):**
- **New junior joining:** Adi is bringing on Stevan Stenlly Sinaga to help. Same graduating year as
  Zeina and Rahman (same inner circle). He's seems picked up the dev workflow right away.
- **Adi works closely with all juniors via a Discord channel.**
- **⚠ Footer still needs polish (Adi):** not fully matching the live site yet — adjustments still
  needed. Adi will polish it.
- **✅ Print CSS — DONE 2026-06-20** (was: print picked up the **mobile** layout). Fixed via a new
  global `assets/print.css` + desktop-only `[bw_print]`. See the 2026-06-20 entry above.
- **Page build (junior):** **49 pages total** in progress. Tracker:
  https://docs.google.com/spreadsheets/d/1deQhwwBx8WIP-BeHiJ2XzBaDs_R9JdLfG5Kf6q8oB4Q/edit?gid=0#gid=0
- **Mega menu** updated by Adi — made a bit more **compact** and more **consistent**.
- **Nav sub-menus reordered** by Adi to match the **live site** ordering.
- **⚠ Mobile menu NOT updated yet** — it's a **separate** menu from the main/mega menu, so the
  compacting + reordering above do **not** apply to it. Adi will **sync the mobile menu when online
  tomorrow (2026-06-20)**.

**Block changes today (detected from child-theme file diffs):**
- **NEW block — "Brentwood Table Accordion"** (`bw/table-accordion`) + inner **"Accordion Item"**
  (`bw/accordion-item`): a bordered link table/grid whose cells expand a rich content panel below.
- **Updated — "Brentwood Table Link"** (`bw/table-link`): editable links as a bordered table / link
  grid with configurable columns.
- **Updated — "Brentwood Hero (Manual)"** (`brentwood/video-hero`): block.json/editor/render/style +
  a new `frontend.js`. (Reminder: this manual hero is normally not needed — heroes render
  automatically from the Hero ACF fields.)

**Single (detail) templates — READY FOR RIAN'S REVIEW:**
- **Single Staff** — e.g. https://brentwooddev.demoing.info/staff/stephanie-roth/
- **Single Livestream** — e.g. https://brentwooddev.demoing.info/livestreams/284/
- **Single Course** — e.g. https://brentwooddev.demoing.info/courses/philosophy-politics-economics-12-ppe-12/
- **Single Landing — NOT done yet** (still to build).

**⚑ FOR RIAN (Adi/Claude take no action on these):**
- **Push to Digital Ocean.** Adi considers this a significant update; Rian suggested pushing it to the
  DO dev droplet (`dev.brentwood.ca`, via `push-brentwood-dev`). **Rian to do this** — Adi hasn't
  tested whether his role can run the push. Supersedes the earlier "next push ~next week" note.
- **Create the client's backend WP user** so they can view the WordPress admin if needed. **Rian to
  do this** (role + username/email at Rian's discretion). Recorded as a note only.

### 2026-06-18 — Rian + Claude — Mirror admin login FIXED (password now fixed + survives syncs)

- **Resolved Adi's ⚑ mirror-admin blocker.** Root cause was twofold: `sync-mirror.sh` (step 5)
  **rotated the admin password to a new random value every sync**, AND wrote it to
  `mirror-login.txt` which is **rian-only** — so Adi could never read it and any password he had
  was invalidated on the next pull.
- **Fix:** the mirror admin password is now **fixed/standard** — stored in `.mirror-admin-pw`
  (rian-only) and baked into `sync-mirror.sh`, so it **persists across every future sync**. Reset
  applied now: `admin@mirror.test` (admin role) works with that password.
- **@Adi (read by Adi 2026-06-19):** Rian has the permanent password and will pass it to you
  out-of-band — once you have it, it stays valid through future syncs (no more re-lockouts).
  Login: https://brentwood.demoing.info/login.

### 2026-06-18 — Adi + Claude — Livestream single finished + Staff single template built

Theme work on `brentwooddev.demoing.info` (kadence-child child theme only; nothing pushed to
the DO dev droplet — next push ~next week per Adi).

- **Single Livestream view — now self-contained.** The single template
  (`/livestreams/{id}`) reproduces the live Laravel page on its own, so the planned separate
  Kadence Element is no longer needed:
  - Click-to-play YouTube player, widened to the reference **1152px**; consistent top/side padding.
  - Title + date + recording note moved **below** the video into a boxed card (Kadence's own title
    suppressed via a `kadence_post_layout` filter).
  - **Event lists integrated into the template** (Upcoming + Completed) by rendering the existing
    `bw/youtube-list` block inline via `render_block()`. Added a new **`exclude`** option to that
    block (block.json + helpers + render.php + the REST endpoint) so the lists omit the stream
    you're currently watching — mirrors Laravel's `exclude_id`. AJAX search/pagination still work.
  - Files: `inc/brentwood-livestream.php`, `assets/livestream.{css,js}`,
    `blocks/youtube-list/{block.json,helpers.php,render.php}`, `inc/brentwood-blocks.php`.

- **Single Staff template — built (new).** `/staff/{slug}` now has a proper template
  (e.g. `/staff/stephanie-roth/`). **Decision:** staff get a real single page **+ modal reuse**
  (per Rian's ask + PROJECT-BRIEF) — this intentionally supersedes `MIGRATION-DECISIONS.md`'s
  "modal-only, no standalone pages" note. Staff stay **noindex / sitemap-excluded via Yoast for
  now** (Adi handling); hiding the standalone pages is deferred.
  - Reuses the **same profile data/typography + shield footer** as the `bw/team-modal` /
    `bw/instructor` pop-up (shared `inc/bw-staff-profile.php`), so the single page and the modal
    stay visually consistent.
  - **Two-column layout aligned to the 33% red accent line:** featured photo as a widget on the
    **left** (left of the line); H1 name → underline → role → credentials → bio on the **right**
    (right of the line); shield footer below. Padding made consistent with `/why-brentwood/`.
  - Kadence's own title **and** featured image suppressed via `kadence_post_layout`
    (`title`/`feature` => hide) so neither is duplicated (the profile renders its own H1 + photo).
  - Files: NEW `inc/brentwood-staff.php`, NEW `assets/staff.css`; edited `inc/bw-staff-profile.php`
    (optional heading-level arg) + `functions.php` (require).

- **Next:** Adi moving to the next task after an afternoon break. Open item below (mirror admin)
  still needs Rian.

### 2026-06-18 — Adi — ⚑ Need mirror admin access (`admin@mirror.test`) — ACTION FOR RIAN

- **@Rian — blocked, need your help.** Last night's live-DB sync into the Laravel mirror
  (`brentwood.demoing.info`) replaced its users table, so the local `admin@mirror.test` login no
  longer works. Verified: a real login POST with the password I had returns `302 → /login`
  (auth fails). I **can't recover** the old password — Laravel stores only a one-way bcrypt hash,
  so there's nothing to read back. And I can't reset it myself: the mirror's MySQL is a container
  with no host port, and developers can't run `docker`/`php artisan` (correctly — that's the
  security model).
- **What I need (either is fine):**
  1. **Set a known password on `admin@mirror.test` and share it with me out-of-band** (password
     manager / 1Password / secure note — *not* in chat or this file, to keep secrets out of the
     transcript + diary). Reset one-liner (you have Docker):
     ```bash
     docker exec brentwood-app php artisan tinker --execute="\$u=App\Models\User::firstOrNew(['email'=>'admin@mirror.test']); \$u->name='Mirror Admin'; \$u->password=bcrypt('REPLACE_WITH_PASSWORD'); \$u->email_verified_at=now(); \$u->save(); if(!\$u->hasRole('admin')){\$u->addRole('admin');} echo 'ok '.\$u->id;"
     ```
  2. **OR give me a safe way to reset it myself** so I'm not blocked on you each sync. NOT the
     docker group (violates the security model). Better options: (a) add the snippet above to the
     end of `sync-mirror.sh` so the mirror admin auto-restores after every pull (then just tell me
     the password once); or (b) a scoped gateway command (e.g. `srv-gw mirror-reset-admin`) that
     runs the artisan reset inside `brentwood-app` for `brentwood-dev` members — same pattern as
     `srv-gw wp`, no direct Docker access.
- **Note:** "reading" the password isn't possible for anyone (it's hashed); the realistic ask is a
  safe **reset** path. Recommendation: option 2(a) — bake it into `sync-mirror.sh` — kills the
  recurrence permanently.

### 2026-06-17 — Adi — Read Rian's messages; staying on demoing for now

- **@Rian:** Read all four of your unread messages today (dev.brentwood.ca + push workflow, brand
  logos/`#c8272c`, Google access on rian@rian.ca, timetable.jpg). Thanks.
- **Not pushing to the DO droplet for now** — I'll keep building on `brentwooddev.demoing.info` as before.
  Planning the **next push to DO sometime next week** once there's a meaningful batch of progress.
- Recent build: single **Livestream** view added to the child theme (banner + click-to-play YouTube player
  + date + recording note), live on `brentwooddev.demoing.info/livestreams/{id}`.

### 2026-06-17 — Rian + Claude — Dev site moved to dev.brentwood.ca (DO droplet) + push workflow

- **Migrated the WordPress build from `brentwooddev.demoing.info` to the client's dev domain
  `https://dev.brentwood.ca`**, on a new DigitalOcean droplet (`dev-wp`, TOR1, Ubuntu 24.04, 4 GB). Full DB
  (~23k rows) + 5 GB wp-content moved; URLs rewritten brentwooddev.demoing.info→dev.brentwood.ca; all content
  types verified 200. HTTPS via Let's Encrypt; **password-gated** (not public).
- **Cutover** = DO Reserved-IP reassignment (`159.203.50.215` → new droplet): instant, no DNS change (we don't
  hold client DNS), fully reversible. **Old Laravel dev droplet left running, untouched, as backup** + a
  pre-WordPress snapshot retained. Live `brentwood.ca` never touched.
- **Push workflow:** `push-brentwood-dev` (one command, ~50s) pushes mosiah's brentwooddev DB + wp-content to
  the droplet — runnable by rian + adi, no docker needed, or just ask Claude. Details:
  `brentwooddev/deploy/README.md`.
- Pending: `/usr/local/bin/push-brentwood-dev` symlink needs Rian's sudo; decommission old droplet once fully
  confident.

### 2026-06-15 — Rian — Brand logos received + Google access obtained

- **Brentwood sent the brand logos** (Google Drive pack) + usage guidance → logged in `notes/BRAND-ASSETS.md`.
  Brand red `#c8272c`; OPTION 1 = horizontal full-name red mark (default); OPTION 2 = stacked crest +
  "BRENTWOOD" (space-constrained); black/reverse only over solid backgrounds. De Manu In Manum = commencement
  / Head-of-School only. Flagged for Adi (header/footer build).
- **Google access obtained** (Analytics, Search Console, the calendar GCP project `brentwood-cms-274415`) —
  granted to **`rian@rian.ca`** because the `brentwood.ca.web@gmail.com` account isn't working. Logged in
  `notes/THIRD-PARTY-ACCESS.md` (2026-06-15 update). Unblocks tracking + calendar rebuild.
- (Adi's recent build progress — ACF-JSON switch, hero system, 137 page heroes imported, blog block, etc. —
  lives in his own logs: `brentwooddev/ADI-UPDATES-2026-06-12.md` + `diary/2026-06-12-adi.md`.)

### 2026-06-03 — Rian + Claude — FULL content migration complete + page heroes nailed down

- **Full blog migration done:** all **6,016** published blogs imported (Laravel-driven by blog id, 0 failures,
  8,301 images). Hero = WP featured image (single) or ACF Featured Gallery (123 multi); bodies are **native
  Gutenberg blocks** (paragraphs/headings/lists/tables — converted from the Laravel HTML, not a wp:html blob);
  photo-block text + photo captions captured. Exact-Laravel slugs (dirty live URLs like
  `/blogs/-leading-with-his-heart` still resolve 200 via WP loose matching; 20/20 random sample = 200).
  Builder `migration/build_blog_full.py`, importer `migration/importers/import-blog-full.php` (resumable).
- **Blog content shape verified exhaustively:** the whole archive uses only paragraphs + a handful of
  lists/one table + photo galleries — no quotes/videos/embeds (those live in pages). So nothing was lost.
- **Page heroes — definitive call made.** Both the human's visual pass and a naive DB query had badly
  under-counted videos/sliders (a video header looks like a grey box and a slider like one image in a
  screenshot). Re-derived from each page's **rendered HTML** (the truth of what displays), visually
  validated. Result: **40 videos + 24 sliders** + images, written into the Migration Map `Title Area Type`
  column (per Rian's call to trust this over the human labels). **21 rows left "Needs Review"** (they 500
  on the mirror — check on live). Full per-page list: `notes/page-heroes-final.csv`.
- **Adi added** to the `brentwooddev` project.
- Everything above (with the gotchas) is in `notes/MIGRATION-DECISIONS.md`.

### 2026-06-03 — Rian + Claude — Post types built + 5 content sets imported into WordPress

**Migration target = `brentwooddev.demoing.info`** (container `brentwooddev-wordpress`), NOT `brentwoodwp`.
Decided this with Rian. Note: `brentwoodwp.demoing.info` has a **separate ~80-page build from today** that
is unrelated to this import — leave it alone; it is not the migration target.

**Foundation (code, reproducible):**
- New plugin `wp-content/plugins/brentwood-migration/` registers CPTs + taxonomies + ACF field groups in
  code (not clicked in the UI). CPTs: `staff` (/staff/), `livestream` (/livestreams/), `course` (/courses/).
  Taxonomies: `department`, `livestream_category`, `course_category`, `course_subject`, `course_grade`.
- **Installed Yoast SEO** (was missing). Staff CPT set to *noindex* (excluded from search results + XML
  sitemap) since staff are modal popups.
- Importer scripts preserved at `/srv/apps/brentwood/migration/importers/` (out of web root). The original
  staging dir `wp-content/_import/` (which briefly held inquiry **PII**) was **deleted from the web root**
  after import — do not recreate PII files under wp-content.

**Imported:**
- **Staff — 170** (115 publish / 55 draft). First+Last → title, Bio → body, Credentials + Title/Position →
  ACF, Departments → `department` terms (65), photo → featured image (103 had photos). Gunel Akhundova
  correctly draft.
- **Livestream — 289**, slugs preserved as `/livestreams/{id}`. Description → body, Category → 18 terms,
  ACF: YouTube ID, Start Date&Time, Length, Unlisted. 143 banners imported. Future-dated upcoming streams
  forced to `publish` (so they stay visible, not scheduled/hidden).
  - **DEFERRED (confirm with client):** "Will be recorded" and "Allowed Groups" were **not** imported.
    We have the data in the sheet and can add these later if the client wants them. See memory note.
- **Course — 136** (1 junk row skipped). `/courses/{slug}` preserved. ACF: Advanced Placement, Elective,
  Credit. Taxonomies: category (Academics/Arts/Athletics), subject (18), grade (9–12). **Body content
  pulled from Laravel** (`course_descriptions.body`); 14 courses legitimately have no body; **8 courses
  also have a Laravel PhotoBlock that was NOT brought in** (note for later). Page Title/Meta Desc → Yoast.
- **Inquiries — 1,155** → new Gravity Form **"Admissions Inquiries (migrated)"** (form #1). All 11 fields.
  Date Submitted → entry date; archived flag preserved as a field + `is_read` (1,143 archived/read, 12
  open/unread). Kept all **active** (not trashed) so none are auto-purged.
- **Redirects — 102** into the Redirection plugin (had to install its DB tables first; they were missing).
  81 active (regex backrefs work, e.g. `/news/single-page-news/article/X` → `/blogs/X`). **21 disabled,
  titled `[NEEDS TARGET]`** — their Laravel targets are page-id+content-anchor refs (`/58#c-UUID`) with no
  WP equivalent yet; list at `notes/redirects-needs-target.txt`. Resolve target pages, then enable.

**Pages — BUILT** (approved): 213 internal pages w/ full hierarchy, 12 landing (`/l/`), Home = front page,
131 bw-dev H1 overrides, 11 external links logged. Deferred fields (title-area/footer/locked/search-
exclude/special-perms) + body content still pending. Fixed a redirect↔page conflict (9 catch-alls disabled).

**Blogs — PILOT BUILT (10 posts), awaiting Rian's review before the full batch** (approved: native posts
at `/blogs/{slug}`, full fidelity). 10 real posts imported with assembled body (TextBlocks + PhotoBlock
images as wp:image blocks), featured images, author (ACF `blog_author`), categories, Yoast. Full 126-term
category hierarchy created. **Review URLs** in `notes/MIGRATION-DECISIONS.md`. **Gotchas for the full
6,031 run noted there** (dup-name joins, 15 degenerate slugs, scale/images, featured-vs-first-photo dup).

### 2026-06-03 — Rian + Claude — Full content audit + migration mapping sheet
- Audited **every page and content type** in the Laravel site against the data model, and built a
  comprehensive **content-migration mapping workbook**: `brentwood-pages-v12.xlsx` (project root; saved
  as versioned files to avoid the LibreOffice/SMB lock issue — latest = v12). Tabs:
  - **Migration Map** (236 pages — nav path, slug, page title, meta desc, H1, title-area type, footer
    image/colour, search-exclude, locked, special permissions, redirects). Landing pages remapped to a
    `/l/` CPT.
  - **Blog Migration** (6,031 — name, slug, publish date, author, featured image, categories).
  - **Blog Categories** (the 126-tag, 2-level hierarchy → WP categories).
  - **Staff Migration** (170), **Course Migration** (137 — Category = Academics/Arts/Athletics, plus
    Subjects, Grades, AP, Elective, Credit), **Livestream Migration** (289).
  - **Redirects** (69 hub rules) and **Inquiries** (1,155 admissions form entries — contains PII).
- **Key findings/decisions:**
  - Blogs are **~6,090**, not the proposal's "700+" — they use the existing hierarchical tag taxonomy,
    which maps cleanly to WP categories (~1,360 are untagged → a categorisation pass).
  - Pages triaged by audience: public marketing / campaign-landing (`/l/`) / current-families+students /
    internal IT (Innovations) / deprecated. Only **4 pages are truly access-gated**.
  - Header/featured-image type was **verified visually by a human assistant** (my data-derived guess was
    unreliable) and merged in.
  - **Livestream advanced features — chat, registration, moderators, restricted-visibility — confirmed
    unused/dormant (nothing since 2024). NOT being rebuilt.**
  - Courses: grades & departments are **tags, not meta fields**; split into Category + Subjects + Grades
    + AP/Elective booleans.
  - **Inquiries = admissions form submissions** (→ Gravity Forms / FinalSite entries; note FinalSite has
    no inbound API). **Publications = blog/announcement list blocks** (not a content type — nothing to
    migrate). **Announcements (5)** — client doesn't want them moved.
- **Not in the sheet (by design):** the page/blog **body content** (~26k content blocks → Gutenberg) is
  the core build via the extractor + block library; and the **media library** (~25k photos + ~83 videos,
  alt/captions) is a separate bulk import Rian is tackling on his own.
- **Next:** media-library inventory (with alt-text gaps); resolve ~5 courses with no Category + the
  ~1,360 untagged blogs; FinalSite discovery call to lock the inquiry/forms strategy.

### 2026-06-03 — Adi + Claude — Exec plan polish pass
- Second review pass on `10-WEEK-PLAN.md`: surfaced the three in-scope items that were invisible —
  **editor experience** (118→~8 roles + content-approval/pending-review workflow) now in Week 8 (closes
  the "editor friction" promise in the intro); **performance + backups + image optimization** added to
  Week 6; Week 5 broadened from "Key features" to "Custom features" (calendar, livestream, video hero,
  etc.); Week 7 label aligned ("Feature complete + full QA"). Style preference holds: no emojis, no bold.

### 2026-06-03 — Adi + Claude — Exec plan reviewed (Rian lens) + tightened
- Reviewed `10-WEEK-PLAN.md` "10 weeks at a glance" from the project-owner/client-comms perspective and
  updated it: added a **"What we need from Brentwood"** column (client obligations: brand assets,
  FinalSite call, ✅ sign-offs W5/W7/W8, IT DNS cutover W9); **softened Week 4** to "first complete draft
  (rough — cleanup follows)"; made **Week 5 the client's first review**; named **Week 8 as the buffer**;
  flagged FinalSite call must be pre-arranged; surfaced the **launch DNS/hosting cutover (Brentwood IT)**
  and a new risk #5 on the tight Week-7→9 run. (Claude/AI still kept out of this client-facing doc.)

### 2026-06-03 — Adi + Claude — Split plan into executive vs internal
- **`10-WEEK-PLAN.md` is now the executive / client-facing view** — high-level weekly milestones + "what
  Brentwood sees", team **composition by role/count** (1 owner=Rian, 1 lead dev, 1 assistant lead, 3
  juniors, + AI assist) with **no per-person task assignments**, client checkpoints, and schedule risks.
- All the engineering detail (per-person assignments, critical path, header decision, Pages deep-dive,
  Week-1 SQL, open follow-ups) moved to new **`PLAN-INTERNAL.md`** (team working plan). Block thinking
  stays in `manual-thought-on-web-dev.md`. Nothing lost — just audience-separated.

### 2026-06-03 — Adi + Claude — Block planning notes added to the plan
- Folded Adi's **`tmp/Block planning @brentwood.pdf`** (page-by-page block survey of the live site) into
  a new **"Block planning notes (Adi)"** section in `10-WEEK-PLAN.md`, written in the same first-person
  voice as `manual-thought-on-web-dev.txt`.
- Core steer: the ~25 surveyed "blocks" collapse to **~6–8 real custom blocks with layout options**
  (Hero Media, Page Title, Text+Dual-Image, Video, Interlink, Table-link, Testimonial) + Kadence Row /
  Advanced Query Loop for a couple (lightbox row, team grid) + CPT-driven lists (livestream/news/search).
  Also: unregister unused Kadence blocks for a clean editor; contextual header sub-menus; red-line boxed
  body via CSS; integrated footer.
- **3 "ask Rian" decisions logged in follow-ups** (decide before the Week-2 block sprint): consolidate
  Video/Interlink layouts; drop the lightbox "+" icon (→ Kadence Row); skip dark mode.

### 2026-06-03 — Adi + Claude — Task tracker + burn log stood up
- Stood up **`TASK-TRACKER.md`** (Adi's Week-1 deliverable): live status board (104 items, E0–E14, status
  TODO/WIP/BLK/REV/DONE + owner + week + per-item spent), an **epic-level burn-down** vs the 450h ceiling
  (rough total ~480h — over by design at planning stage; re-baseline after E0-1 counts), and an
  append-only **burn log** with a weekly roll-up table for RB's client report (RAG per milestone).
- Also exported **`task-tracker.csv`** (104 rows) for import into an external tool (Trello/Sheets/Jira)
  if the team prefers — generated from the tracker tables so it won't drift.
- **Next:** run E0-1 (DB counts) to convert epic estimates → per-item; team starts logging hours.

### 2026-06-03 — Adi + Claude — Detailed task backlog + juniors de-siloed
- **De-siloed the junior team:** IJ · RQ · YM now work as **one shared pool** (no fixed per-person
  content lanes); **ZR assigns/reviews**. Updated `10-WEEK-PLAN.md` team section accordingly.
- Wrote **`TASK-BREAKDOWN.md`** — the granular backlog (epics E0–E14, ~90 items) that feeds the task
  tracker. Grounded CPT/feature items in the actual migrations (Staff `bio_draft`/`departments`,
  Courses `show_profiles`/dept+grade tax, Livestream `start_date`/`length`/`video_id`/`will_be_recorded`,
  multi-source `calendars`). Items needing the Week-1 DB counts marked ⏱️size-after-W1; FinalSite-gated
  marked 🔒; Vue-rebuild blocks marked 🧩.
- **Next:** run E0-1 (DB counts) to size; load the backlog into the tracker. Per-content-type deep-dives
  (Staff/Courses/Blogs/Calendar/Livestream) still worth doing like the Pages one.

### 2026-06-02 — Adi + Claude — ZR re-tiered as assistant lead / Kadence specialist
- Zeina (ZR) elevated above the other juniors in `10-WEEK-PLAN.md`: AP's deputy — helps plan, owns the
  **Kadence template/header/footer** work, **leads & QAs the junior page-assembly team**, takes Tier-B/C
  pages. AP still ~90% the lead (architecture + custom blocks). ZR builds the Kadence side of the header;
  AP handles child-theme override parts. "J" in the plan now = IJ · RQ · YM; the ~200 brochure pages
  spread across them with ZR reviewing.

### 2026-06-02 — Adi + Claude — Critical path + header decision in the plan
- Added a **"Critical path & a key build decision"** section to `10-WEEK-PLAN.md`:
  - **Blocks gate the juniors** → **Week 2 is a block sprint** (T1+T2 + placement-behavior wrapper)
    before juniors ramp page assembly in W3. Block slip = whole junior workstream slips.
  - **Custom header decision:** prefer **Kadence Header Builder / Elements** (client-editable, marketing
    maintains it) with a **child-theme override only where Kadence can't match** the design — hybrid.
    Decide with RB in W1, build W2. Footer same approach; affects the per-page footer-image ACF hook.

### 2026-06-02 — Adi + Claude — Pages deep-dive added to the 10-week plan
- Pages looked too thin in the first draft. Read `Page`, `ContentElement`, `content.blade.php` + the
  content-element blades/models to break it down properly. Added a **"Deep-dive: Pages migration"**
  section to `10-WEEK-PLAN.md`.
- Key findings: a page = hierarchy node + ordered stack of ~18 **polymorphic content-element types**
  (render via `view('content-elements.{type}')`), with per-placement pivot behaviors
  (expandable/filter/randomize/no_margin/hide_print/guest/unlisted). Work splits into 3 streams:
  **(A)** block library build-once (T1 static → T3 dynamic; the T3 lists/filter/calendar are **Vue
  today → rebuild as block JS**), **(B)** re-runnable extractor, **(C)** page assembly/cleanup tiered A/B/C.
- **Good news:** bespoke per-page view overrides are few (`hub`, `parents`, `live/*`, system pages) —
  most pages are generic stacks → bulk-migratable.
- **Couldn't pull DB counts** (MySQL not exposed to host). Left exact tiering SQL in the plan as a
  **Week-1 AP task**. Re-sequenced the Pages track "block-library-first".

### 2026-06-02 — Adi + Claude — Drafted the 10-week project plan
- Wrote **`10-WEEK-PLAN.md`** (new, project root) — a client-facing progress tracker that expands
  `PROJECT-PLAN.md` into dated, owner-assigned milestones. **Status: DRAFT, not yet reviewed by Rian/client.**
- Framing: anchored to the **Aug 1 launch**, which lands in **Week 9**, so it's 9 weeks build → launch →
  Week 10 post-launch stabilization (= first week of the 30-day bug-fix window).
- Lanes defined: Adi (architecture/custom build), 4 juniors (page building + content cleanup + QA),
  Rian (client comms/FinalSite call/training), AI-assist (extraction/transforms/QA). Suggested per-junior
  content lanes captured in the doc.
- **Flagged for Rian:** the ~450h budget ceiling means the team can't be fully loaded all 10 weeks — plan
  sequences who's active when; a burn-down model is still TODO.
- **Next / open follow-ups** (also listed in the plan): Rian/client review of the plan + Week-9 launch
  framing; confirm junior lanes with the team; build the hours-vs-budget burn model; stand up the task tracker.
- Resynced `brentwood.demoing.info` (the `./laravel` mirror) from the **LIVE** site, **read-only**:
  copied this morning's DB backup (`root@159.203.48.227:/var/www/db_backups`, 70 MB) + rsync-pulled
  the 76 GB media. **Nothing was changed on brentwood.ca**; no live DB connection; dev box untouched.
- Imported the live DB (**2,964 users / 289 livestreams**); rewrote `www.brentwood.ca →
  brentwood.demoing.info` in content (URL form only — emails/stack-traces left alone).
- **Fixed a long-standing bug:** the `public/storage` symlink pointed at the live server's
  `/mnt/volume_tor1_03/…` path (dead on mosiah), so every `/storage/…` asset — incl. the homepage
  hero video — silently 404'd. Repointed to local storage; video now streams (HLS 200, mp4 206).
- Recreated reference admin **`admin@mirror.test`** (admin role) — creds in
  `~rian/brentwood-private/mirror-login.txt`.
- **Reusable resync script:** `~rian/brentwood-private/sync-mirror.sh` — read-only pull, uses
  `--size-only` so monthly resyncs fetch only genuinely-new files. Run it to refresh the mirror.

### 2026-06-02 — Rian + Claude — WP stack, mirror login, livestream/Hudl
- **brentwoodwp:** installed the full template plugin stack + licenses (Kadence Pro / Blocks / Blocks
  Pro, ACF Pro, Gravity Forms, UpdraftPlus) + **bw-dev**; Kadence child theme active. Admin is now the
  template's `support` account.
- **Mirror login:** reset `admin@mirror.test` on the Laravel mirror (creds in owner-only
  `~rian/brentwood-private/mirror-login.txt`).
- **Livestream/Hudl:** investigated and documented in the **Livestreaming** section above — Hudl is a
  link-out (not an embed); no self-hosted live infra found; "the old way" still to confirm with Brentwood IT.
- **Security:** moved live prod secrets (`laravel/.env.save`) out of the dev-readable folder to
  owner-only storage.

### 2026-05-29 — Rian + Claude — Project handoff prep
- Stood up the WordPress target at `brentwoodwp.demoing.info` inside the existing brentwood project
  (own `wp-db`, isolated from the Laravel mirror and from the shared WP MariaDB).
- Made the project tree fully editable by the `brentwood-dev` group (ACLs).
- Wrote `PROJECT-BRIEF.md`, `notes/Scope of Work (from proposal).md`, and `PROJECT-PLAN.md`.
- Stripped financials from the proposal; full original preserved outside the project (owner-only).
- Set up this handoff/diary system. Adi to be added to the project as lead.
