# Changelog

All notable changes to BW Dev are documented here.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [1.13.2] - 2026-05-29

### Changed
- **Animate Row: feature disabled site-wide pending a proper rewrite.** The 1.13.1 patch (skip above-the-fold + `AOS.refreshHard()` on `window.load`) fixed the blank-hero symptom on promptvictoria.ca, but a follow-up report showed that below-the-fold rows then never revealed either. Rather than ship another partial fix, the entire Animate Row pipeline is short-circuited: `enqueue_assets()` returns immediately, so no AOS CSS or JS is enqueued on any frontend page on any site. Per-site enable state and saved configuration are **preserved untouched** in `bw_dev_settings[animate_row]` for when the feature is rewritten and re-enabled. The settings tab now shows a prominent warning notice at the top explaining the disablement and recommending Kadence's per-block "Advanced → Block Animation" controls as an interim path. The Modules-tab toggle still exists but has no effect while this short-circuit is in place. To re-enable later: delete the early `return` at the top of `enqueue_assets()` in `class-bw-dev-module-animate-row.php`.

## [1.13.1] - 2026-05-29

### Fixed
- **Animate Row: blank above-the-fold content on live sites.** Reported on promptvictoria.ca (Flywheel). The module was blanket-stamping `data-aos="fade-up"` on every Kadence row/section, including the hero. AOS's `[data-aos^=fade] { opacity: 0 }` rule kicked in immediately, but AOS's reveal logic reliably failed to add `aos-animate` to elements that were already in the initial viewport (a known weakness with tall heroes, bg-video parents, and transformed ancestors) — leaving the hero stuck at opacity 0 forever, rendering a blank page. **Two fixes:** (1) `applyAnimations()` now skips any element that intersects the initial viewport (`getBoundingClientRect().top < viewportH && bottom > 0`), so above-the-fold rows are never opacity-hidden in the first place — 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 images / iframes / bg videos finish loading and shift the layout (this catches the secondary class of stuck elements just below the fold).

### Changed
- **Analytics: removed the "Emit GTM tags" toggle.** GTM emission is now keyed solely off the `gtm_id` field — non-empty → emit, empty → skip. Eliminates the separate checkbox and the `GTM-NOIDSET` placeholder behavior (placeholder injection no longer happens; an empty field means no GTM markup at all, which is what users expected). One less click to configure. The settings tab also drops the "Markup that will be emitted" preview block — it was wall-of-code clutter and the real source of truth is viewing the live site. Migration is implicit: any saved `gtm_enabled` key is ignored. Sites with a non-empty `gtm_id` keep emitting; sites with an empty ID stop (matching the new and clearer behavior). To pause GTM on a live site after this update, clear the GTM ID field (or disable the whole Analytics module on the Modules tab).

## [1.13.0] - 2026-05-29

