# BW Dev — Handoff Notes

Read this FIRST when picking up the project. Whether you're a fresh Claude session, adi resuming later, or rian planning the next round of work — this file tells you where to land.

**Status: 1.13.1 RELEASED (2026-05-29).** Three versions caught up in one session — 1.12.1, 1.13.0, and 1.13.1 all shipped after rian explicit-released each. Live at https://plugins.bowden.works/wp-content/uploads/plugin-updates/bw-dev-1.13.1.zip — sites with bw-dev installed auto-update on their next Flywheel/WP cron poll. Module count: **36 modules** across seven sidebar groups (35 from 1.13.0 + new Analytics module).

## What just landed (1.13.1, 2026-05-29) — bug fix patch

**Animate Row: blank above-the-fold content on Flywheel.** Reported on promptvictoria.ca: the live site rendered a blank hero. Diagnosis (full text from rian in the 2026-05-29 session): the module was blanket-stamping `data-aos="fade-up"` on every Kadence row, including the hero. AOS's `[data-aos^=fade] { opacity: 0 }` rule applied immediately, but AOS reliably fails to add `aos-animate` to elements that are already in the viewport at init time (known weakness with tall heroes / bg-video parents / transformed ancestors). The hero stayed at opacity 0 forever. **Fix in `assets/js/animate-row.js`:** (1) `applyAnimations()` now skips any element that intersects the initial viewport via `getBoundingClientRect()` — above-the-fold rows are never opacity-hidden, they just appear normally (which is what you want for hero content anyway); (2) added a `window.load` listener that calls `AOS.refreshHard()` so the offsets-cache is rebuilt after bg-video / images / iframes shift the layout.

**Analytics simplified.** Removed the "Emit GTM tags" toggle and the `GTM-NOIDSET` placeholder behavior. GTM emission is now keyed solely off the `gtm_id` field — non-empty → emit, empty → skip. Also dropped the wall-of-code "Markup that will be emitted" preview from the settings tab. Behavior change for sites that had `gtm_enabled: false` + non-empty `gtm_id`: those will START emitting GTM after the update (which is what users would expect — if you set an ID, you want it firing). No saved-state migration needed; any orphan `gtm_enabled` key is just ignored.

## What just landed (1.13.0, 2026-05-29) — Analytics module + three legacy plugins merged

**Two additions in this release:**

**Analytics module** (`analytics`, group `frontend`) — drops Google Tag Manager script tags + the Search Console verification meta into every frontend page (admin / login / REST stay untouched). NOTE: in 1.13.0 there was an in-module "Emit GTM tags" toggle that defaulted OFF, plus a `GTM-NOIDSET` placeholder for empty ID. **1.13.1 removed both** (see above) — the documentation that follows describes the 1.13.1 shape: empty `gtm_id` = nothing emitted, non-empty = both head script + body iframe emit. Search Console field is paste-tolerant — accepts the full `<meta name="google-site-verification" content="…">` tag, a verification-file URL like `https://example.com/google123abcd.html`, the bare filename, or just the raw token. Both fields independent; either can be empty.

**Three standalone plugins merged in as Front-end modules** (the originally planned 1.13.0 work). All three are **Front-end** group, default ON. Pattern followed: full re-prefix to `bw_dev_*`, settings nested under `bw_dev_settings[<slug>]`, one-way non-destructive migration (v2), `render_tab()` each, shared assets under `assets/`.

Adi asked to fold three standalone Bowden Works plugins into BW Dev as proper modules. All three are **Front-end** group, default ON. Pattern followed: full re-prefix to `bw_dev_*`, settings nested under `bw_dev_settings[<slug>]`, one-way non-destructive migration (v2), `render_tab()` each, shared assets under `assets/`.

- **`inline_search`** (from `bw-inline-search`) — expand-from-icon search field scoped to one container (`.site-header` default). Shortcode `[bw_dev_inline_search]` + widget + auto-replacement of the theme's header search toggle. Single setting: container CSS selector (stripped of `; { } \ < > " '` since it's interpolated into a scoped `<style>` block). Legacy `[bw_inline_search]` kept as a guarded alias. JS: `assets/js/inline-search.js`, config `bwDevInlineSearchConfig`.
- **`animate_row`** (from `bw-animate-row`) — global AOS scroll animation on Kadence rows/columns/sections lacking their own `data-aos`. **AOS is now VENDORED** at `assets/vendor/aos/` (v2.3.4) — the original hot-loaded it from the unpkg CDN, a supply-chain + visitor-privacy exposure on every public page (rian chose vendoring). Ten flat `bw_animation_*`/`bw_target_*` legacy options → one nested section. JS: `assets/js/animate-row.js`, config `bwDevAnimateRowConfig`. AOS + init load only when enabled AND a target type is checked.
- **`scroll_down`** (from `bw-scroll-down` v4.0.0) — scroll-down indicator (6 SVG icons) with smooth-scroll to next-section/anchor. Shortcode `[bw_dev_scroll_down]` + trigger-class auto-inject. Settings: icon, size, wp-color-picker, label, offset, anchor, trigger class, auto-target, float animation, live hero preview. Ported from the plugin's clean inlined footer script (NOT its stale debug `assets/js/`). JS: `assets/js/scroll-down.js`, config `bwDevScrollDownConfig`.

**Migration v2** (`BW_Dev_Migration::CURRENT_VERSION` 1 → 2): re-runs the fill-missing-only migration on already-activated sites to pick up `bw_search_container_selector` → `inline_search`, the ten `bw_animation_*`/`bw_target_*` → `animate_row`, and `bw_scroll_down_settings` → `scroll_down`. `legacy_map()` extended with the three source basenames.

**Verified (WP-CLI on dev site):** all 3 lint clean; registry now 35 modules with all 3 present; migration v2 runs (version=2); both `[bw_dev_*]` shortcodes register; sanitizers correct (type/easing/icon allowlist fallback, size/duration clamps, hex color, `#` anchor prepend, `sanitize_html_class`, CSS-breakout strip). `cleanup-scan` clean, `security-scan` clean (the 3 warnings are pre-existing image-optimizer false-positives, not the new modules). **Browser/visual QA still pending** — see Pending items.

