# Image Alt Text Migration — Strategy & TODO

**Owner:** Rian · **Drafted:** 2026-07-13 (Adi + Claude) · **Status:** strategy drafted, awaiting go to execute homepage proof batch
**Target:** `brentwooddev.demoing.info` (staging) → later push to dev/live
**Source of truth for original alt:** the **Laravel `photos.alt`** column (`laravel/database/migrations/2020_03_19_..._create_photos_table.php:23`), NOT the live rendered site (it is JS-rendered; curl of `brentwood.ca` returns only a 5-image shell, and rendered alt is often auto-derived/unreliable).

Scoped in the project docs, still open:
- Brief §67 & Scope of Work §85: *"Photo library — scripted copy with alt text and captions preserved."*
- `TASK-BREAKDOWN.md` **E6-9**: *"Media library: alt text + captions preserved."*
- `HANDOFF.md:965` next-step: *"media-library inventory (with alt-text gaps)."*

---

## 1. Current state (measured 2026-07-13, do not trust from memory — re-measure per batch)

| Metric | Value |
|---|---|
| Image attachments in media library | **10,660** |
| …with non-empty alt meta (`_wp_attachment_image_alt`) | **8,585 (80.5%)** |
| …**missing** alt meta | **2,075 (19.5%)** |
| Published **pages** (page-by-page unit) | **205** |
| Published **blog posts** | **6,016** |

Missing-alt attachments by filename origin:

| Origin (filename pattern) | Count | Likely branch |
|---|---|---|
| `other/manual upload` | 1,585 | B (write new) — many may be orphans/unused |
| `photos-####` (Laravel) | 237 | check Laravel `photos.alt` first, else B |
| `i####` (Laravel) | 190 | check Laravel `photos.alt` first, else B |
| `screenshot` (manual) | 63 | B (write new) |

> ⚠️ These are *media-library* counts. What matters per page is the images actually **rendered**, which is a smaller, page-scoped set. Always work from the rendered page, not the whole library.

---

## 2. The model — WordPress stores alt in TWO places, and there are THREE fix mechanisms

Alt lives both on the **attachment** (`_wp_attachment_image_alt` meta) **and inline** in each block's `<img alt="…">` (copied at insert time). The rendered page uses the **inline** value for static blocks, and whatever the **block's PHP** emits for dynamic blocks. So a missing alt on screen can have three very different causes:

| # | Mechanism | Signature | Fix | AI needed? |
|---|---|---|---|---|
| **1** | **Dynamic block drops alt** — a block (recent-posts / featured-image grid, gallery) renders `alt=""` even though the attachment HAS alt | image **not** found in `post_content` but appears in rendered HTML; attachment meta alt **exists** | **Render-level fix** in the theme (make the block emit the attachment's alt). One fix helps every page using that block. | No |
| **2** | **Static block, alt on attachment but empty inline** | image token **in** `post_content` as `<img alt="">`; attachment meta alt **exists** | **Re-sync**: copy attachment meta → the block's inline `alt` (scripted, idempotent) | No |
| **3** | **No alt anywhere** | attachment meta alt **empty** (and Laravel `photos.alt` also empty) | **Write new alt**, save to attachment meta, then it flows via #1/#2 | Yes (AI drafts, human approves) |
| — | **Decorative** (spacers, dividers, `dash.svg`, background flourishes) | purely presentational | **Leave `alt=""`** — adding alt here is WRONG for accessibility | No |

**Precedence to try, per image:** decorative? → Mechanism 1/2 (free, correct source) → Mechanism 3 (AI) only when truly empty everywhere.

---

## 3. Anti-hallucination rules (non-negotiable)

1. **Work page-by-page from the rendered output**, never from assumptions. Re-measure each batch with the audit script; never carry counts from memory.
2. **Small batch → verify → expand.** Batch 1 = homepage only. Confirm every image is correct before touching page 2.
3. **AI-written alt (Mechanism 3) is a DRAFT.** It describes only what is visibly in the image. If the image content is ambiguous (who/what/where unknown), the draft says so and the item is **flagged for human**, not guessed.
4. **Never overwrite a non-empty alt.** All scripts only fill blanks.
5. **Decorative images stay empty** — don't "fix" them.
6. **Everything reversible:** attachment-meta writes and content re-syncs are logged (old→new) to a per-batch file so any batch can be rolled back.
7. Human (Rian/Adi) signs off each batch's alt text before it's considered done.

---

## 3a. Alt SOURCE precedence — READ FIRST (rule from Rian, 2026-07-13)

**When the live site already has alt text for an image, use that original VERBATIM — never rewrite it.** Only compose new alt for images the live site left blank (or that aren't used on the live site). Rian's example: live says `Students drawing in an Art studio`; do NOT "improve" it to `Boarding school students sketching…`.

Precedence for every image, in order:

1. **Existing dev attachment alt** — already migrated (80% of the library); leave as-is.
2. **Live/reference original alt, matched by filename** — use exactly as written, including the live site's own casing/wording (e.g. `private school arts - pottery`). This is the authoritative source and the whole point of "alt preserved" in the scope.
3. **Only if 1 and 2 are blank →** write new alt in the §3b voice below (view the image first).

**Where to get the live original:** the live page embeds each photo's data as JSON — `curl https://www.brentwood.ca/<path>` and extract `"name"` (filename) + `"alt"` from the `:photo='{…}'` blobs; match `name` (minus extension) to the dev attachment's `_wp_attached_file` basename. (Same values live in the Laravel `photos.alt` column.) On `/arts/` this supplied 17 of 20 originals.

## 3b. Alt-writing voice — for images with NO live original (from Rian's manual homepage pass)

Rian hand-wrote the homepage's truly-blank images. **Match this voice only when writing NEW alt (precedence step 3).**

Reference examples (his actual homepage alts):

| Image | Alt he wrote |
|---|---|
| seniors in science class | `Boarding school seniors in a science class listening to the teacher` |
| students on Opening Day | `Private school students walking under international flags outside on Opening Day` |
| summer-camp promo graphic | `Brentwood summer camps 2026` |

Rules distilled:

1. **Lead with an SEO keyword phrase** — `Boarding school…`, `Private school…`, `Brentwood…` (the school's search targets), woven in naturally — not keyword-stuffed.
2. **Describe the visible scene**: who + setting + action ("seniors in a science class listening to the teacher").
3. **Use page/event context** when known: "Opening Day", "summer camps 2026".
4. **No "image of / photo of"** prefix; concise (roughly ≤ 125 chars).
5. **Decorative images stay empty** (`dash.svg` was correctly left `alt=""`).
6. If the subject/context isn't clearly determinable from the image + page, **flag for human** — do not invent names/events.

**Where to write it:** set **both** the attachment meta (`_wp_attachment_image_alt`, the durable source) **and** the block's inline `alt` (what renders). *(Rian set inline-only on 2 of 3; standardize on both so reuse elsewhere inherits it.)*

---

## 4. Batch 1 — HOMEPAGE (the proof) — measured, concrete

Homepage = page **859** ("Home"). Rendered `<img>`: **14** → 6 already OK (accreditation logos), 8 empty. The 8 empties:

| Image (token) | Attachment | Meta alt? | Mechanism | Action |
|---|---|---|---|---|
| `dash.svg` | (theme/inline SVG) | n/a | Decorative | **Left empty ✅** |
| `i2555` | 7249 | ✅ "Hitting the Ice, Hitting Your Couch" | **1 (dynamic)** | ⏳ needs block render fix |
| `i8300` | 21647 | ✅ "Brentwood Broadcast: Erasing the Distance" | **1 (dynamic)** | ⏳ needs block render fix |
| `i8298` | 21643 | ✅ "House Pride on the Line: Interhouse Soccer…" | **1 (dynamic)** | ⏳ needs block render fix |
| `i8296` | 21639 | ✅ "Handcrafted with Love: Mother's Day Card…" | **1 (dynamic)** | ⏳ needs block render fix |
| `large-screenshot-2024-10-28…` | 23022 | now inline ✅ (meta still none) | **3** | ✅ **DONE (Rian)** — "Boarding school seniors in a science class listening to the teacher" |
| `large-screenshot-2024-09-16…` | 22787 | now inline ✅ (meta still none) | **3** | ✅ **DONE (Rian)** — "Private school students walking under international flags outside on Opening Day" |
| `large-image001…` | 31569 | ✅ "Brentwood summer camps 2026" | **3** | ✅ **DONE (Rian)** — set on media item + inline |

**Status 2026-07-13:** Mechanism-3 statics **done by Rian manually** (voice captured in §3a). Remaining homepage gap = the **4 dynamic blog thumbnails** (Mechanism 1) — fix once at the block level and they render their existing title-alt. `dash.svg` correctly left empty.

Notes:
- The four `i####` are **recent-blog featured images** whose attachment alt = the blog title. They come from a **dynamic block** (not in `post_content`) — need to identify which block (candidates: a synced `core/block` pattern, `brentwood/hero-card`, or `kadence/advancedgallery`) and fix its rendered `alt`. **This single fix likely covers many pages/blogs.**
- The three Mechanism-3 statics are `kadence/advancedgallery` / image blocks — write alt, set attachment meta, re-sync inline.
- `dash.svg` is a decorative divider — confirm and leave.

**Homepage acceptance:** re-render homepage → 0 content images with empty alt (decorative dividers excepted); the 4 blog thumbnails show their title as alt; the 3 statics show human-approved alt.

---

## 5. Tooling — SHIPPED 2026-07-13

**The mechanism model collapsed into one clean pipeline:** set alt ONCE on the attachment (the media item), and a global render filter injects it wherever the image appears. This means **Mechanisms 1 AND 2 are now automatic** — no per-block edits, no `post_content` rewrites, no re-sync step. Per-page work reduces to **Mechanism 3 only**: for images whose attachment has no alt, look at the image, write alt onto the **attachment**, done — the filter renders it everywhere.

- ✅ **`inc/bw-img-alt.php`** (kadence-child theme) — `render_block` filter, priority 12. For any rendered `<img>` with a blank/missing alt, resolves the attachment (via `bw/interlinking` `imageId`, or `wp-image-<id>`, or the src filename — tolerating `-WxH` and `.jpg.webp` derivatives) and injects that attachment's `_wp_attachment_image_alt`. **Fills blanks only; never overwrites.** Unresolvable images (decorative theme assets: `dash.svg`, quote marks, SVG icons) are left empty — correct. This single filter covers interlinking cards, Kadence/core images, AND the dynamic blog thumbnails (the "global thumbnail fix").
- **Writing alt (Mechanism 3):** view the actual image file(s) on disk (`wp-content/uploads/...`, use a ~300–500px derivative), draft in the §3a voice, then `update_post_meta( <att>, '_wp_attachment_image_alt', <alt> )` — blank-only, logged to `notes/alt-batch-logs/<page>-<date>.log` for rollback.
- **`audit-page-alt` (read-only, still worth formalizing):** curl the page from the origin with `-H "Host: <site>" -H "X-Forwarded-Proto: https"` against `172.17.0.1:<port>` (defeats the id-auth gate + the canonical→https redirect → full rendered HTML), list every `<img>` src+alt, resolve to attachment, flag which attachments still lack alt. Seed scripts used on 2026-07-13 are in the session history.
- **Laravel backfill (optional):** for `i####`/`photos-####` attachments still missing meta alt, look up `photos.alt` in the Laravel source and fill if present. (Confirm Laravel DB is queryable first.)

---

## 6. Phased TODO

### Phase 0 — Tooling & source confirmation
- [x] Global render fix shipped (`bw-img-alt.php`, §5) — replaces the planned resync/M1 tooling.
- [ ] Confirm Laravel `photos` DB is queryable; spot-check `photos.alt` for a few `i####` ids (only needed for the optional bulk backfill).
- [ ] Formalize `audit-page-alt` as a reusable script (currently ad-hoc, see §5).

### Phase 1 — Homepage proof (Batch 1) ✅ DONE
- [x] Audit 859 → §4 table (2026-07-13).
- [x] Mechanism-3 statics written — **Rian, manually** (3 images; voice §3a).
- [x] 4 `i####` thumbnails — **auto-closed by the global filter** (render their post titles). No per-block work.

### Phase 2 — Global render fix (Mechanism 1 + 2) ✅ DONE
- [x] Shipped `inc/bw-img-alt.php` (§5): interlinking cards + Kadence/core images + dynamic blog thumbnails, all from attachment meta. One fix, site-wide, blank-only.
- [ ] Spot-check ~10 more varied pages to confirm no dynamic image source is missed.

### Phase 3 — Pages sweep (Mechanism 3 only), small batches
- [x] **`/arts/` (page 865)** — first page batch DONE 2026-07-13: 20 content photos viewed on disk + alt written to attachments (voice §3a); rendered **5 → 29** alts, 7 remaining all decorative. Log: `notes/alt-batch-logs/arts-865-2026-07-13.log`.
- [ ] Continue via the **§8 batched TODO (all 205 pages)** — Tier 1 first (B1…), one batch at a time.
- [ ] Per page (simplified now): render → for each attachment with no alt, **live original first (§3a)**, else **view image + write (§3b, draft→approve)**. The filter does the rest.

### Phase 4 — Blogs (6,016) — later / optional
- [ ] Featured-image thumbnails already covered by the global filter. Decide whether in-body blog images (mostly already ~80% alt'd) warrant a sweep or are acceptable as-is.

---

## 8. FULL PAGE SWEEP — batched TODO (all 205 pages)

**Goal:** every published page checked for image alt.

**✅ STATUS 2026-07-13 — COMPLETE. Every one of the 205 pages now has alt text on all content images (0 non-decorative empty-alt, verified by full-site render scan).** Media-library coverage **10,346 / 10,660 (97%)**; the remaining 314 empty attachments are **orphans not shown on any page** (optional to fill for library tidiness). Method: live originals site-wide → per-section viewed+written (§3b voice) → galleries via site captions + page-aware contextual alt → brentwood-100 archive via CPT featured/gallery images + cache flush. Every batch logged in `alt-batch-logs/`.

### §8.1 Residual worklist — images needing WRITTEN alt (no live original), by section
| Section | Distinct images | Note |
|---|---|---|
| **trips** | 180 | 17 trip photo galleries — biggest single effort |
| **brentwood-100** | 166 | historical archive grid; ~67 have a short subtitle (e.g. "Regatta") that can seed alt |
| ~~admissions~~ | ✅ DONE | 24 written (viewed each); staff portraits by name+title. Log: `admissions-2026-07-13.log` |
| events | 25 | |
| why-brentwood | 18 | |
| alumni | 18 | |
| regatta | 12 | |
| student-life | 9 | · innovations 9 · giving 7 · clubs 6 · academics 6 · external-rentals 5 · athletics 5 · thank-you 4 · myschool 2 · live 2 · brentwood-family 2 · staff 1 · careers 1 · calendar 1 |

Worklist file: `scratchpad/residual-distinct.txt` (section|image-token). **trips + brentwood-100 = 346 of 506 (68%)** — candidates for a lighter/bulk treatment or lower priority; the other ~160 are ordinary per-section write batches.

### §8.2 Original batch checklist (live-harvest applied to all)

**AUTONOMOUS MODE (approved by Rian 2026-07-13):** proceed section by section without a per-batch approval gate. For residual (live-blank) images, **view the image on disk, write alt in the §3b voice, and apply directly** — every write logged to `notes/alt-batch-logs/` for Rian's review + spot-check. Rian's standing rules still bind: live original wins (§3a); decorative stays empty; describe only what's visible; blank-only writes; overwrite only to correct to a live original.

**KEY EFFICIENCY (proven on arts):** harvest live originals for a section at once (`scratchpad/harvest_live_alts.py <live URLs>` → filename→alt map → apply blank-only by normalized filename). On arts this auto-filled **144** images with the site's own alt (zero hallucination); only ~35 live-blank residuals needed writing. Per section: (1) harvest+apply live [safe, bulk], (2) view+write residuals [autonomous], (3) verify 0 non-decorative empties, (4) log + bump coverage.

**Batch size rule (anti-hallucination):** one section per batch, **≤ ~8 pages**; split big sections. Large sections here are pre-split. Do ONE batch fully, verify, log, then the next.

**Per-batch procedure (standing):**
1. Re-pull the section's exact published pages (authoritative — don't trust this list to be complete forever):
   `srv-gw db-query --project brentwooddev "SELECT p.ID,p.post_name FROM wp_posts p LEFT JOIN wp_posts par ON par.ID=p.post_parent WHERE p.post_type='page' AND p.post_status='publish' AND (p.post_name=... OR par.post_name='<section>')"`
2. For each page: render via origin (`-H "Host: brentwooddev.demoing.info" -H "X-Forwarded-Proto: https" http://172.17.0.1:3097/<path>/`), list `<img>` + alt state, resolve empties to attachments.
3. For each empty-alt attachment, get the **live original** (`curl https://www.brentwood.ca/<path>` → photo JSON `name`+`alt`, match by filename). **Use live verbatim (§3a).**
4. Only where the live alt is blank/absent → **view the image**, draft in the §3b voice, **get Rian's approval**, then write.
5. Write to attachment meta (blank-only for new; overwrite only when correcting to a live original), log old→new to `notes/alt-batch-logs/<section>-<date>.log`, re-render to verify, tick the box, bump the coverage count.

**Exclude (dev/test — do NOT process):** `template` (23960), `test` (23171), `test-adi` (24145).

### Tier 1 — Core marketing (do first)
- [x] **B1 — arts children ½** (9) ✅ DONE (live 68 + residuals). Log: `alt-batch-logs/b1-arts-children-live-2026-07-13.log`, `arts-residuals-2026-07-13.log`
- [x] **B2 — arts children 2/2** (9) ✅ DONE (live 75 + residuals). Log: `alt-batch-logs/b2-arts-children-live-2026-07-13.log`
- [ ] **B3 — why-brentwood** (8): why-brentwood, campus, choice, legacy, philosophy, support, unique-timetable, university-placement
- [ ] **B4 — admissions** (7): admissions, admissions-events, apply, contact-us, financial-information, new-families, voh-internal-source
- [ ] **B5 — academics + accommodation** (7): academics, ap-courses, courses, facilities, faculty, grade-8, accommodation
- [ ] **B6 — athletics core** (7): athletics, accomplishments, coaches, facilities, fitness-performance, strength-and-conditioning, outdoor-pursuits
- [ ] **B7 — athletics sports A** (7): basketball, climbing, cross-country-running, dance, field-hockey, flag-football, golf
- [ ] **B8 — athletics sports B** (7): ice-hockey, jiu-jitsu, multisport, rowing, rowing-program-outline, rugby, scuba-diving
- [ ] **B9 — athletics sports C** (6): soccer, squash, swimming-and-lifesaving, tennis, volleyball, yoga
- [ ] **B10 — student-life** (5): student-life, activities, boarding, leadership, student-services
- [ ] **B11 — students** (4): students, course-selection, leadership, wellness
- [ ] **B12 — giving** (5): giving, current-priorities, planned, frisbe (US Donors), ways-to-give
- [ ] **B13 — alumni + family + 100** (7): alumni, archives-and-publications, old-brentonian-awards, stories, brentwood-family, volunteer-opportunities, brentwood-100
- [ ] **B14 — camps + clubs + singles** (8): summer-camps, spring-break-camps, activities/skiing-snowboarding, music/private-music-lessons, clubs, b-well, brain, brain/brain-media

### Tier 2 — Programs & sections
- [ ] **B15 — trips ½** (9): trips, amazon, art-new-york, basketball-costa-rica, carnaval, dance, eswatini, everest, fernie
- [ ] **B16 — trips 2/2** (9): haida-gwaii, midterm, new-york-mun, rugby-tour-new-zealand-march-2027, spain-and-france, strathcona-park, taiwan, theatre, university-tour
- [ ] **B17 — events ½** (7): events, alumni-reunion, bring-your-parents-to-class, centenary, centenary-bash, graduation, languages-conference
- [ ] **B18 — events 2/2** (6): service-day, snow-bus, schoolstart (Start of School Year), theatre-programs, west-coast-concert-choir-festival, winterlude
- [ ] **B19 — regatta** (8): regatta, coaches-information, draw, environmental-tips, event-information, media, officials-trophies, results
- [ ] **B20 — course-selection** (6): course-selection/arts, athletics, grade-9, grade-10, grade-11, grade-12
- [ ] **B21 — philosophy** (4): mission-vision-values, purpose-values-commitments, social-responsibility, strategic-plan
- [ ] **B22 — innovations** (8): innovations, accounts, cisco-phones, innovations-orientation, network-device-request, printing, repairs, software
- [ ] **B23 — library + guides** (9): library, basic-llc-info, book-a-room, databases, guides, links, guides/library-resources-apps, llc-calendar-tutorial, sora-for-teachers
- [ ] **B24 — school houses** (8): schoolstart/alex, allard, ellis, hope, mack, privett, rogers, whittall

### Tier 3 — Functional / low-image (verify; likely little/no work)
- [ ] **B25 — policies** (6): policies, accessibility-plan, information-technology-acceptable-use-policy, privacy, tos, travel-requirements
- [ ] **B26 — live** (6): live, assembly, view, register, registration-complete, unregister-complete
- [ ] **B27 — info singles** (9): careers, current-families, current-families/financial-information, calendar, news, contact-us (top), directions, crooks-menu, external-rentals
- [ ] **B28 — staff/system A** (6): staff, students (top), hub, myschool, onboarding, school-documents
- [ ] **B29 — system B (verify-empty)** (4): login, search, my-profile, thank-you-contact-information

> After each batch: update the **Coverage** count at the top of §8, tick the box, and add a one-line §7 entry. When all boxes are ticked, the alt-text task is complete for pages (blogs = Phase 4, separate).

---

## 7. Progress log (append per batch — newest first)

- 2026-07-13 — **🎉 ALL 205 PAGES COMPLETE.** Finished student-life, regatta, events, alumni, small tails (viewed+written), then galleries: trips + brentwood-100 via harvested site captions (74) + page-aware contextual alt (per-trip / "historical archive"); brentwood-100 grid uses `get_the_post_thumbnail`/`wp_get_attachment_image` so setting attachment alt + flushing `bw_hundred_grid_c*` transients populated the tiles/modals. Full-site render scan → **0 non-decorative empty-alt on every page.** Coverage 10,346/10,660 (97%); 314 remaining are orphans (unused). Logs: `student-life-…`, `regatta-…`, `events-…`, `alumni-…`, `tails-…`, `gallery-captions-…`, `gallery-contextual-…`, `brentwood-100-archive-…`, `final-cleanup-…`.
- 2026-07-13 — **8 more sections DONE** (autonomous, viewed each residual + wrote §3b alt): why-brentwood (21), academics/giving/athletics/clubs/external-rentals cluster (44), innovations (15, logos+screenshots). Coverage 9,785/10,660 (92%). Logs: `why-brentwood-…`, `cluster-…`, `innovations-…`. Remaining: events, alumni, regatta, student-life, small tails, then trips+brentwood-100 galleries.
- 2026-07-13 — **admissions section DONE** (autonomous): viewed 23 residual images on disk, wrote alt in §3b voice (incl. staff portraits as "Name, Title"), 34 attachment rows set. 0 non-decorative empties on all 7 admissions pages. Log: `alt-batch-logs/admissions-2026-07-13.log`.
- 2026-07-13 — **SITE-WIDE live harvest (autonomous mode ON).** Harvested 1,157 filename→alt pairs from 175 live pages (`harvest_concurrent.py` over all 202 page URLs) → applied **821** more blank-only site-wide. **Media library alt coverage 8,585 → 9,671 / 10,660 (91%).** Remaining ~989 empties = live-blank/orphan/decorative; being worked per section (view+write). Log: `alt-batch-logs/sitewide-live-2026-07-13.log`. Arts section verified 100% (0 non-decorative empties).
- 2026-07-13 — **Arts residuals written & applied (autonomous):** 35 live-blank images viewed on disk + alt written in §3b voice (musicals, pottery, sci-illustration, etc.). Arts = COMPLETE. Log: `alt-batch-logs/arts-residuals-2026-07-13.log`.
- 2026-07-13 — **B1+B2 (arts section, 18 children) — live originals harvested & applied: 143 images filled** (68+75) from `www.brentwood.ca/arts/*` photo JSON via `harvest_live_alts.py`, zero hand-writing. 38 distinct images remain (no live alt) → pending Rian-approved written alt. Logs: `alt-batch-logs/b1-…` + `b2-…`.
- 2026-07-13 — **Rule added (§3a): prefer the live original alt verbatim; only write new when live is blank.** Re-sourced `/arts/` from `www.brentwood.ca/arts` photo JSON → replaced 17 of 20 with live originals (kept 3 the live site had no alt for: piano 704a1233, mean_girls, robotics image not on live). Reconcile log: `notes/alt-batch-logs/arts-865-live-reconcile-2026-07-13.log`.
- 2026-07-13 — **`/arts/` (865) batch + global fix shipped.** Viewed all 20 content photos on disk; wrote first-pass alts to attachments (voice §3b). Shipped `inc/bw-img-alt.php` render filter → interlinking cards, Kadence images, AND dynamic blog thumbnails now inherit attachment alt site-wide. `/arts/` rendered **5→29** alts (7 remaining all decorative); homepage's 4 thumbnails auto-fixed too. Rollback: `notes/alt-batch-logs/arts-865-2026-07-13.log`. Staging only (not pushed to dev/live yet).
- 2026-07-13 — **Rian** hand-wrote the 3 Mechanism-3 static-image alts on the homepage (§3a captures the style). `dash.svg` left decorative.
- 2026-07-13 — strategy drafted; homepage (859) fully audited (§4).