### Added
- **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). Two independent features:
  - **GTM**: gated by an in-module "Emit GTM tags" checkbox that defaults OFF (so updating bw-dev on an existing client site doesn't surprise-inject tracking). When on, the `<head>` snippet hooks `wp_head` priority 1 and the `<noscript>` iframe hooks `wp_body_open` priority 1. When on with an empty GTM ID field, the placeholder `GTM-NOIDSET` is used so the markup structure is present (handy when a third party hands over the real container ID later) — matches the documented "if no id, use 'GTM-NOIDSET'" behavior.
  - **Search Console**: a single paste-tolerant text field. Sanitizer extracts the bare verification token from the full `<meta name="google-site-verification" content="…">` tag, a verification-file URL like `https://example.com/google123abcd.html`, the bare `google123abcd.html` filename, or just the raw token. Empty value = no meta tag emitted.
  - Settings tab shows a live "Markup that will be emitted" preview reflecting the current GTM ID / placeholder, plus a "Stored as / Will emit" pair under the SC field once a token is configured.
- **Inline Search module** (`inline_search`, group `frontend`) — merges the standalone `bw-inline-search` plugin. An elegant search field that expands from a search icon, scoped to one configurable container (the site header by default) so its styles and behavior never leak onto the rest of the page. Three placements: the `[bw_dev_inline_search]` shortcode (attributes: `placeholder`, `icon_color`, `theme="light|dark"`, `align`), a "BW Inline Search" widget, and automatic replacement of the theme's own header search toggle (notably Kadence's search drawer) inside the container. Settings tab has a single "Container CSS selector" field. The selector is interpolated into a scoped `<style>` block, so it's stripped of CSS-structural characters (`; { } \ < > " '`) on both save and output. Re-prefixed throughout: option → `bw_dev_settings[inline_search][container_selector]`, JS config `bwInlineSearchSettings` → `bwDevInlineSearchConfig`, CSS `.bw-inline-search-*` → `.bw-dev-inline-search-*`. The legacy `[bw_inline_search]` shortcode is kept as a back-compat alias, registered only when no other plugin already claims it.
- **Animate Row module** (`animate_row`, group `frontend`) — merges the standalone `bw-animate-row` plugin. Applies a global AOS scroll-in animation to Kadence rows / top-level columns / sections that don't already carry their own `data-aos` attribute. Per-site config: animation type (27 AOS keywords), duration, easing (17 keywords), delay, trigger offset, animate-once, and which Kadence element types to target. **AOS is now bundled with the plugin** (`assets/vendor/aos/`, v2.3.4) and enqueued locally instead of hot-loaded from the unpkg CDN — removing the supply-chain + visitor-privacy exposure of running third-party JS from a CDN on every public page, and the CDN single-point-of-failure. The ten flat `bw_animation_*` / `bw_target_*` options collapse into the single nested `bw_dev_settings[animate_row]` section; the inline footer init script becomes a localized handle on `assets/js/animate-row.js`. AOS + the init script load only when the module is enabled AND at least one target type is selected.
- **Scroll Down module** (`scroll_down`, group `frontend`) — merges the standalone `bw-scroll-down` plugin (v4.0.0). A scroll-down indicator (one of six animated SVG icons) that smooth-scrolls to the next section or a configured anchor. Two placements: the `[bw_dev_scroll_down]` shortcode, or a trigger CSS class added to any Kadence row (the indicator is injected + wired automatically). Settings tab: icon picker, size, color (wp-color-picker), label text, bottom offset (`%` or `px`), anchor target, trigger class, auto-target-next-section toggle, float-animation toggle, plus a live hero preview. Re-prefixed: option `bw_scroll_down_settings` → `bw_dev_settings[scroll_down]`, JS config → `bwDevScrollDownConfig`, CSS `.bw-scroll-down*` → `.bw-dev-scroll-down*`, `@keyframes bw-float` → `bw-dev-float`. Frontend logic ported from the plugin's clean inlined footer script (not the stale debug copy in its `assets/`). Legacy `[bw_scroll_down]` kept as a back-compat alias when unclaimed.
- **Migration v2.** Activation migration bumped to version 2 so already-activated sites re-run the (conservative, fill-missing-only) migration and pick up the three new sections: `bw_search_container_selector` → `inline_search`; the ten `bw_animation_*`/`bw_target_*` options → `animate_row`; `bw_scroll_down_settings` → `scroll_down`. Legacy options are never deleted (the source plugins own their own uninstall). `legacy_map()` extended with the three new source-plugin basenames so the deactivation diagnostics cover them.

## [1.12.1] - 2026-05-27

### Changed
- **Image Optimizer preview: button-triggered instead of auto-on-change.** The Phase 3 "live preview re-renders on every field change" behavior worked fine on the dev site but turned out to be too slow on real client hosts (Imagick on Flywheel can take several seconds per render). Replaced with an explicit **Update preview** button so the user controls when processing happens. Button shows a spinner + "Processing…" label while the AJAX call is in flight, then "Done in N.Ns" with the elapsed time after success. Any change to a profile field marks the preview as **stale** — the After image gets a dashed yellow outline and a status message ("settings changed — click Update preview") so the user knows the preview isn't current. Initial render still fires once on page load (so the saved settings are visible without a click), and picking a new sample image still triggers an immediate render (since that's an explicit user action).

## [1.12.0] - 2026-05-27

### Added
- **Image Optimizer module** (`image_optimizer`, group `editor_admin`) — Phase 1: resize + compression. Hooks `wp_handle_upload_prefilter` so image uploads are processed BEFORE WordPress saves them, preventing 8-10MB AI-generated PNGs (Gemini, Midjourney, etc.) from filling client Media Libraries at the source. Pipeline: edge crops → resize (fit-to-bounds or cover-with-9-anchor) → JPEG compression (Imagick-first with `wp_get_image_editor` fallback). PNG transparency detection via `Imagick::getImageChannelRange( CHANNEL_ALPHA )` — real alpha keeps PNG output, decorative alpha (channel enabled but every pixel opaque) flattens to JPEG. Filename suffix `_bw` (never doubled). Skip conditions: non-image uploads, SVGs (handled by SVG Upload module), JPEGs already smaller than 512 KB AND within profile dimensions. Source replacement only happens if the processed output is actually smaller than the original. Master "process new uploads" toggle defaults OFF (separate from the Modules-tab enable) so module deployment to existing client sites doesn't surprise-mutate uploads. Bundled "Web 1920" default profile: 1920×1080 fit-to-bounds, JPEG q82, no edge crops, watermark disabled. Single-profile in v1; multi-profile is a v1.2.0 feature. Lifetime stats counter (images processed, bytes saved) shown in the settings tab with optional reset-on-save. Watermark removal (the crop.bowden.works Gemini-watermark trick) is stubbed in the data model but lands as Phase 2 in a follow-up release.
- **Per-user admin-bar widget for Image Optimizer**: editors who can `upload_files` see an "Optimizer" admin-bar dropdown with three states — Active (full pipeline including watermark removal once Phase 2 ships), Resize only (skip watermark for real-photo uploads), Off (pass-through, for cases like "I need this image pristine"). State is sticky per-user via user meta `_bw_dev_image_optimizer_mode`. Widget is hidden when the feature is globally disabled. State change goes through a nonce-gated `admin-post.php?action=bw_dev_image_optimizer_set_mode` handler with `upload_files` capability check. Per-upload opt-out is "set widget to Off → upload → set back" — no separate per-upload UI in v1.

### Fixed
- **Image Optimizer: also hook `wp_handle_sideload_prefilter`** so programmatic upload paths (`wp media import` via wp-cli, remote-URL imports, theme demo importers, block-editor "insert from URL" flows) get the same optimization treatment as wp-admin Media Library uploads. Caught during dev-site smoke test when `wp media import` of an 18 MB PNG bypassed processing entirely. Both filters share the same `$file` shape and the same callback.
- **Image Optimizer: stats counter now reliably ticks** on every processed upload. `record_stats()` was reading via the cached settings layer, which could be clobbered by a stale snapshot held by the BW_Dev_Plugin singleton in edge cases. Rewrote to go straight to `get_option()` → mutate → `update_option()`, then sync the in-memory cache.

### Changed (Image Optimizer Phase 3 — multi-profile + UI refinement)
- **Live preview now runs the full pipeline.** The preview previously showed only the watermark-removal step on the source image. It now applies (in order) watermark removal → edge crops → resize (fit/cover with anchor) → JPEG compression at the profile's quality — exactly matching the upload pipeline. So edge crops and resize-mode changes now show up in the After panel. Preview-side downscaling to 900 px max width is only applied as a final transport step *after* the profile's own resize, so smaller profile targets (Square 1080, etc.) render at their actual final dimensions instead of being further shrunk for the preview. Auto-refresh listener broadened to all profile fields (was watermark-only).
- **Multi-profile model.** Replaced the single-profile design with named profiles. Each profile is a self-contained bundle (label, dimensions, fit/cover, anchor, quality, edge crops, watermark sub-block with its own enable). Editors pick which profile to apply from the admin-bar widget on a per-upload basis.
- **Admin-bar widget rewritten** for dynamic profile lists. The 3-state (Active / Resize-only / Off) trichotomy is gone — replaced by "Off" + one entry per saved profile. The "resize only" use case is now expressed as "create a profile with watermark disabled and pick that." Each user has a sticky per-session choice (`_bw_dev_image_optimizer_profile` user meta — replaces `_mode`). Falls back to the site-wide default profile when no per-user pick exists. Auto-migrates pre-multi-profile `_mode` values (`active`/`resize_only` → default profile; `off` stays off).
- **Settings tab — tabbed profile editor with 2-column sticky preview.** Profiles live in a tab strip at the top of the section; clicking a tab switches the editor pane below. A `+ Add profile` button creates a new pane (named "New profile N" by default) — JS assigns a temp `_new_*` slug and the sanitizer reslugs from the label on save with collision handling. Per-pane: "Use as default profile" radio (all profiles share the same name so it's an exclusive selection), and a "Delete this profile" button (disabled when only one profile remains — at-least-one invariant). The Live Preview column lives in a sticky right-hand column (`position: sticky; top: 42px`) inside the active pane and JS-moves between panes on tab switch, so it's always in view while you adjust watermark fields on the active profile.
- **Sample image is shared across profiles.** One picker at the top of the preview column; the chosen attachment is used for whichever profile you're currently editing.
- **`active_profile` setting renamed `default_profile`** to better reflect its role: the profile applied to editors who haven't made a per-user pick yet (not "the one always being applied").
- **Sanitize hardened**: handles add/edit/delete in a single form submit via `__deleted_profiles[]` hidden inputs; resolves `_new_*` temp slugs to label-derived slugs with `_2`, `_3`, ... collision suffixes; enforces at-least-one invariant by re-seeding the default profile if a submission would result in zero profiles; sanitizes `default_profile` to always reference a slug that survives the form save.

### Added (Image Optimizer Phase 2 — watermark removal + live preview)
- **Watermark removal step** in the upload pipeline. Algorithm ported from `crop.bowden.works` (`/srv/apps/opti/app.py`): covers a configurable bottom-right rectangle with a fill color sampled from outside or inside the box, optionally textured with bilinear-resized random noise, blended into the surrounding image via a corner-distance gradient mask (`1 - sqrt((1-u)² + (1-v)²)` feather pattern). Runs in 4-7 ms on a 1920×1047 image via Imagick (`getImageChannelRange()`-aware build).
- **Per-profile watermark config** in `bw_dev_settings[image_optimizer][profiles][web_1920][watermark]`: `enabled` (default false), `w_pct` (1-50, default 11), `h_pct` (1-50, default 11), `sample` (`outside` / `inside`, default `outside`), `noise` (0-100, default 0).
- **Settings tab — Watermark removal section** with enable checkbox, width/height percentage inputs, sample-from radio, and noise slider. Below it: a Live Preview area with a Media Library picker for choosing a sample image (typically a previously-uploaded watermarked image), and a side-by-side Before / After preview that **auto-re-renders within 300ms of any watermark-field change** — no save needed to iterate.
- **AJAX endpoint** `bw_dev_image_optimizer_preview` (nonce + `manage_options` cap) that runs the watermark-removal algorithm on the chosen sample attachment with current form values, downscales to 900px max width for fast transport, and returns the result as a base64 data URL — keeps temp files off disk, no cleanup required.
- **Admin-bar "Active" vs "Resize only" modes now functionally diverge.** Until Phase 2 they did the same thing because watermark removal didn't exist yet. Now: Active runs the full pipeline (resize + compress + watermark removal when the active profile has it on); Resize only runs resize + compress but skips the watermark step — for editors uploading real photos where the bottom-right may legitimately contain content they want to keep.

## [1.11.2] - 2026-05-22

### Fixed
- **Separator + Subtitle blocks — color picked from a Kadence theme palette was silently dropped on the frontend.** Both blocks ran the saved color attribute through `sanitize_hex_color()`, which only accepts hex like `#ff6600` and returns null for anything else. The block editor's `ColorPalette` stores values exactly as the theme provides them, and Kadence stores its palette colors as CSS-variable references like `var(--global-palette14)`. That string failed the hex check, the inline `style="color:..."` was omitted, and the frontend rendered with the theme's inherited text color instead of the picked one. The editor preview was fine because it skipped the sanitizer and used the raw value. **Fix:** replaced the hex-only check with a wider validator (kept inline as a closure in each `render.php` to avoid include-twice redeclaration) that accepts hex via `sanitize_hex_color`, `var(--name)` references, and `rgb()` / `rgba()` / `hsl()` / `hsla()` color functions — anything else still falls back to theme inheritance. Defensive prefilter blocks `<`, `>`, `"`, `'`, `;`, `\` to prevent the value from breaking out of the inline-style attribute. No editor changes needed — only `render.php`. Confirmed against post 99 on the dev site (`var(--global-palette14)` now reaches the frontend as a working CSS variable).