> **Source plugins** still live at `wp-content/plugins/bw-{inline-search,animate-row,scroll-down}/`. They are the reference implementation — do NOT edit them. Adi deactivates each manually once the matching module is verified on a site (same workflow as the original 5). The CSS class re-prefix (`.bw-inline-search-*` → `.bw-dev-inline-search-*`, `.bw-scroll-down*` → `.bw-dev-scroll-down*`) means any site with hand-written CSS against the old classes needs updating after switchover — mirrors the Sticky module's `.bw-is-sticky` rename.

## 1.12.1 (2026-05-27, RELEASED note backfilled 2026-05-28)

1.12.1 changed the Image Optimizer preview from auto-on-change to a button-triggered **Update preview** (Imagick on Flywheel was too slow for live re-render). This entry was missing from HANDOFF/SESSION-LOG when 1.12.1's version was bumped; backfilled here. See CHANGELOG `[1.12.1]` for the full description.

## What just landed (1.12.0, 2026-05-27)

**Image Optimizer module** (`image_optimizer`, group `editor_admin`) — Three phases collapsed into one release because rian wanted to ship the whole story at once instead of one-feature-per-version:

### Phase 1 — resize + JPEG compression on upload
- Hooks both `wp_handle_upload_prefilter` (wp-admin Media Library, block editor) AND `wp_handle_sideload_prefilter` (`wp media import`, theme demos, remote-URL imports) — covers every upload path.
- Pipeline: edge crops → resize (fit-to-bounds OR cover-with-9-anchor) → JPEG compression.
- Imagick-first with `wp_get_image_editor` (GD) fallback. The GD fallback skips watermark removal but still does resize + compress.
- PNG transparency detection via `Imagick::getImageChannelRange( CHANNEL_ALPHA )` (Imagick 7's `getImageChannelStatistics()` doesn't populate the alpha channel reliably) — real transparency keeps PNG output, decorative-alpha (channel-on-but-all-opaque) flattens to JPEG.
- Filename suffix `_bw` (never doubles).
- Skip conditions: non-image uploads, SVGs (handled by SVG Upload module), JPEGs already smaller than 512 KB AND within profile dimensions. Source replacement only if processed output is genuinely smaller.
- Master "Process new uploads" toggle defaults OFF (separate from the Modules-tab enable) so an update to existing client sites doesn't surprise-mutate uploads.

### Phase 2 — watermark removal
- Algorithm ported from `/srv/apps/opti/app.py` (the `crop.bowden.works` Flask tool): bottom-right rectangle filled with a sampled color (inside or outside the box), optionally textured with bilinear-upscaled random noise, blended into the surrounding image via a corner-distance gradient mask (`1 - sqrt((1-u)² + (1-v)²)`).
- Runs in 4-7 ms per 1920×1047 image.
- Lives in `apply_watermark_removal()` with helpers `sample_avg_color()` (crop-then-resize-to-1x1 averaging trick), `build_fill_layer()`, `build_noise_mask()`, `build_gradient_mask()`.

### Phase 3 — multi-profile + live preview UI
- **Multi-profile data model.** `profiles` is a slug-keyed array; each profile is a self-contained bundle (label, dimensions, fit/cover, anchor, quality, edge crops, watermark sub-block). `default_profile` (replaces the old `active_profile`) is the slug applied to editors who haven't picked one.
- **Admin-bar widget** lists Off + every saved profile. Per-user choice in `_bw_dev_image_optimizer_profile` user meta (auto-migrates from the legacy `_mode` key).
- **Tabbed settings UI** with a sticky 2-column live preview. The Live Preview column lives in `position: sticky; top: 42px` inside the active pane and JS-moves between panes on tab switch.
- **Add / Delete profiles in one form post.** `+ Add profile` clones a `<template>` with a temp `_new_*` slug; sanitizer reslugs from the label with `_2`/`_3` collision suffixes. "Delete this profile" appends to `__deleted_profiles[]`; at-least-one invariant restores the seed if you delete everything.
- **Preview shows the FULL pipeline** (watermark + crops + resize + JPEG quality), not just the watermark step. Auto-refresh fires within 300 ms of any profile-field change.

### Bug fixes caught during dev
- `record_stats()` was reading through the cached settings layer (could be clobbered by a stale singleton snapshot). Rewrote to bypass the cache: `get_option()` → mutate → `update_option()`, then sync the cache. Stats now reliably tick on every processed upload.
- `Imagick::importImagePixels()` on Imagick 7.1.x rejects a binary string `$pixels`; takes an array of ints. Refactored `build_noise_mask` and `build_gradient_mask`.
- `WP_Image_Editor::get_mime_type()` is protected — was being called externally in the GD fallback. Replaced with a PNG byte-signature sniff (`"\x89PNG\r\n\x1a\n"`).

## What's in the previously-pending releases (1.1.0 → 1.11.2, all now shipped under 1.12.0)

**Eleven new modules across five sidebar groups, plus one 1.11.1 enhancement to the Scheduled Post Actions module.** Each new module replaces a standalone plugin we'd otherwise have to install on every Kadence site.

### 1.11.2 — Separator + Subtitle blocks: theme-palette colors now reach the frontend (2026-05-22)

Both blocks were running their saved color attribute through `sanitize_hex_color()`, which only accepts plain `#xxx` / `#xxxxxx` hex and returns null for anything else. The block editor's `ColorPalette` stores theme-palette colors as CSS-variable references like `var(--global-palette14)` (Kadence) — those failed the hex check, the inline `style="color:..."` was dropped, and the frontend rendered the inherited theme color instead. The editor preview worked because it used the raw value without sanitization. **Fix:** replaced the hex-only check in both `blocks/separator/render.php` and `blocks/subtitle/render.php` with a closure-based validator that accepts hex (3 / 6 / 8 digit), `var(--name)` references, and `rgb()` / `rgba()` / `hsl()` / `hsla()`. Defensive prefilter rejects values containing `< > " ' ; \\` to prevent attribute / value breakout. Render-only fix; no editor changes. End-to-end verified on post 99 (`var(--global-palette14)` separator now renders with the inline style attribute).

### 1.11.1 — Scheduled Post Actions: chain multiple actions per trigger (2026-05-21)

Each post can now carry any number of actions on a single scheduled trigger instead of just one. Use it to fully transition a post on its expiry datetime — e.g. *add* taxonomy term "past", *remove* taxonomy term "current", and *change status* to Draft, all at the same time. Meta-box UI is a repeater (Add another action / Remove per row, JS show/hide scoped per row). Storage: new `_bw_dev_psa_actions` JSON-encoded post-meta key; the legacy single-action keys (`_bw_dev_psa_action`/`_status`/`_taxonomy`/`_terms`) are read transparently on first access (one-shot lazy migration) and cleared on the next save / cron-fire. Cron runner loops over the actions and fires each before clearing the schedule. The `bw_dev_psa_executed` hook signature changed (now receives the full actions array instead of a single action string) — non-breaking since the hook is brand new in 1.7.0 with no known third-party consumers. Schedule-index table renders multi-action rows as an ordered list with an "N actions chained" header.

### Editor & Admin group (4 new)
- **`disable_comments`** (1.1.0) — strips every form of comment functionality from a site. Removes comment + trackback post-type support, forces `comments_open` / `pings_open` / `comments_array` / `get_comments_number` closed everywhere, sets `pre_option_default_comment_status` / `pre_option_default_ping_status` to `closed` at the DB level, hard-fails any submission via `pre_comment_on_post` (`wp_die` 403), hides the Comments admin menu + admin bar node + Recent Comments dashboard widget, redirects `edit-comments.php` and `options-discussion.php` to the dashboard, empties all notification + moderation email recipients, dequeues `comment-reply.js`, drops the comments-feed link. No persisted settings — module enable is the toggle. The tab shows a prominent green "Comments are disabled site-wide" callout with a direct link to the Modules tab for turning it off. Replaces the standalone "Disable Comments" plugin.
- **`admin_menu_role`** (1.4.0) — per-role checklist for hiding top-level wp-admin sidebar items. Hook: `admin_menu` priority 999, unions all of the current user's roles' hide lists, calls `remove_menu_page()` for each. Settings tab enumerates the global admin-menu array (rendered as an admin who sees the canonical full list), one fieldset per role with a CSS-grid checkbox list. Top-level menus only. Recovery hatch: `BW_DEV_ADMIN_MENU_ROLE_DISABLE` constant in wp-config.php bypasses the module if an admin accidentally hides Settings → BW Dev. Distinct from the front-end `menu_visibility` module (which hides nav-menu items, not wp-admin chrome).
- **`dashboard`** (1.5.0, extended in 1.9.2) — two related features. (a) Checkboxes to remove default WP widgets: `dashboard_primary` (WP News), `dashboard_quick_press` (Quick Draft), `dashboard_right_now` (At a Glance), `dashboard_activity`, `dashboard_site_health`, plus the "Welcome to WordPress" panel via `remove_action('welcome_panel', 'wp_welcome_panel')` on `admin_init`. Recent Comments delegated to `disable_comments`. (b) Optional custom welcome widget pinned to the top via `array_merge` on `$wp_meta_boxes['dashboard']['normal']['core']`, with wp_editor body (`wp_kses_post`-sanitised) and per-role visibility filter. **1.9.2 added auto-detection of plugin-added widgets**: on every Dashboard load `cache_plugin_widgets()` snapshots `$wp_meta_boxes['dashboard']` into the `bw_dev_dashboard_widget_cache` option (autoload=false, write-only-on-change) BEFORE removing anything; the settings tab renders that cache as a checkbox list so Yoast / Gravity Forms / etc widgets can be ticked off. Empty-state hint tells the user to visit `/wp-admin/index.php` once to populate the cache.
- **`scheduled_actions`** (1.7.0, extended in 1.7.1) — Post Expirator / PublishPress Future replacement. Per-post one-shot trigger at a future datetime; three action types: change post status (publish/draft/private/pending/trash), add taxonomy term(s) (append via `wp_set_object_terms`), remove taxonomy term(s) (via `wp_remove_object_terms`). Works with any public CPT + any taxonomy registered for it. Storage: 5 underscore-prefixed post-meta keys (`_bw_dev_psa_*`). Cron: hourly recurring `bw_dev_psa_run` polling for `_bw_dev_psa_timestamp <= now()` in batches of 100; meta cleared after run. **Self-schedules on `init`** so auto-update sites don't need reactivation. Filter `bw_dev_psa_interval` overrides the schedule slug. Hour-level precision is the documented trade-off. **1.7.1** added a schedule-index table at the bottom of the settings tab (mirrors the Admin Note "Notes index"): Title (with Edit + View row actions), Type, Status (colour-coded), Scheduled (site-tz + coloured `due now` / `overdue Nh` / `in Nm/Nh/Nd` delta pill), Action summary.

### Security group (1 new)
- **`login_redirect`** (1.2.0) — per-role landing-page redirect after a successful login. Hooks `login_redirect` priority 20 / 3 args. Settings: one URL/path per registered role; empty keeps WP's default. Multi-role users walk `$user->roles` in stored order, first configured wins. `/`-prefixed paths resolve against `home_url()`; absolute URLs pass through (core's `wp_safe_redirect()` enforces the host allowlist). Quick-templates list in the settings tab.

### Front-end group (2 new)
- **`maintenance_mode`** (1.6.0) — Kadence Pro's threadbare maintenance page + the SeedProd-style "Coming Soon" plugin niche, replaced. Three modes: `coming_soon` (HTTP 200), `under_construction` (HTTP 200), `maintenance` (HTTP 503 + `Retry-After` header so monitors retry instead of alerting). Self-contained HTML splash on `template_redirect` priority 1 (no theme load, works even with a broken theme), `<meta robots noindex,nofollow>` emitted. Bypass chain: admins (`manage_options`), wp-admin/wp-login/AJAX/cron, configurable extra roles, `?bw_preview=TOKEN` shareable URL setting a session cookie via `hash_equals` against the stored token. Token auto-generated on first save when mode flips active.
- **`sidebars`** (1.8.0) — register N custom widget areas via a repeater (slug + name + description) AND unregister theme/plugin sidebars via a checkbox list. Hooks: `widgets_init` priority 11 (registers — after theme's default priority 10), priority 99 (unregisters — after every registration including our own). `[bw_dev_sidebar slug="..."]` shortcode wraps `dynamic_sidebar()` in `ob_start` and returns empty if not registered or no active widgets — safe drop-in for Kadence header/footer builder. Unregister list filters out our own customs. Widgets attached to unregistered sidebars move to "Inactive widgets" — not deleted, restored by re-ticking.

### Core group (3 new)
- **`login_branding`** (1.3.0) — brands `wp-login.php`. Replaceable logo (media-picker UI with configurable container width/height), logo link URL + title attribute, page background + form-panel background colours, primary button background + text colours (hex regex `/^#[0-9a-fA-F]{3,8}$/` validated), toggles to hide "Back to <site>" + "Lost your password?" / "Register" navigation. CSS is built from saved settings and emitted on `login_enqueue_scripts`; `login_headerurl` + `login_headertext` filters swap the logo link. Pairs with the existing Core → Branding white-label layer (different domain — Branding = wp-admin chrome, this = `wp-login.php`).
- **`server_info`** (1.9.0, extended in 1.9.1) — read-only environment snapshot for developers and support tickets. Six widefat striped tables: **Server** (software, hostname, OS, current site-tz time), **PHP** (version, SAPI, memory limit + current/peak via `memory_get_usage(true)`, max execution + input vars, upload + post max, OPcache used/total + cached scripts, default timezone), **WordPress** (version, URLs, locale, charset, permalink, multisite, active theme name+version, plugin count, BW Dev version, 8 dev-relevant constants), **Database** (host, name, server version via `$wpdb->db_version()`, charset, collation, prefix), **Filesystem** (ABSPATH + wp-content + uploads paths, writability flags, disk free + total), **Object cache + cron** (external cache flag, persistent cache class name, WP-Cron vs system-cron). **1.9.1** added two more tables: **Themes** (every installed theme, sorted active → active-parent → installed) and **Plugins** (every installed plugin, sorted network-active → active → inactive then alphabetical). "Copy to clipboard" button snapshots all sections into a plain-text block for pasting into support tickets / Slack threads.
- **`import_export`** (1.10.0) — back up + move BW Dev config across the fleet. **Export** downloads `bw-dev-settings-YYYY-MM-DD-HHMMSS.json` with a `_meta` envelope (plugin version, exported_at, source_site, format=`bw-dev-settings`, format_version=1) + the full `bw_dev_settings` option dump. **Import** accepts a previously exported file, validates `_meta.format`, then dispatches every recognised section through its own module's `sanitize()` before merging — so hand-edited or malicious files can't sneak unfiltered values into the option. Modules NOT present in the imported file keep their current settings (partial imports supported). 2 MB upload cap. Actions dispatched inline on `admin_init` (NOT admin-post.php — skips the `wp_validate_auth_cookie()` nopriv-routing trap; see 1.8.x history below).