## [1.11.1] - 2026-05-22

### Changed
- **Scheduled Post Actions — chain multiple actions in one trigger.** 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. The meta-box UI is a repeater: each row is one action (action-type select + conditional status / taxonomy + terms fields), with an "Add another action" button at the bottom and a "Remove" link per row. JS scopes the show/hide logic per row so each row's taxonomy + terms selects work independently. **Storage** changes: actions are stored as a JSON-encoded list under a new `_bw_dev_psa_actions` post-meta key; `_bw_dev_psa_timestamp` is still the canonical "is scheduled" key. The legacy single-action meta 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 — so any post scheduled under 1.7.0–1.11.0 keeps firing without action needed. **Cron runner** loops over the actions list and executes each one before clearing the schedule; the `bw_dev_psa_executed` action hook now receives the full actions array (was a single action string in 1.7.0–1.11.0 — this is a non-breaking signature change since no third-party code consumes the hook yet). **Schedule-index table** in the settings tab shows every action when a row has more than one (rendered as an ordered list with a small "N actions chained" header), or the single-action summary inline when only one is scheduled. `uninstall.php` adds `_bw_dev_psa_actions` to the per-post meta cleanup list (legacy keys also still cleaned up).

## [1.11.0] - 2026-05-16

### Added
- **Site Knowledge module** (`site_knowledge`, group `indexing`) — exports a comprehensive structured snapshot of the entire WordPress site as `knowledge.json`, designed to drop into Claude Code or any other AI tool as full site context. Goes beyond plain-text `llms-full.txt` by capturing structure: site identity (name, URL, locale, permalink scheme, front-page setup, active theme + parent), full navigation (every registered theme nav location + every menu as a nested tree with type/object/object_id/classes/target per item), per-post-type blocks (slug/label/public/hierarchical/has_archive/rest_base/supports/taxonomies + items[] with id/title/slug/status/URL/parent/menu_order/dates/author/excerpt/SEO meta/taxonomy assignments — hierarchical CPTs nest children[]), per-taxonomy blocks (terms with id/name/slug/description/count/URL — hierarchical taxes nest children[]). **SEO meta auto-detected** from Yoast SEO (`_yoast_wpseo_title`/`_metadesc`/`_canonical`/`_opengraph-image`/`_schema_page_type`), Rank Math (`rank_math_title`/`_description`/`_canonical_url`/`_facebook_image`/`_rich_snippet`), SEOPress (`_seopress_titles_title`/`_titles_desc`/`_robots_canonical`/`_social_fb_img`), All in One SEO (`_aioseo_title`/`_description`/`_canonical_url`/`_og_image_custom_url`), or falls back to post_title + the_excerpt + permalink. Each post's `seo` block includes a `_source` field naming the plugin so AI consumers know provenance. **Two modes**: Normal (content-focused, default) and Developer (adds a `developer` block with server software/hostname/OS, PHP version/SAPI/memory_limit/etc, WP version+key constants, database server version/charset/collation/prefix, every installed theme, every installed plugin with active flag). **Customisation** via settings tab: mode radio, per-post-type include checkboxes (system post types like attachments/revisions/nav_menu_item/block templates always excluded), per-taxonomy include checkboxes, post-status filter (publish always restored if all unchecked), include full content toggle (default off — usually too large for AI context), include featured image URLs toggle, max items per post type (1–5000, default 500). **Download** via nonced `?bw_dev_knowledge_action=download` query — inline `admin_init` handler streams `Content-Type: application/json` + `Content-Disposition: attachment; filename=knowledge.json` (same pattern as Import/Export, avoids the admin-post.php pitfalls). Filename is literally `knowledge.json` per adi's vocabulary. Smoke-tested on the dev site: 29.9 KB payload covering 6 top-level blocks, Yoast auto-detected, hierarchical pages nest correctly, developer block enumerates 22 plugins + 3 themes.

## [1.10.0] - 2026-05-16

### Added
- **Import / Export module** (`import_export`, group `core`) — back up and move BW Dev configuration between sites. **Export:** downloads a JSON file with a `_meta` block (plugin version, exported_at ISO timestamp, source_site URL, format name + version) and a `settings` block containing the full `bw_dev_settings` option. File name: `bw-dev-settings-YYYY-MM-DD-HHMMSS.json`. Triggered by a nonced `?bw_dev_io_action=export` query param on the settings tab — handler sends `Content-Disposition: attachment` + JSON body and `exit`s. **Import:** file upload form on the same tab; accepts the same JSON format, validates the `_meta.format` marker, 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; rejects empty/oversized/non-JSON uploads with a clear error message. **Why inline rather than admin-post.php:** the 1.8.x purge-button experiments showed admin-post.php routing has gotchas on HTTPS sites where `wp_validate_auth_cookie()` returns false for `wordpress_logged_in_*`-only sessions. Settings-tab inline handling on `admin_init` avoids that entirely — already an authenticated admin context. Branding block re-sanitised with the same rules as `BW_Dev_Settings::sanitize()`'s Branding tab; module-enable map sanitised via bool cast + known-module filter. Flash messages on redirect via `?bw_dev_io_status=imported|error&bw_dev_io_message=...`.

## [1.9.2] - 2026-05-16

### Added
- **Dashboard — auto-detection of plugin-added widgets.** New "Plugin-added widgets" section on the Dashboard settings tab. Lists every dashboard widget registered by a third-party plugin (Yoast, Gravity Forms, Wordfence, Elementor, etc.) with a checkbox to hide it. **How it works:** on each Dashboard load, `configure_dashboard()` (hooked at `wp_dashboard_setup` priority 99) snapshots the current `$wp_meta_boxes['dashboard']` BEFORE removing anything and writes it to a `bw_dev_dashboard_widget_cache` option (per widget: `[ widget_id => [ title, context ] ]`, plus an `updated_at` timestamp). The settings tab renders that cache as a CSS-grid checkbox list (auto-fill, minmax 260px) showing the widget title + `<code>widget_id · context</code>`. Ticking a widget adds its ID to `bw_dev_settings.dashboard.remove_widgets`; on the next dashboard load `remove_meta_box($id, 'dashboard', $context)` runs. **WP core widgets are excluded from this list** (handled by the existing top section), as is the `dashboard_recent_comments` widget (delegated to `disable_comments`) and our own `bw_dev_welcome` widget. **First-visit UX:** if the cache is empty, the section shows a helpful "Visit the Dashboard once after activating new plugins, then come back here" hint with a direct link to wp-admin/index.php. Sanitize: widget IDs run through `preg_replace('/[^a-zA-Z0-9_\-]/')` + 100-char cap; `array_unique` dedups. Cache option is written with `autoload=false` and only touches the DB when the widget set actually changed (timestamp-only updates skip the write). `uninstall.php` drops the cache option.

## [1.9.1] - 2026-05-16

### Added
- **Server Info — Themes + Plugins sections.** Two new tables added to the Server Info tab between "WordPress" and "Database". **Themes** lists every installed theme via `wp_get_themes()`, sorted active → active-parent → installed, with name + version (e.g. `[active] kadence-child → Kadence-Child v1.0.0`). For child themes, the parent theme is flagged as "active (parent)". **Plugins** lists every installed plugin via `get_plugins()`, sorted network-active → active → inactive then alphabetical by name within each group, with name + version (e.g. `[active] bw-dev/bw-dev.php → BW Dev v1.9.0`). Multisite `active_sitewide_plugins` are detected and labelled "network-active". Both sections are picked up automatically by the "Copy to clipboard" plaintext summary.

## [1.9.0] - 2026-05-16