### Indexing group (1 new)
- **`site_knowledge`** (1.11.0) — `knowledge.json` export designed to drop into Claude Code or any other AI tool as full site context. Six top-level blocks: `_meta` (format/version/timestamps + the actual filter inputs used), `site` (identity, URLs, locale, permalink scheme, front-page setup, active theme + parent), `navigation` (every registered theme nav location + every menu rendered as a nested item tree), `post_types` (per-CPT metadata + items with hierarchical nesting via parent_id, SEO meta, taxonomy assignments, optional content + featured image), `taxonomies` (per-tax metadata + hierarchical term trees), `developer` (mode=developer only — server / php / wp / db / themes / plugins). **SEO meta auto-detected** from Yoast / Rank Math / SEOPress / All in One SEO (free + Pro file variants); each post's `seo` block has a `_source` field naming the plugin. Per-post-type and per-taxonomy checkboxes, post-status filter, include-full-content toggle, include-featured-image toggle, max-items-per-type (1–5000, default 500). Download via nonced `?bw_dev_knowledge_action=download` on `admin_init`, served as `Content-Disposition: attachment; filename="knowledge.json"`.

### Brief 1.8.x detour: Disable Comments purge experiment (reverted)

1.8.1 added a "Purge all comments" admin-post.php action to the Disable Comments tab. Two follow-up patches chased bugs: **1.8.2** fixed a nested-form HTML problem (every module tab renders inside `<form action="options.php">`; the inner `<form action="admin-post.php">` got silently flattened by the browser → submit went to options.php instead) by switching to HTML5 `<button formaction>` + `formmethod` + custom-named nonce field. **1.8.3** fixed an admin-post.php routing problem (`wp_validate_auth_cookie()` returns false on HTTPS sites with only the `wordpress_logged_in_*` cookie → real admin requests routed to the nopriv branch → `wp_die('',400)`) by registering the handler on BOTH `admin_post_<action>` and `admin_post_nopriv_<action>`, with the handler's own caps + nonce check keeping anon requests safe. The malformed onclick was also fixed (esc_attr-wrap the JSON-encoded confirm string). **1.8.4** removed the entire purge feature — the admin-post.php integration kept surfacing edge-case bugs and adi judged it was causing more confusion than value. Historical comments can be cleared via WP-CLI: `wp comment delete $(wp comment list --format=ids) --force`. The same 1.8.2 + 1.8.3 fixes were applied to login_log's purge button for consistency.