### Added
- **Server Info module** (`server_info`, group `core`) — read-only environment snapshot for developers and support tickets. Six grouped tables (widefat striped): **Server** (server software, hostname, server name, OS via `php_uname`, current site-tz time), **PHP** (version, SAPI, memory limit, current/peak memory via `memory_get_usage(true)`, max execution time, max input vars, upload + post max, `display_errors`, OPcache enabled+usage+cached-script count, default timezone), **WordPress** (version, site/home URL, locale, charset, permalink structure, multisite, active theme name+version, active plugin count, BW Dev version, key constants — `WP_DEBUG`, `WP_DEBUG_LOG`, `WP_DEBUG_DISPLAY`, `WP_MEMORY_LIMIT`, `WP_MAX_MEMORY_LIMIT`, `DISALLOW_FILE_EDIT`, `AUTOMATIC_UPDATER_DISABLED`, `DISABLE_WP_CRON`), **Database** (host, name, server version via `$wpdb->db_version()`, charset, collation, table prefix), **Filesystem** (ABSPATH, wp-content + uploads paths, writability flags, disk free + total via `disk_free_space`/`disk_total_space` on the ABSPATH mount, sizes formatted via `size_format`), **Object cache + cron** (external cache flag via `wp_using_ext_object_cache`, persistent cache class name via `get_class($wp_object_cache)`, WP-Cron vs system-cron status via `DISABLE_WP_CRON`). A "Copy to clipboard" button at the bottom snapshots all sections into a plain-text block (one `key: value` line per row, grouped by `## section` headings + timestamped header) for pasting into a support ticket or Slack thread — uses `navigator.clipboard.writeText` with a `document.execCommand('copy')` fallback. `current_user_can('manage_options')` guard inside `render_tab()` as belt-and-suspenders on top of the Settings → BW Dev page's own capability gate. No settings, no hooks — purely a settings-tab display.

## [1.8.4] - 2026-05-16

### Removed
- **Disable Comments — purge-all feature removed.** Reverted the bottom-of-tab "Purge all comments" section (added in 1.8.1, with subsequent admin-post.php routing fixes in 1.8.2 and 1.8.3). Adi asked to drop it: the feature was causing user-facing confusion and the admin-post.php integration path kept surfacing edge-case bugs. The tab now contains only the green "Comments are disabled site-wide" status callout (with the Modules-tab link) and the "What this strips" reference list. Anyone needing to clear historical comments can drop to WP-CLI (`wp comment delete $(wp comment list --format=ids) --force`) or a DB tool. All purge-related code is gone: `PURGE_ACTION` + `PURGE_NONCE` constants, `admin_post_*` + `admin_post_nopriv_*` hook registrations, `handle_purge()` method, and the `existing_comment_count()` private helper.

## [1.8.3] - 2026-05-16

### Fixed
- **Purge buttons still 400'd after the 1.8.2 fix — admin-post.php was routing through the nopriv branch.** WordPress's `admin-post.php` calls `wp_validate_auth_cookie()` with no arguments, which picks a scheme via `is_ssl()` (selects `secure_auth` on HTTPS sites). On this dev site the user's authenticated session has only the `wordpress_logged_in_*` cookie — no `wordpress_sec_*` — so `wp_validate_auth_cookie()` returned false even for a real admin user, routing the request to `admin_post_nopriv_<action>` for which no handler was registered → `wp_die( '', 400 )`. Fix: register the purge handler on BOTH `admin_post_<action>` and `admin_post_nopriv_<action>`. The handler's own `current_user_can( 'manage_options' )` + nonce checks keep it safe — a genuine anonymous request now gets a clear 403 instead of an opaque 400. Applied to both `disable_comments` and `login_log`.
- **Malformed onclick on the Disable Comments purge button.** `wp_json_encode($confirm_msg)` produced a double-quoted JSON string that collided with the surrounding `onclick="..."` attribute, breaking the HTML at the embedded quote. The JS-confirm dialog never fired (the button submitted unconditionally) and the attribute parser treated the rest of the confirm message as garbage attributes. Wrapped the `return confirm(...);` expression in `esc_attr()` so the inner `"` characters are encoded as `&quot;` and the attribute remains well-formed.

## [1.8.2] - 2026-05-16

### Fixed
- **Purge buttons in Disable Comments and Login Activity Log were submitting to `options.php` instead of `admin-post.php`.** Both module settings tabs render inside the outer Settings API form (`<form action="options.php">`). HTML doesn't allow nested forms — the browser silently dropped the inner `<form action="admin-post.php">` wrappers, so clicking either purge button submitted the outer form to options.php (which then tried to save it via the Settings API and returned to the settings page with no purge performed). Replaced both nested-form patterns with HTML5 `<button formaction="..." formmethod="post" formnovalidate>` on the submit element — same outer form, but those specific buttons override the submit target. Custom per-module nonce field names (`_bw_dev_disable_comments_purge_nonce`, `_bw_dev_login_log_purge_nonce`) so they don't collide with the outer settings form's `_wpnonce` field; `check_admin_referer()` is passed the custom nonce-arg name accordingly. Action is set via the URL query string on `formaction` so admin-post.php's `$_REQUEST['action']` lookup routes correctly.

## [1.8.1] - 2026-05-16

### Changed
- **Disable Comments settings tab — clearer "how to turn this off" callout.** Replaced the small note with a prominent green-bordered status box at the top of the tab: dashicons-yes-alt + bold "Comments are disabled site-wide" + a direct link to the Modules tab with explicit text "To turn this feature OFF, go to the Modules tab and untick the 'Disable Comments' checkbox under 'Editor & Admin'." Restates that there are no per-feature toggles inside this tab — the module enable/disable switch is the single control.

### Added
- **Disable Comments — purge-all action.** New "Purge all comments" section at the bottom of the Disable Comments settings tab. Shows the current DB comment count, a red-bordered danger callout explaining the operation is irreversible, and a destructive `button-primary`-styled action with `dashicons-trash`. Submits to `admin_post_bw_dev_disable_comments_purge` (nonce + `current_user_can('manage_options')` check). Handler runs `DELETE FROM {commentmeta}`, `DELETE FROM {comments}`, `UPDATE {posts} SET comment_count = 0 WHERE comment_count > 0`, drops the `counts` cache group entries (`comments-0`, `wp_count_comments`), then redirects back with `?purged=N` for a success toast via WP's `notice-success`. JS `confirm()` dialog before submit. Empty-state callout when there are no comments in the DB.

## [1.8.0] - 2026-05-16

### Added
- **Sidebars module** (`sidebars`, group `frontend`) — manage widget areas without editing the theme. Two features bundled. (1) **Register custom sidebars:** repeater UI (slug + name + description per row, add/remove rows via inline vanilla JS) that calls `register_sidebar()` on `widgets_init` priority 11. New sidebars appear in Appearance → Widgets immediately. Default markup matches WP's recommended HTML5 (`<section>` wrapper, `<h2>` title). Slug sanitised via `sanitize_title`; empty rows and duplicate slugs are silently skipped. (2) **Unregister existing sidebars:** checkbox list of every currently-registered sidebar (theme + plugin + ours) rendered from `$GLOBALS['wp_registered_sidebars']` at tab-render time. Our own customs are filtered out of the unregister list to prevent self-foot-shooting. Calls `unregister_sidebar()` on `widgets_init` priority 99 (after every registration, including our own). Widgets attached to an unregistered sidebar move to "Inactive widgets" in Appearance → Widgets — not deleted, so unticking the box restores them. (3) **Output shortcode:** `[bw_dev_sidebar slug="..."]` registered on `init`. Wraps `dynamic_sidebar()` in `ob_start` and returns empty when the sidebar isn't registered or has no active widgets — safe to drop into Kadence header/footer builder, template parts, or any block before the widgets are actually added. Replaces the recurring "I need another widget area" feature-request that previously needed a child-theme `functions.php` edit.

## [1.7.1] - 2026-05-16

### Added
- **Scheduled actions index** in the `scheduled_actions` settings tab. Mirrors the Admin Note module's "Notes index" pattern: direct `$wpdb` query against the `_bw_dev_psa_timestamp` post meta (ordered by timestamp ascending — soonest first), one row per post with a current scheduled action. Columns: Title (with edit + view row actions), Post Type, current Post Status (colour-coded), Scheduled (site-tz datetime + a coloured delta pill — `due now` / `overdue Nh` in red, `in Nm` / `in Nh` / `in Nd` in grey), Action (human-readable summary — `Change status → Draft`, `Add term(s) → Tag: news, featured`, `Remove term(s) → Event Status: new`). Empty-state callout when no schedules are set. No new schema or hooks — read-only view of existing post meta.