**Conventions established by the 1.8.x detour** (worth knowing for future destructive-action UI):
- **Prefer inline `admin_init` action dispatch over admin-post.php** for actions scoped to a settings page — already authenticated, no nopriv-routing concerns.
- If you must use admin-post.php, register handlers on BOTH `admin_post_<action>` AND `admin_post_nopriv_<action>` — the handler's own `current_user_can()` + nonce check keeps it safe.
- For destructive buttons inside a settings-tab form, use HTML5 `<button formaction="..." formmethod="post" formnovalidate>` instead of nesting a new `<form>` (HTML doesn't allow nested forms).
- Custom nonce field names per module (`_bw_dev_<module>_<action>_nonce`) so they don't clobber the outer settings `_wpnonce`. Read via `check_admin_referer($action, $custom_nonce_name)`.
- `esc_attr()` wrap any `onclick="..."` JS that interpolates a JSON-encoded string — `wp_json_encode()` produces unescaped `"` characters that collide with the attribute quotes.

## What was published before the sprint (2026-05-14)

- **1.0.0 release**: 20-module milestone shipped to the dist host. The 1.0.0 line is the foundation everything in this sprint builds on.
- **1.0.1 patch**: addressed 13 ABSPATH-guard warnings that `tools/test-plugin.sh` emitted during 1.0.0. The guards were already present in every file — they just sat past the scanner's 20-line window because of long file-level docblocks. Awk-transformed all 13 to put the guard immediately after `<?php`. Zero functional change.
- Latest dist artifacts:
  - Manifest: https://plugins.bowden.works/wp-json/bw/v1/update-check?slug=bw-dev → currently serves `1.0.1`.
  - Snapshot: `/srv/apps/bw-plugins/.releases/bw-dev/`.

## Where things stand

- **36 modules built** (20 shipped at 1.0.1, plus 12 added across 1.1.0 → 1.12.0, plus 4 added across 1.13.0–1.13.1 — three legacy-plugin merges and the new Analytics module). All registered in `BW_Dev_Plugin::build_modules()`.
- **Full module list:** `favicon`, `sticky`, `youtube`, `post_link`, `admin_columns`, `admin_menu_role`, `dashboard`, `scheduled_actions`, `svg_upload`, `image_optimizer`, `admin_note`, `disable_comments`, `flywheel_auto_updates`, `login_branding`, `server_info`, `import_export`, `maintenance_mode`, `menu_visibility`, `sidebars`, `hide_login`, `login_redirect`, `security_hardening`, `login_log`, `subtitle`, `separator`, `robots_txt`, `llms_txt`, `site_knowledge`, `title_override`, `established_year`, `vendors` (label "Plugins"), `theme`, `inline_search`, `animate_row`, `scroll_down`.
- **Sidebar layout** with seven groups:
  - **Core** (5): About, Modules, Branding, Flywheel Auto-Updates, Login Page Branding, Server Info, Import / Export. (About / Modules / Branding are admin-page injections, not module-registry entries — total module-registry count for `core` is 4.)
  - **Editor & Admin** (10): SVG Upload, Admin Note, Disable Comments, Sticky Elements, Admin Columns, Admin Menu by Role, Dashboard, Scheduled Post Actions, Title Override, Established Year.
  - **Front-end** (7): Favicon, Maintenance Mode, Menu Visibility, Sidebars, Inline Search, Animate Row, Scroll Down.
  - **Security** (4): Hide Login URL, Login Redirects, Security Hardening, Login Activity Log.
  - **Indexing** (3): Robots.txt Manager, LLMs.txt Generator, Site Knowledge.
  - **Blocks** (4): YouTube Block, Post Link Block, Subtitle Block, Separator Block.
  - **Vendors** (2): Plugins (vendor checklist), Theme (audit).
- Single root option `bw_dev_settings`, dispatching sanitize, per-module enable toggles, branding/white-label layer. Default landing tab is `about`.
- **Group-aware defaults**: Blocks default OFF; everything else (Core / Editor & Admin / Front-end / Security / Indexing / Vendors) defaults ON.
- **`BW_Dev_Module_Interface::group()`** is required as of 1.0.0 — places each module in a sidebar group. Valid values: `core` / `editor_admin` / `frontend` / `security` / `indexing` / `blocks` / `vendors`; unknowns fall back to Core.
- **DB**: activation creates `{prefix}bw_dev_login_log` via `dbDelta` (idempotent, gated on `bw_dev_login_log_db_version`). Daily `bw_dev_login_log_prune` cron + hourly `bw_dev_psa_run` cron are scheduled idempotently.
- **Constants the deployer should know about**:
  - `BW_DEV_HIDE_LOGIN_DISABLE` — bypasses Hide Login URL (recovery hatch).
  - `BW_DEV_ADMIN_MENU_ROLE_DISABLE` — bypasses Admin Menu by Role (recovery hatch).
  - `DISALLOW_FILE_EDIT` — auto-defined when Security Hardening's "Disable file editing" is on.
- **Other internal options**: `bw_dev_dashboard_widget_cache` (autoload=false; populated by Dashboard module on `wp_dashboard_setup` priority 99 — empty until the user visits the actual Dashboard at least once).
- **Activation migration**: reads legacy options (`bw_favicon_url`, `bw_sticky_settings`, `bw_youtube_embed_settings`, `bw_admin_column_settings`, `bw_admin_note_settings`, `bw_established_date`) into the unified `bw_dev_settings` schema. Conservative merge, tracked by `bw_dev_migration_version`, idempotent. Legacy options are NOT deleted — source plugins handle their own uninstall.
- **Dev site** at https://bw-plugins.demoing.info is the test bed. All sprint modules have been smoke-tested via WP-CLI; visual verification still pending for a few of the late-sprint additions.

## How to pick up — by scenario

### Scenario A: Fresh Claude session, no prior context

1. Read `CLAUDE.md` in this directory (already auto-loaded by Claude Code).
2. Read THIS file (you are here).
3. Read `docs/SESSION-LOG.md` newest entries (top of file) for what was just done — there are 18 entries covering 2026-05-15 / 16 alone.
4. Read `docs/notes-from-adi.txt` — adi's original brief, preserved verbatim.
5. Read `docs/ROADMAP.md` for the phase history + post-1.0 sprint summary.
6. Read `docs/KNOWN-ISSUES.md` before recommending anything migration-related.
7. **Hard rules** (also in plugin CLAUDE.md):
   - Settings live at `Settings → BW Dev` (submenu under Settings, never a top-level admin menu).
   - The source plugins at `/srv/apps/bw-plugins/wp-content/plugins/bw-{admin-column,favicon,pretty-post-link,sticky-settings,youtube-embed,admin-note}` are the **reference implementation** — DO NOT modify them.
   - Re-prefix everything when porting EXCEPT the grandfathered exceptions: `_bw_admin_note` post-meta key, `bw_schema_*` options (separate plugin), `[bw_year_number]` / `[bw_year_word]` shortcode aliases (registered only when the legacy plugin isn't), `[bw_youtube]` shortcode (suppressed when `bw-youtube-embed` is active).
   - All scans must pass before release: `tools/cleanup-scan.sh bw-dev`, `tools/security-scan.sh bw-dev`. `tools/test-plugin.sh` requires docker; only `rian` can run it.

### Scenario B: Cutting a patch / minor release

```bash
cd /srv/apps/bw-plugins
tools/bump-version.sh bw-dev <version>       # adi can run
# Update CHANGELOG.md — move [Unreleased] entries under the new versioned heading.
tools/cleanup-scan.sh bw-dev                 # must be clean
tools/security-scan.sh bw-dev                # must be clean
tools/test-plugin.sh bw-dev                  # rian only — docker
tools/release.sh bw-dev <version>            # rian only
```

The release script: snapshots source → runs all 3 scans → builds the production zip → publishes to `/srv/apps/bw-plugins-dist/wp-content/uploads/plugin-updates/` → verifies the dist manifest serves the new version. Halts on any failure.

### Scenario C: Rolling bw-dev to a real client site

1. Install + activate `bw-dev` on the target site (or wait for auto-update).
2. Activation runs `BW_Dev_Migration::on_activation()` automatically — reads legacy options, fills `bw_dev_settings`, installs the login-log table, schedules both cron events. Idempotent.
3. Visit `Settings → BW Dev` and verify every module shows the migrated config. Spot-check front-end (favicon, sticky elements, menu visibility, sidebars) and editor (YouTube block, Post Link block, Admin Note panel, Subtitle, Separator, Scheduled Action meta box).
4. Visit `Settings → BW Dev → Plugins` (Vendors module): install/activate the recommended third-party plugins. Then `Settings → BW Dev → Theme`: confirm Kadence parent + child are the only themes; delete strays.
5. **Visit `/wp-admin/index.php` once** to populate the `bw_dev_dashboard_widget_cache` — without this the Dashboard tab's "Plugin-added widgets" list stays empty.
6. Deactivate the legacy single-purpose plugins one at a time. Suggested order:
   - `bw-favicon`
   - `bw-sticky-settings`
   - `bw-youtube-embed`
   - `bw-pretty-post-link`
   - `bw-admin-column`
   - `bw-admin-note` (regent + promobix only)
   - `established-year` (newcap only)
   - `bw-inline-search` (only where it was installed)
   - `bw-animate-row` (only where it was installed)
   - `bw-scroll-down` (only where it was installed)
7. **Content rewrites** (do BEFORE deactivating the matching source plugin, then re-verify):
   - YouTube block: `wp search-replace 'wp:bw/youtube' 'wp:bw-dev/youtube' --skip-columns=guid --dry-run`
   - Post Link blocks: `wp search-replace 'wp:bw/pretty-post-link-' 'wp:bw-dev/post-link-' --skip-columns=guid --dry-run`
   - Take a DB backup first. Always.
8. **CSS rewrites** (audit any custom theme / site CSS):
   - `.bw-is-sticky`, `.bw-is-pushed`, `.bw-sticky-placeholder` → `.bw-dev-*`
   - `.bw-ppl*` → `.bw-dev-post-link*`
   - `.bw-youtube-block*` → `.bw-dev-youtube-block*`
   - `.bw-admin-column-*`, `.bw-featured-image-cell`, `.bw-admin-thumb` → `.bw-dev-admin-columns-*`, `.bw-dev-admin-columns-featured-image-cell`, `.bw-dev-admin-columns-thumb`
9. **Exception**: `[bw_youtube]`, `[bw_year_number]`, `[bw_year_word]` shortcodes and `_bw_admin_note` post-meta key need NO rewrite — bw-dev preserves them for compat.

Full per-module rename details are in `docs/KNOWN-ISSUES.md`.

### Scenario D: adding a 37th module

Pattern is well-established. Reference implementations by complexity:
- Simplest no-settings: `class-bw-dev-module-flywheel-auto-updates.php`.
- Simplest with settings: `class-bw-dev-module-favicon.php`.
- Read-only display module: `class-bw-dev-module-server-info.php`.
- Custom action handler (download / export): `class-bw-dev-module-import-export.php` or `class-bw-dev-module-site-knowledge.php`.
- Most feature-rich: `class-bw-dev-module-admin-columns.php`.

The 8 steps:
1. New file `includes/modules/class-bw-dev-module-<slug>.php` implementing `BW_Dev_Module_Interface` — **including the `group()` method**.
2. `require_once` in `bw-dev.php`. **Edit bw-dev.php FIRST, await success, THEN edit `class-bw-dev-plugin.php`** — the stale-read trap when these are edited in parallel has caused multiple class-not-found fatals during the sprint.
3. Append `new BW_Dev_Module_<Slug>()` to `BW_Dev_Plugin::build_modules()`.
4. Bundle JS/CSS under `assets/{js,css}/` (or `blocks/<slug>/` for Gutenberg blocks with `block.json`).
5. Settings live as `bw_dev_settings[<slug>]`. Form fields use `bw_dev_settings[<slug>][...]`. Submit goes through Settings API; `BW_Dev_Settings::sanitize()` dispatches to the module's `sanitize()` via the hidden `__tab` field.
6. If the new module replaces a legacy plugin: add a clause to `BW_Dev_Migration::migrate_legacy_options()` + extend `legacy_map()`. Keep migration conservative.
7. Re-prefix everything (CSS, JS globals, AJAX, REST, events). Exception: post-meta keys / shortcodes that already exist on real client sites — match the `admin_note` / `established_year` precedent.
8. Run `cleanup-scan` + `security-scan` until clean. Update CHANGELOG `[Unreleased]`. Add a SESSION-LOG entry at the top.

**For modules with cron**: schedule from `register()` on `init` AND from `BW_Dev_Migration::on_activation` (both idempotent via `wp_next_scheduled` check) — the init path is load-bearing for sites picking up the module via auto-update (they never re-activate). See `scheduled_actions::schedule_cron()`.

**For modules with destructive actions** (purge / regenerate / reset / etc): prefer **inline `admin_init` dispatch** with a nonced query param. See `import_export` and `site_knowledge` for the pattern. Avoid admin-post.php unless the action truly needs to be callable from the front-end.

## Pending items

| What | Who | Why blocked |
|---|---|---|
| Regenerate `.pot` translation template | adi or rian | New strings since the last regen — covers the eleven 1.1.0–1.11.2 modules plus the new Image Optimizer module. `srv-gw wp --project bw-plugins -- i18n make-pot wp-content/plugins/bw-dev wp-content/plugins/bw-dev/languages/bw-dev.pot --domain=bw-dev`. Not blocking — only matters when localising. |
| Visual smoke-pass of all 12 new modules on a real client site | adi or rian | recommended before mass rollout. The risky ones to verify on production: `maintenance_mode` (admin-bypass logic), `admin_menu_role` (lockout risk), `scheduled_actions` (cron timing), `image_optimizer` (upload pipeline mutation), `site_knowledge` download. |
| Browser/visual QA of the 3 new 1.13.0 modules | adi or rian | only programmatic (WP-CLI) verification done so far. Check on the dev site: `inline_search` (icon expands in the Kadence header, theme toggle replaced, Esc closes), `animate_row` (rows fade in on scroll, AOS loads from `assets/vendor/aos/` not unpkg — confirm in Network tab), `scroll_down` (indicator injects on a row with the trigger class, smooth-scrolls, color picker works in settings). |
| Visual QA of the 1.13.1 Animate Row fix on promptvictoria.ca | rian | once Flywheel auto-updates land 1.13.1, verify the hero renders correctly and below-the-fold rows still scroll-fade-in. The fix is on `assets/js/animate-row.js` — skip above-the-fold + AOS.refreshHard on window.load. |
| Auto-run `srv-gw fix-acls` inside `create-project` | future / chip on screen | systemic fix for the umask-leak issue caught 2026-05-29 (adi's mode-700 `.claude/` in bw-plugins). Spawned as a separate task — touches gateway code, not bw-dev. |
| Product page for bw-dev on `plugins.bowden.works` | rian | infra access. |
| First production rollout (pick first client site) | adi / rian | regent or promobix recommended (both have `bw-admin-note`). |
| Harden `tools/test-plugin.sh` ABSPATH check | future | scanner false-positive class still exists; touches tooling, not the plugin. |
| Optional: switch SVG sanitizer to `enshrined/svg-sanitize` | future | needs Composer pipeline. |
| Optional: full `phpinfo()` dump in Server Info | future | requires iframe-embedding to avoid breaking the admin DOM. |

## Architecture quick reference

```
bw-dev/
├── bw-dev.php                                Thin bootstrap. Constants, requires, register_activation_hook, BW_Dev_Plugin::instance()->boot() on plugins_loaded.
├── uninstall.php                              Drops bw_dev_settings + bw_dev_migration_version + login-log table + bw_dev_dashboard_widget_cache + per-post meta + scheduled hooks. Multi-site aware.
├── CLAUDE.md, README.md, CHANGELOG.md, LICENSE
├── includes/
│   ├── interface-bw-dev-module.php           Module contract (includes group()).
│   ├── class-bw-dev-settings.php             Single option bw_dev_settings + dispatching sanitize + group-aware enable defaults.
│   ├── class-bw-dev-brand.php                White-label resolver + plugin-row filter + block-category registration.
│   ├── class-bw-dev-admin-page.php           Sidebar-layout Settings → BW Dev page with group sections.
│   ├── class-bw-dev-plugin.php               Singleton bootstrap, module registry, conditional register().
│   ├── class-bw-dev-migration.php            Activation-hook migration + cron scheduling (login_log_prune + bw_dev_psa_run).
│   └── modules/                              36 module classes.
├── assets/{js,css}/                          Shared module assets.
├── blocks/{youtube,post-link,subtitle,separator}/   Gutenberg block bundles.
├── languages/bw-dev.pot                      Translation template — STALE, needs regen before next release.
├── vendor/plugin-update-checker/             Auto-update support. DO NOT EDIT.
└── docs/                                     Dev docs (this file, SESSION-LOG, ROADMAP, SPEC, ARCHITECTURE, KNOWN-ISSUES, TESTING, notes-from-adi.txt).
```

**Filters / actions exposed**:
- `bw_dev_brand` — override resolved brand config in a mu-plugin.
- `bw_dev_modules` — append/filter the module registry.
- `bw_dev_module_enabled` — override per-module enable state.
- `bw_dev_module_default_enabled` (`$default, $slug, $group`) — override the per-group enable default.
- `bw_dev_module_registered` — fires per module after `register()` returns.
- `bw_dev_loaded` — fires once after all modules are bootstrapped.
- `bw_dev_psa_interval` — override the Scheduled Post Actions cron schedule slug (default `hourly`).
- `bw_dev_psa_executed` (`$post_id, $action`) — fires after a scheduled action executes.
- `bw_dev_title_override_post_types`, `bw_dev_llms_post_types`, `bw_dev_llms_txt_content`, `bw_dev_llms_full_txt_content` — module-specific overrides.

## Useful one-liners

```bash
# Visit the settings page
xdg-open https://bw-plugins.demoing.info/wp-admin/options-general.php?page=bw-dev

# Read / reset the unified option
srv-gw wp --project bw-plugins -- option get bw_dev_settings --format=json
srv-gw wp --project bw-plugins -- option delete bw_dev_settings

# Trigger activation manually (testing — re-runs migration + cron scheduling, idempotent)
srv-gw wp --project bw-plugins -- eval 'BW_Dev_Migration::on_activation();'

# Lint a single file inside the WP container (works without docker socket)
srv-gw wp --project bw-plugins -- eval 'echo shell_exec("php -l /var/www/html/wp-content/plugins/bw-dev/<path>");'

# Run BW scans
tools/cleanup-scan.sh bw-dev
tools/security-scan.sh bw-dev
tools/test-plugin.sh bw-dev            # rian only — docker

# Bump version (adi can run)
tools/bump-version.sh bw-dev <version>

# Regenerate translation template
srv-gw wp --project bw-plugins -- i18n make-pot wp-content/plugins/bw-dev wp-content/plugins/bw-dev/languages/bw-dev.pot --domain=bw-dev
srv-gw fix-permissions --project bw-plugins

# Generate knowledge.json on the CLI (skips the admin download — useful for scripted exports)
# (just visit the settings tab's download button — easier than scripting)

# Confirm dist host is serving the latest release
curl -s 'https://plugins.bowden.works/wp-json/bw/v1/update-check?slug=bw-dev' | jq .version
```