## [1.7.0] - 2026-05-16

### Added
- **Scheduled Post Actions module** (`scheduled_actions`, group `editor_admin`) — per-post one-shot scheduled trigger that fires at a chosen datetime to perform one of three actions: change post status (publish / draft / private / pending / trash), append term(s) to a taxonomy via `wp_set_object_terms($id, $terms, $tax, true)`, or remove term(s) from a taxonomy via `wp_remove_object_terms`. Works with any public post type and any taxonomy registered for it — custom post types + custom taxonomies fully supported. Replaces the standalone "Post Expirator" / "PublishPress Future" plugins for the typical Bowden Works workflow. **Storage**: underscore-prefixed post meta (`_bw_dev_psa_timestamp` in UTC unix, `_bw_dev_psa_action`, `_bw_dev_psa_status`, `_bw_dev_psa_taxonomy`, `_bw_dev_psa_terms` CSV); cleared after a successful run so it can't re-fire. **Cron model**: a single recurring `bw_dev_psa_run` hook (hourly default; override via `bw_dev_psa_interval` filter) that queries posts where `_bw_dev_psa_timestamp <= now()` in batches of 100 ordered by timestamp ascending. More reliable than per-post `wp_schedule_single_event` on low-traffic sites where WP-Cron only fires on page loads. Hour-level precision is the trade-off — a 9:30 target fires at the top of the next hour. **Meta box**: sidebar on the post editor, conditional fields (status select for change_status; taxonomy + multi-select term picker for add_terms/remove_terms) driven by inline vanilla JS. Datetime input uses HTML5 `datetime-local`; saved values are converted to UTC via `get_gmt_from_date()` and displayed in site timezone via `wp_date()`. **Settings tab**: checkbox list of public post types to enable (default `post` + `page`), cron status display (next-run timestamp), how-it-works documentation, and three use-case recipes (content expiry, "new" tag drop, event "past" tag). **Save handler**: nonce + `edit_post` capability check; clears all meta when the toggle is off or required sub-fields are empty so we never store a half-baked schedule. **Migration + uninstall**: `BW_Dev_Migration::on_activation()` schedules the recurring event idempotently (mirrors the login_log_prune pattern); `register()` also self-schedules on `init` so sites picking the module up via auto-update don't have to reactivate. `uninstall.php` drops all five `_bw_dev_psa_*` meta keys and clears the scheduled hook.

## [1.6.0] - 2026-05-16

### Added
- **Maintenance Mode module** (`maintenance_mode`, group `frontend`) — replaces Kadence Pro's threadbare maintenance page and the SeedProd-style "Coming Soon" plugin niche. Three modes: `coming_soon` (HTTP 200, pre-launch splash), `under_construction` (HTTP 200, partial work in progress), `maintenance` (HTTP 503 + `Retry-After` header so search engines and uptime monitors retry instead of de-indexing). Hook: `template_redirect` priority 1 — sends status, emits a self-contained HTML page (no theme load, works even with a broken theme) with `<meta name="robots" content="noindex,nofollow">`. Settings: heading + message (defaults per-mode), three colours (background / body text / heading accent, hex-validated), logo image (media picker), Retry-After hours (1–168 for maintenance mode). Bypass logic: admins (`manage_options`), wp-admin / wp-login.php / cron / AJAX contexts, configurable extra roles, plus a shareable `?bw_preview=TOKEN` URL that sets a session cookie. Token auto-generated (`wp_generate_password( 32, false, false )`) on first save when mode goes active; can be rotated by clearing the field and saving again. `hash_equals` compares cookie/query values to the stored token. `sanitize_hex` applies the same hex-regex contract used by the Login Page Branding module.

## [1.5.0] - 2026-05-16

### Added
- **Dashboard module** (`dashboard`, group `editor_admin`) — two related features for the wp-admin Dashboard screen. **(1) Remove default widgets:** checkbox per widget — `dashboard_primary` (WP News), `dashboard_quick_press` (Quick Draft), `dashboard_right_now` (At a Glance), `dashboard_activity` (Activity), `dashboard_site_health` (Site Health), and the dismissible "Welcome to WordPress" panel (`welcome_panel` action). **(2) Custom welcome widget:** optional widget pinned to the top of the Dashboard with a user-set title (default: "Welcome to <site name>"), wp_editor-edited rich-text body (`wp_kses_post`-sanitised on save and on render), and per-role visibility filter (empty = show to all roles). Hook: `wp_dashboard_setup` priority 99 for both removal and addition; `admin_init` removes `wp_welcome_panel` when configured. Pin-to-top via `$wp_meta_boxes` reorder — per-user drag preferences still win on subsequent loads (saved via `meta-box-order_dashboard` user meta). Recent Comments widget removal is intentionally delegated to the Disable Comments module rather than duplicated here.

## [1.4.0] - 2026-05-16

### Added
- **Admin Menu by Role module** (`admin_menu_role`, group `editor_admin`) — per-role checklist for hiding top-level wp-admin sidebar items. Distinct from the existing front-end `menu_visibility` module (that one hides nav-menu items; this one hides wp-admin chrome). Hook: `admin_menu` priority 999 → enumerates current user's roles, unions per-role hide lists, calls `remove_menu_page()` for each slug. Multi-role users: union of all roles' hide lists (most restrictive view). Sanitize: per-role `hide` arrays of slug strings, control chars stripped, slugs capped at 200 chars, duplicates removed via `array_unique`. Settings tab enumerates the global `$menu` (the settings page is rendered by an admin, who sees the canonical full list), then renders one fieldset per registered role with a CSS-grid checkbox list of menu items. Recovery hatch: `BW_DEV_ADMIN_MENU_ROLE_DISABLE` constant in `wp-config.php` bypasses the module entirely if an admin hides Settings and locks themselves out. Top-level menus only — submenu hiding is intentionally deferred.

## [1.3.0] - 2026-05-16

### Added
- **Login Page Branding module** (`login_branding`, group `core`) — pairs with the existing Core → Branding white-label layer. Customises `wp-login.php`: replaceable logo image (media-picker UI with live preview), configurable logo container size (width 32–600px, height 32–400px, scaled with `background-size: contain`), logo link URL + title attribute (defaults to `home_url()` + site name via `login_headerurl` / `login_headertext` filters), page background colour, form-panel background colour, primary-button background + text colours (hex validated against `/^#[0-9a-fA-F]{3,8}$/`), toggles to hide the "Back to <site>" link (`#backtoblog`) and the "Lost your password?" / "Register" navigation (`#nav`). Built CSS is injected via `login_enqueue_scripts`. Conditional `wp.media` enqueue on the admin page mirrors the favicon module. All fields optional — empty values fall through to WordPress defaults, so enabling the module with no configuration is a no-op.

## [1.2.0] - 2026-05-16

### Added
- **Login Redirects module** (`login_redirect`, group `security`) — per-role landing-page redirect after a successful login. Hooks `login_redirect` (priority 20, 3 args). Settings tab lists every registered role (admin / editor / author / contributor / subscriber + any custom roles like WooCommerce's `customer` and `shop_manager`) with a text input for the post-login URL. URL resolution: empty string keeps WP's default; values starting with `/` resolve against `home_url()`; absolute `http(s)://` URLs pass through (external hosts are blocked downstream by `wp_safe_redirect()` via `allowed_redirect_hosts`). Multi-role users: walks `$user->roles` in stored order, first role with a configured target wins. Sanitize strips control chars and caps URL length at 500. Quick-templates list in the settings tab (site home / dashboard / posts list / pages list / profile / media library / WooCommerce my-account).

## [1.1.0] - 2026-05-16

### Added
- **Disable Comments module** (`disable_comments`, group `editor_admin`) — replaces the standalone "Disable Comments" plugin for the typical Bowden Works build where comments are unused and a perennial spam target. Strips comment + trackback post-type support from every CPT; forces `comments_open` / `pings_open` / `get_comments_number` / `comments_array` closed / zero / empty everywhere; forces WP core's `default_comment_status` and `default_ping_status` to `closed` via `pre_option_*` filters so new posts ship comments-off at the DB-column level; hard-fails any submission that slips through via `pre_comment_on_post` → `wp_die` 403; hides the Comments admin menu, admin bar node, and Recent Comments dashboard widget; redirects `edit-comments.php` and `options-discussion.php` to the dashboard so the screens aren't reachable by direct URL; empties `comments_notify_recipients` and `comment_moderation_recipients` and forces `notify_post_author` false so no notification or moderation emails are ever generated; dequeues `comment-reply.js`, unregisters the Recent Comments core widget, and drops the comments-feed link from the front-end. No persisted settings — enabling the module on the Modules tab is the toggle. Settings tab is info-only and shows the count of historical comments in the DB (this module deliberately does not delete existing comment data).

## [1.0.1] - 2026-05-14

### Fixed
- **ABSPATH-guard scanner warnings.** Moved `defined( 'ABSPATH' ) || exit;` to sit immediately after `<?php` (before the file-level docblock) in 13 files where a long docblock pushed the guard past `tools/test-plugin.sh`'s 20-line scan window. Files: `includes/class-bw-dev-brand.php` plus modules `admin_note`, `security_hardening`, `svg_upload`, `title_override`, `login_log`, `hide_login`, `separator`, `established_year`, `vendors`, `llms_txt`, `menu_visibility`, `youtube`. No functional change — the guard was always present, just past the scanner's window. Matches the literal convention in `docs/CLAUDE-STANDARDS.md` ("Every PHP file starts with: `<?php` then `defined( 'ABSPATH' ) || exit;`").

## [1.0.0] - 2026-05-14

### Added
- Phase 1 settings framework: `BW_Dev_Module_Interface`, `BW_Dev_Settings`, `BW_Dev_Admin_Page`, `BW_Dev_Plugin` singleton.
- Settings → BW Dev page with Modules, Branding, and per-enabled-module tabs.
- Single root option `bw_dev_settings` with dispatching sanitize callback that routes per-module data.
- `bw_dev_modules`, `bw_dev_module_enabled`, `bw_dev_module_registered`, `bw_dev_loaded` filters/actions.
- **Favicon module** (Phase 2): ported from `bw-favicon`. Injects a custom favicon at priority 9999 on `wp_head`, `admin_head`, and `login_head`. Media-picker UI with live preview. Sanitizes saved URL via `esc_url_raw` — rejects `javascript:` and other unsafe protocols (the original was missing this). **Bulletproofed (2026-05-14)**: when a URL is set, also `remove_action()`s WP core's own `wp_site_icon` from all three head actions so our favicon doesn't fight the Customizer Site Icon output (or anything Kadence routes through it). Module's settings-tab copy explains the "why this exists" (forcefully fix Kadence/theme favicon failures) and includes status callouts (Currently active / Feature inactive).
- **Sticky Elements module** (Phase 3): ported from `bw-sticky-settings`. Repeater UI for multiple sticky elements with per-element top offset, margin-bottom, push-up target, z-index, mobile-disable + breakpoint. Frontend `sticky.js` only loads when at least one element is enabled. Localized config name renamed `bwStickyConfig` → `bwDevStickyConfig`; CSS classes renamed `.bw-is-sticky` → `.bw-dev-is-sticky`, `.bw-is-pushed` → `.bw-dev-is-pushed`, `.bw-sticky-placeholder` → `.bw-dev-sticky-placeholder` (see KNOWN-ISSUES for migration impact). Sanitize clamps `mobile_breakpoint` to 320–1200, enforces `z_index` ≥ 1, and skips rows with empty selectors.
- **YouTube Block module** (Phase 5): ported from `bw-youtube-embed`. Dynamic Gutenberg block `bw-dev/youtube` that pulls a YouTube URL from an ACF field and renders a responsive 16:9 iframe (falls back to featured image when no video is set). NEW: each block instance can override the ACF field name via the sidebar InspectorControls panel — the global default in Settings → BW Dev → YouTube Block applies when the per-block field is empty. Shortcode `[bw_dev_youtube]` plus a backwards-compat `[bw_youtube]` alias that auto-suppresses when the legacy `bw-youtube-embed` plugin is active (no conflict during side-by-side migration). CSS classes renamed `.bw-youtube-block*` → `.bw-dev-youtube-block*` (see KNOWN-ISSUES). Video-ID regex tightened to `[A-Za-z0-9_-]+` (was `[^?&"'>\s]+`, which would accept HTML in malformed URLs and rely on downstream escaping).
- **Post Link Block module** (Phase 6): ported from `bw-pretty-post-link`. Two server-rendered blocks — `bw-dev/post-link-list` (parent, layout + thumbnail width controls) and `bw-dev/post-link-item` (child, internal post pick or external URL). Two layouts: Simple (bulleted text list) and Thumbnail (16:9 thumb + title row). REST endpoint renamed `bw-ppl/v1/post-types` → `bw-dev/v1/post-types`. CSS classes renamed `.bw-ppl*` → `.bw-dev-post-link*`. No persisted settings; module's settings tab renders inline docs.
- **Admin Columns module** (Phase 4): ported from `bw-admin-column`. Configures custom admin list-table columns per post type — featured image with click-to-change AJAX quick edit, taxonomy columns, and meta-key columns (scanned from `wp_postmeta`, optionally including private `_*` keys). Sortable meta columns via `pre_get_posts`. Settings tab uses a sub-tab nav per post type; the active sub-tab is selected via `&ptype=<slug>` query param. Hidden fields preserve other sub-tabs' data on save. AJAX action names re-prefixed (`bw_set_featured_image` → `bw_dev_set_featured_image`, `bw_scan_meta_keys` → `bw_dev_scan_meta_keys`); nonces re-prefixed; column IDs re-prefixed (`bw_*` → `bw_dev_*`); CSS classes re-prefixed (`bw-admin-column-*` → `bw-dev-admin-columns-*`). AJAX handlers verified to have nonce + capability checks (the original had them — confirmed during port, no fix required).
- **White-label branding** (Phase 7): `BW_Dev_Brand` resolver exposes three configurable keys — `plugin_display_name`, `block_category_label`, `block_title_prefix` — defaulted to `BW Dev`, `BW Blocks`, `BW `. The `bw_dev_brand` filter lets mu-plugins force a brand. Plugin name is rewritten in the WordPress plugins list via `all_plugins` filter; Settings submenu title uses the brand label. A new stable block category `bw-dev-blocks` is registered with the brand label and all `bw-dev/*` blocks are reassigned to it via a JS-side `blocks.registerBlockType` filter (default `BW ` title prefix is stripped before the configured prefix is applied, so titles never double up). Saved post content is unaffected by brand changes — category slug stays stable.
- **Activation migration** (Phase 8): `register_activation_hook` runs `BW_Dev_Migration::on_activation()` which reads legacy options (`bw_favicon_url`, `bw_sticky_settings`, `bw_youtube_embed_settings`, `bw_admin_column_settings`) and writes them into the unified `bw_dev_settings` schema. Conservative merge — only fills in module keys NOT already present in `bw_dev_settings`, so partial migration plus manual setting tweaks survive a re-activation. Tracked via `bw_dev_migration_version` so it never re-runs. Legacy options are NOT deleted; the user manually deactivates source plugins once each module is verified.
- **SVG Upload module** (extra request, not in the original 5-plugin combine): allows .svg and .svgz uploads with two safety gates — (1) a role-based capability check (default: administrators only; toggle to "anyone with upload_files capability"), and (2) sanitization on every upload via DOMDocument. The sanitizer strips `<script>`, `<foreignObject>`, `<iframe>`, `<embed>`, `<object>`, `<animate*>`, `<set>`, `<handler>`, `<a>` elements; every `on*` event attribute; `href`/`xlink:href` values starting with `javascript:` or `data:`; `style` attributes containing `expression()` or `javascript:`; and XML processing-instructions + external DTDs (XXE). 8 attack-payload tests pass; legitimate SVGs preserved. No Composer dependency — vanilla libxml.
- **Admin Note module** (added by request, ported from the standalone `bw-admin-note` deployed on regent + promobix): adds an internal-only note to posts and pages. Editors get a sidebar `PluginDocumentSettingPanel` for editing and a prominent yellow banner above the first block when a note is set. Per-post-type enable map (default: page). Notes index table in the settings tab lists every post/page with a non-empty note. Post-meta key `_bw_admin_note` is intentionally NOT re-prefixed so existing client sites keep their notes when migrating to bw-dev — same grandfathered-prefix exception as bw-ai-schema-pro. Migration carries `bw_admin_note_settings` → `bw_dev_settings.admin_note` and leaves the legacy option in place.
- **Flywheel Auto-Updates module** (added by request): fixes WordPress auto-updates on Flywheel-hosted sites. Flywheel's managed cron fires `wp_update_plugins` but never `wp_maybe_auto_update`, so plugins/themes flagged for auto-update never actually update. This module hooks `wp_update_plugins` (priority 20) and manually fires `wp_maybe_auto_update` when in a cron context, with a re-entrancy guard via `doing_action()`. No settings beyond the master on/off toggle. Default-on. Safe on non-Flywheel hosts — `wp_maybe_auto_update` is idempotent.
- **Menu Visibility module** (added by request): per-menu-item role/login visibility controls. Adds a "Visibility (BW Dev)" select on every menu item in `Appearance → Menus` with four modes — Everyone (default), Logged-in only, Logged-out only, and Specific roles (multi-select against `wp_roles()`). Filters `wp_get_nav_menu_items` on the front-end to drop items the current user shouldn't see; hidden parents cascade to children. Filtering is skipped in admin and REST contexts so the menu editor and block/site editor previews always show every item. Per-item meta key `_bw_dev_menu_visibility` (underscore prefix hides it from the custom-fields UI; default-state items store no meta). Group: `frontend`.
- **Settings page reorganized into a vertical sidebar** with grouped sections: Core (About, Modules, Branding, Flywheel Auto-Updates), Editor & Admin (SVG Upload, Admin Note, Sticky Elements, Admin Columns), Front-end (Favicon, Menu Visibility), and Blocks (YouTube Block, Post Link Block). Replaces the single-row horizontal `nav-tab-wrapper` so the page scales as more block modules are added. Existing `?tab=` bookmarks continue to resolve. New `BW_Dev_Module_Interface::group()` method returns one of `core` / `editor_admin` / `frontend` / `blocks`; modules with an unknown group fall back to Core. Submit button is suppressed on info-only sections (About, Post Link Block, Flywheel Auto-Updates, Menu Visibility) so the misleading "Settings saved" toast no longer appears for pages with nothing to save.
- **About section** as the new default landing tab at `Settings → BW Dev`. Explains what BW Dev is and why it should not be disabled, summarises Bowden Works (web dev / digital marketing agency in Courtenay BC, 18+ years), credits the team — Rian Bowden (rian@rian.ca) and Adi Pramono (info@adipramono.com) — and links to https://bowdenworks.com. Plugin description in `bw-dev.php` header rewritten to flag the plugin as "required for several site features" and point at the About tab so a client browsing the WordPress plugins list does not delete it. Author URI moved from `bowden.works` to `bowdenworks.com` (the agency's main marketing site; `plugins.bowden.works` remains the dist host).
- **New "Security" sidebar group** added between Front-end and Blocks. New `BW_Dev_Module_Interface::group()` value `'security'`.
- **Hide Login URL module** (`hide_login`, group `security`) — replaces the standalone "WPS Hide Login" plugin. Custom slug serves the WP login form; canonical `/wp-login.php` and `/wp-admin/` (for guests) return 404 by default or redirect to the homepage per setting. Allowlists `admin-ajax.php` and `admin-post.php` so plugins from the front-end keep working. Outgoing wp-login.php URLs in emails, logout redirects, and password-reset links are rewritten via `site_url`/`network_site_url`/`wp_redirect` filters. Default OFF (empty slug) — module can be enabled but the feature stays inert until a slug is configured. Recovery hatch: define `BW_DEV_HIDE_LOGIN_DISABLE` in wp-config.php to bypass the module entirely (documented in the settings tab). Slug validation: 3–50 chars, lowercase letters/digits/hyphens/underscores only.
- **Security Hardening module** (`security_hardening`, group `security`) — five independent toggles. (1) Block user enumeration: blocks `/?author=N` redirects for guests via `parse_request` and removes `/wp-json/wp/v2/users` from the REST endpoints map for unauthenticated requests via `rest_endpoints`. (2) Disable XML-RPC: `xmlrpc_enabled` filter, removes `X-Pingback` header, drops `rsd_link`/`wlwmanifest_link` from wp_head. (3) Disable file editing: defines `DISALLOW_FILE_EDIT` if not already defined (removes Plugin Editor and Theme Editor). (4) Strip WP version fingerprints: removes `<meta generator>`, empties `the_generator` filter, strips `?ver=` only when it matches the actual WP version (so plugin-supplied cache-busting is preserved). (5) Disable Application Passwords: returns false for `wp_is_application_passwords_available`. **All five default ON** — safe-baseline security posture for a typical Bowden Works deployment. Sites that need XML-RPC (legacy mobile clients) or Application Passwords (headless WP) can uncheck those toggles in the settings tab.
- **Login Activity Log module** (`login_log`, group `security`) — records every successful + failed login attempt to a dedicated table `{prefix}bw_dev_login_log` (id, ts, user_login, user_id, ip, user_agent, result; indexed by ts). Cloudflare-aware IP detection prefers `HTTP_CF_CONNECTING_IP` over `REMOTE_ADDR`. Storage discipline: daily wp-cron prune (`bw_dev_login_log_prune` hook) drops entries older than the configured retention (default 30 days, range 1–365), plus a hard row-count ceiling (default 10,000, range 100–1,000,000) that deletes oldest-first regardless of date. "Purge all" admin-post action with nonce + capability check. Settings tab shows the most recent 50 entries with success/failure highlighting. Privacy notice in the settings tab about IP logging.
- **Activation + uninstall**: `BW_Dev_Migration::on_activation()` extended to call `BW_Dev_Module_Login_Log::install_table()` (idempotent via `bw_dev_login_log_db_version` option) and `schedule_cron()` (idempotent via `wp_next_scheduled`). `uninstall.php` extended to drop the login log table, drop `_bw_dev_menu_visibility` post-meta, clear the prune cron, and delete the new options.
- **Subtitle Block module** (`subtitle`, group `blocks`) — server-rendered Gutenberg block `bw-dev/subtitle`. Eyebrow / kicker text with uppercase letter-spaced styling. Attributes: text, alignment (left/center/right), color (custom ColorPicker — default = inherit theme), URL with same-tab/new-tab toggle. Ported from the standalone `rg-subtitle` block in the Regent Grand Kadence-child theme; the original's hardcoded "gold/gold-light/white/navy" Regent palette was replaced with a free ColorPicker so the block fits any client site. Uses `Inter` → system-font fallback for typography.
- **Separator Block module** (`separator`, group `blocks`) — server-rendered Gutenberg block `bw-dev/separator`. Decorative divider: two horizontal lines flanking either a built-in Unicode glyph (13 to choose from: ✦ ✧ ◆ ◇ ★ ☆ • ◉ ❖ ✿ ❀ ⬥ ⬦) or a custom SVG uploaded via the Media Library. Attributes: align, symbolType (`predefined` | `svg`), symbol, svgId, svgUrl, color. Lines and symbol both use `currentColor`; SVG symbols are rendered via CSS `mask-image` with the URL in a CSS variable, so the picked color colors the SVG regardless of its internal fills (works for any uploaded SVG). Editor surfaces a Notice if the user picks SVG mode while the SVG Upload module is disabled (since SVG mode needs the Media Library to accept .svg uploads). Ported from `rg-separator`; the original's two-color (gold/white) dropdown is replaced by the same free ColorPicker.
- **Modules tab grouped by sidebar group**: the master enable/disable list at `Settings → BW Dev → Modules` now renders one `form-table` per group (Core / Editor & Admin / Front-end / Security / Indexing / Blocks) with the group label as a section heading, instead of one flat table. Same toggles, same field names — just visually bucketed so the list is easier to scan. Empty groups are skipped automatically.
- **New "Indexing" sidebar group** — 6th group in the sidebar between Security and Blocks, for crawler / AI-readiness features. New `BW_Dev_Module_Interface::group()` value `'indexing'`.
- **Robots.txt Manager module** (`robots_txt`, group `indexing`) — appends a custom-rules textarea to WordPress's virtual robots.txt via the `robots_txt` filter (priority 20 so we land below Yoast's sitemap line). Settings tab shows a live preview of /robots.txt that includes core defaults + every plugin's contribution + your custom rules. Detects and warns when a physical `robots.txt` exists at `ABSPATH` (in which case WP's filter is bypassed). Intentionally does NOT include an AI bot allow/block list — sites running BW Dev want to BE indexed by AI crawlers.
- **LLMs.txt Generator module** (`llms_txt`, group `indexing`) — serves `/llms.txt` per the [llmstxt.org](https://llmstxt.org) spec (markdown index of the site's content for LLM consumption) AND `/llms-full.txt` (same index plus full plain-text content of each entry inlined) — both on by default, no user action required. URL interception via `init` priority 1 (no rewrite rules registered). Per-post-type inclusion (defaults to posts + pages; CPTs opt-in), max items per type (default 50), full-mode per-post char cap (default 10,000, truncated at the nearest word boundary). Custom title (defaults to site name), summary (defaults to tagline), optional intro markdown. **SEO-plugin-aware**: pulls each post's hand-crafted SEO meta description (Yoast → Rank Math → SEOPress → AIOSEO post-meta keys) for the bullet description in both files, falling back to post excerpt then auto-trimmed body snippet. In `/llms-full.txt`, also surfaces the SEO meta title (when set and different from post title) on a `SEO Title:` line. Skips values containing unresolved `%%placeholder%%` templates. Both files regenerate on every request — content is always current with no manual rebuild step. Content served as `text/plain; charset=utf-8` with `Cache-Control: public, max-age=3600` and `X-Robots-Tag: noindex, follow`. The `/llms-full.txt` toggle remains so very-large content libraries can disable it if regeneration cost ever becomes visible. Filterable for advanced overrides via `bw_dev_llms_post_types`, `bw_dev_llms_txt_content`, `bw_dev_llms_full_txt_content`. No caching layer in v1 — add a transient if needed later.
- **Per-group default-enabled state** in `BW_Dev_Settings::is_module_enabled()`. New `GROUP_DEFAULTS` table: Core / Editor & Admin / Front-end / Security / Indexing default ON; **Blocks default OFF** (each block adds an editor inserter entry the site might not want, so they're now opt-in). Resolution is a two-step lookup: if the slug is in the saved `modules` map, that explicit user choice wins; otherwise fall back to the per-group default. Once the user saves the Modules tab, defaults no longer apply to that slug. New filter `bw_dev_module_default_enabled` (`$default, $slug, $group`) lets mu-plugins override per-site. Replaces the previous "everything defaults true" behavior — but only affects fresh installs and any module slug not yet in the saved map; existing sites with explicit choices saved are unchanged.
- **New "Vendors" sidebar group** (7th, after Blocks). New `BW_Dev_Module_Interface::group()` value `'vendors'`. GROUP_DEFAULTS for vendors → `true` (group defaults ON).
- **Theme module** (`theme`, group `vendors`) — companion to the Plugins module under the Vendors group. Lists every installed theme via `wp_get_themes()` with screenshot, name, version, slug, and author. Classifies each as Active / Active parent / Recommended (Kadence parent or Kadence-based child) / Other. Top banner reports setup-vs-ideal status (ideal = exactly two themes: Kadence parent + one Kadence-based child active). Per-row Delete button for non-in-use themes via nonce-gated `themes.php?action=delete&stylesheet=...` URL with JS confirm dialog. Capability gate on `delete_themes`. WordPress refuses deletes on the active theme + its active parent regardless, so those buttons are hidden as a courtesy.
- **Vendors → Plugins** label rename — module slug stays `vendors` (no migration needed; saved state unaffected) but the user-visible label is now "Plugins" so the sidebar reads `Vendors > Plugins` + `Vendors > Theme` instead of `Vendors > Vendors`.
- **Vendors module** (`vendors`, group `vendors`) — surfaces install/activate status for the third-party plugins Bowden Works typically pairs with BW Dev. Two tiers: **Recommended** (Kadence Blocks, Kadence Blocks Pro [paid], ACF PRO [paid], WebP Express, Yoast SEO, Redirection) and **Optional** (Gravity Forms [paid], Better Search Replace, Customizer Export/Import, Post Types Order). Each row shows a colored status badge (Active / Inactive / Not Installed) and an action button. Free plugins use WP's Plugin Information Thickbox modal for one-click install; **paid plugins** (`paid: true` + `vendor_url`) get a "PAID" badge next to the name and a "Get plugin →" external link to the vendor site instead of a Thickbox install (since they aren't on wordpress.org). The Activate flow works identically for both — once a paid plugin's files are on disk via the vendor's installer/zip, our nonce-gated `plugins.php?action=activate` link activates it. Plugin name links to vendor URL for paid, wordpress.org listing for free. Detection via `is_plugin_active()` + `file_exists( WP_PLUGIN_DIR . '/<file>' )`. `add_thickbox()` is enqueued only on the BW Dev settings page (gated by `settings_page_bw-dev` hook suffix). No persisted state — pure status display. Replaces the originally-considered single-purpose "WebP module" with a generalized recommended-plugins surface. — ports the standalone `established-year` plugin (`/srv/apps/newcap/wp-content/plugins/established-year`). Stores a single establishment date and exposes `[bw_dev_year_number]` (numeric, e.g. "45") and `[bw_dev_year_word]` (English words, e.g. "forty-five") shortcodes for use in body copy. Backwards-compat aliases `[bw_year_number]` and `[bw_year_word]` register only when the legacy plugin is NOT already registering them (`shortcode_exists()` check) — no collision during side-by-side migration. Year count uses `DateTime::diff` for precision (a company founded 1980-12-31 doesn't tick to "45" until 2025-12-31, not 2025-01-01). Word converter handles 0-999. Static helpers `BW_Dev_Module_Established_Year::years_since()` + `number_to_words()` for use in PHP templates. Sanitization validates strict `YYYY-MM-DD` format + `checkdate()` for real-calendar dates. Activation migration: legacy `bw_established_date` option auto-imported into `bw_dev_settings.established_year.date`.
- **Title Override module** (`title_override`, group `editor_admin`) — adds a "Page Title Override" meta box to the post-edit screen for selected post types (default: `page` only; posts + CPTs opt-in via the settings tab). Limited HTML allowed in the override (`<em>`, `<strong>`, `<i>`, `<b>`, `<br>`, `<span class="…">`), sanitized via `wp_kses` on save. Theme-agnostic: hooks the standard `the_title` filter, gated to `is_singular() + in_the_loop() + is_main_query() + post_id matches get_queried_object_id()` so the override applies ONLY to the front-end H1 page heading — never the browser tab title, OG/Twitter/SEO meta tags, admin post list, nav menu item labels, or breadcrumb segments. Storage: `_bw_dev_title_override` post meta (underscore prefix hides it from custom-fields UI; empty values delete the meta to keep `wp_postmeta` tidy). `uninstall.php` extended to drop the meta key on plugin removal. Filterable: `bw_dev_title_override_post_types` for exposing private CPTs in the settings list.

### Fixed
- **SVG Upload: narrow `wp_check_filetype_and_ext` filter to SVG MIMEs.** The handler ran for every upload and an `unset($data, …)` early in the callback destroyed WordPress's MIME detection result, causing the filter to return an empty array for non-SVG files (which would block unrelated uploads on any site where this module is active). The handler now returns `$data` unchanged for non-SVG extensions and additionally requires the libmagic-detected MIME (`$real_mime`, the 5th filter arg) to look SVG-like (`image/svg+xml`, `image/svg`, `text/xml`, `application/xml`, `text/plain`, empty; `application/gzip` for `.svgz`) before re-labeling the upload as `image/svg+xml`. An `.svg`-named file whose real MIME is e.g. `application/pdf` is no longer relabeled — WordPress rejects it normally.

### Removed
- Throwaway `BW_Dev_Module_Demo` (Phase 1 lifecycle test article).

## [0.1.0] - 2026-05-11

### Added
- Initial scaffold.
