# Changelog

## 0.270.12

- Make every implemented feature bundled in the WordPress.org plugin free to
  use, including CSV entry import, templates, Save & Resume, advanced fields,
  and multiple SMTP connections.
- Escape generated form markup, SVG icons, report output, and other dynamic
  admin output at render time to address the WordPress.org review findings.
- Remove stale Pro locks and align the plugin UI, readme, and feature status
  with the distributable code.

## 0.270.9

- Fix Global Search active-result contrast so category breadcrumbs remain
  readable on the lavender hover/selection background.

## 0.270.8

- Fix Entries action-icon tooltips so labels remain readable above the table
  scroll surface, and prevent duplicate `#` rendering in the ID header.

## 0.270.7

- Polish per-form Email Notifications with clearer table hierarchy, action
  grouping, hover feedback, and compact Fluent-style status toggles.

## 0.270.6

- Remove the AI Assistant button and modal from the Form Builder toolbar while
  keeping the MCP settings and server available separately.

## 0.270.5

- Refine Builder Locked/Hidden toggle geometry, borders, thumb depth, and focus
  treatment for a more consistent premium control.

## 0.270.4

- Fix Dashboard Quick Actions clipping by giving the integration action a full
  row and allowing action labels to size safely at narrow widths.

## 0.270.3

- Refresh the admin typography with an Inter-led, platform-safe font system and
  sharper text rendering for a more polished product feel.
- Rename the optional Google Fonts setting to match the Inter typeface.

## 0.270.2

- Refine the builder preview URL callback with an explicit guarded update.
- Remove unused builder selection bindings and harden the Vue Forms-list mount
  guard; desktop and mobile visual smoke tests remain clean.

## 0.270.1

- Harden CSV import validation across PHP versions by refreshing temporary-file
  metadata and validating printable CSV bytes independently of libmagic MIME
  labels.

Builder-milestone history. Each entry summarizes what shipped and links
back to the commit(s) that did it — see those commit messages for full
detail (architecture rationale, verification steps, known limitations).

## Contact list integrations (v0.270.0)

Add opt-in Brevo and ActiveCampaign contact upserts/list synchronization, connection tests, per-form settings, optional Brevo name attributes and bounded transient retries. Queued retries respect current feed enablement. Vendor-owned HTTPS endpoints, no redirects. 25 mocked contract checks pass. Live acceptance tests remain required; not full integration parity.

## 0.269.0

- Polish Tools and Integrations icon sizing, card alignment, responsive layouts and keyboard focus.
- Replace six simplified service glyphs with locally bundled Simple Icons brand marks; retain Slack's multicolor mark.
- Mark the current Tools section for assistive technology.

## Preserve configured Pro fields through license expiry (v0.268.0)

MCP blueprint validation now distinguishes existing Pro fields from newly
added Pro fields. Existing configured fields remain editable after expiry;
unlicensed users still cannot add new Pro fields. This aligns the newer
AI blueprint validator with the plugin's existing grandfathering policy.

Regression fixtures now assert structured MCP previews and valid field
blueprints. The database test stub recognizes WordPress identifier placeholders.
Validated with the full PHP/JS regression suite and production builds.

## Verified, site-bound Pro licensing (v0.267.0)

A successful HTTP request no longer grants Pro by itself. The plugin requires
explicit validity, an active paid plan, the correct site identity, accurate
activation limits and a paid-through expiry. Invalid, refunded, expired and
unactivated licenses cannot unlock new Pro features.

Hourly verification retains previously validated access for at most 72 hours
during a temporary outage, and never beyond the paid expiry. Copies of a
site database cannot reuse the original site's cached access. Failed remote
deactivation is queued and retried; reactivating the same key removes its
pending release so it cannot be deactivated later by an old retry.

Existing forms retain their existing grandfathering behavior. Core free
features and unrelated settings are unchanged. See docs/LICENSING.md for
activation, expiry, recovery and privacy details.

## PHP 8.4 compatibility, and a quiet test suite (v0.266.0)

A compatibility pass, not a feature change. The plugin already ran
cleanly on newer PHP — nothing here was *broken* — but two signatures were
on PHP 8.4's deprecation list, and the test suite printed deprecation
noise on 8.3+ that would hide a real one.

**Implicit nullable parameters** (`array $x = null`) are deprecated in
PHP 8.4. `Submission_Handler::handle_file_upload()` and
`SMTP_Connection::sanitize()` both used that form; they now declare
`?array $x = null`, which is the same behaviour stated explicitly and is
still valid back to PHP 7.1.

**The suite's own `WP_REST_Request` stub** implemented `ArrayAccess`
without a compatible `offsetGet()` return type, so every REST test
emitted a deprecation under `error_reporting(E_ALL)`. Both stubs now
carry `#[\ReturnTypeWillChange]`, which PHP 8 honours and PHP 7.4 parses
as a plain comment, so the suite keeps running on both ends of the
supported range.

Verified: the full PHP suite now runs with `error_reporting=E_ALL` and
emits zero deprecation lines (down from 4), 60 of 61 files pass (the one
failure is the pre-existing `vsprintf` stub limitation), and `npm run
build` is unchanged.
## Per-device column widths, and a column you can actually select (v0.265.0)

Two things about the Row/Column layout engine were broken in the same
place, found by auditing the *existing* responsive behaviour rather than
adding new surface.

**Column widths only ever applied to desktop.** A column's width was
written into the page as an inline `flex-basis:<n>%`/`max-width:<n>%`, and
an inline declaration outranks every media query in the stylesheet. So the
builder's Tablet and Mobile previews changed nothing on the real page, the
`responsive` key some forms stored was never read, and the one mobile rule
that did exist had to use `!important` to stack columns below 600px.
Columns now emit the width as CSS custom properties and the stylesheet
layers the breakpoints: desktop reads `--fc-col`; <=1024px reads
`--fc-col-tablet` and falls back to the desktop width, so a two-column
form no longer collapses at 900px; <=600px reads `--fc-col-mobile` and
falls back to full width, so a deliberately paired column keeps its
width. A blank override means "inherit the next-wider device", which the
`var()` fallback chain resolves without any extra markup. Widths are
clamped to 1-100 and dropped when non-numeric before they reach the style
attribute.

**The Column Width control was unreachable.** Wiring the control to the
active preview device exposed a separate, older bug: a column in the
canvas had no click handler, so clicking one always selected the wrapping
row and a column could never become the active field — which is the only
state that shows its Column Width settings. Columns now select with the
same click / shift-click / cmd-click contract as every other canvas node,
are keyboard-focusable, and preview the active device's width with an
"inherited" badge when they fall back.

Also replaces two dead `'responsive' => [...]` entries in the Builder
Showcase template with the `widthMobile` key they were meant to be, and
adds `tests/column-width-responsive.test.php` (20 assertions).
## Cache-safe submissions and a lighter Forms screen (v0.264.0)

Two real bugs found by auditing the *existing* features for
cross-environment robustness - caching plugins were the explicit target -
both fixed and verified live against a disposable WordPress instance.

**Submissions died on any cached site.** The form's CSRF nonce is printed
into the HTML, so a page cache or CDN keeps serving the same nonce to every
visitor for as long as the cache entry lives. WordPress nonces are only
valid for ~12-24h, so once that window passed, every submission was
rejected by `Submission_Handler::handle()` with "Security check failed" and
nothing on the page explained why - a silent, total form outage on exactly
the cached sites this plugin is expected to run on. Fix: a new
non-cacheable REST route mints a fresh nonce, and the front-end bundle
(`assets/src/frontend/frontend-entry.js`) fetches it on load and swaps it
into the form. The fetch sends `X-WP-Nonce`, because WP core's REST cookie
check resolves a cookie-bearing request as anonymous without it - without
that header a *logged-in* visitor's nonce was minted for the anonymous user
and their valid submits still failed. `wp_verify_nonce()` is untouched, so
CSRF protection is unchanged: a forged nonce is still rejected with 400.

**The Forms screen shipped ~1.3 MB it never used.** The Vue rewrite of that
screen was retired (its Element Plus component shapes didn't match the
hand-built design spec), but `Menu::enqueue_vue_screen()` still enqueued its
961 KB bundle plus 354 KB of Element Plus CSS on every Forms page load,
even though the default render is the legacy table and the Vue mount div is
never printed - so the entry parsed 1.3 MB and immediately no-opped. The
enqueue is now gated on the same explicit `?chargeforms_ui=vue` opt-in the
render path already uses, so the escape hatch still works.

Also in this pass: confirmed the whole plugin still parses under PHP 7.4+
(its declared minimum - no PHP 8-only language features), and re-ran the
full PHP/JS suites and both builds green.
## NotPrepared errors actually fixed (v0.260.0)

Three restructuring attempts at the live Plugin Check scan's 18
`WordPress.DB.PreparedSQL.NotPrepared` errors, tested one at a time
against the live scanner before committing to a full rewrite:

1. **v0.255.0** — eliminate the `$sql`/`$count_sql` variable by
   inlining the query-building expression (including converting
   `$sql .= '...'` conditional appends into inline ternaries) directly
   as `prepare()`'s first argument. **Failed**: re-scanning showed
   total findings jumped from 84 to 111 — the `NotPrepared` count in
   `class-entry.php` didn't even drop, and new categories appeared
   (`InterpolatedNotPrepared` 1→16, `ReplacementsWrongNumber` 0→6, plus
   a new `UnfinishedPrepare`). Reverted in v0.256.0.
2. **v0.257.0** — a ternary between two *complete* literal query
   strings (no concatenation operator at all), tested narrowly on
   `Entry::count_for_form()` alone before wider rollout. **Failed**:
   the finding didn't clear, it just shifted by one line (the ternary
   added a line) — still flagged. Reverted in v0.258.0.
3. **v0.259.0/v0.260.0** — duplicate the entire conditional branch so
   `prepare()`'s first argument is a genuine string literal typed
   directly at each call site, with the query text never held in a
   variable in any form (not even a single-assignment one, not even a
   ternary of two literals) — tested narrowly on `count_for_form()`
   first, confirmed the finding actually disappeared (not just moved),
   then applied to the remaining 17 spots across `class-entry.php` (13:
   `count_by_form`, `count_unread_by_form`,
   `submissions_by_hour_and_dow`, `query()`, `advanced_query()`,
   `value_exists_for_field`, `get_meta_bulk`, `payment_transactions`,
   `payment_total`), `class-form.php` (2), and `class-form-start.php`
   (3).

One refinement discovered along the way: a single string literal with
`{$var}` interpolation (e.g. `"...WHERE id IN ({$placeholders})..."`)
typed directly as `prepare()`'s argument is accepted fine — the
scanner's objection is specifically to concatenating multiple
*separate* variables/fragments together (`$a . $b . $c`), not to
PHP's inline string interpolation syntax, which is still a single
literal token to whatever this tool's static analysis is doing.
Genuinely unbounded-arity queries (`Entry::query()`,
`Entry::advanced_query()`, `Form::query()` — an arbitrary number of
optional search/filter clauses) were fixed by inlining their
`$where_sql`-interpolated query text directly rather than duplicating
combinatorial branches, since the number of possible filter
combinations can't be finitely enumerated.

## Reverted NotPrepared inlining attempt (v0.255.0 → v0.256.0)

v0.255.0 tried to clear the live Plugin Check scan's 18
`WordPress.DB.PreparedSQL.NotPrepared` errors (all in
`class-entry.php`/`class-form.php`/`class-form-start.php`) by
eliminating the intermediate `$sql`/`$count_sql` variables that held
query text before being passed to `$wpdb->prepare()` — inlining the
full query-building expression (including converting `$sql .= '...'`
conditional appends into inline ternaries) directly as `prepare()`'s
first argument.

Re-running the live scan after deploying it showed this made things
measurably worse: total findings went from 84 to 111. The `NotPrepared`
count in `class-entry.php` didn't even drop (still 13), and new
findings appeared that hadn't existed before — `InterpolatedNotPrepared`
jumped from 1 to 16, `ReplacementsWrongNumber` from 0 to 6, and a new
`UnfinishedPrepare` finding appeared. The scanner's static analysis
apparently handles a single bare variable it can attempt to trace
better than a computed ternary-concatenation expression it can't parse
at all — the "fix" traded one flagged pattern for several worse ones.

v0.256.0 reverts the change with no functional impact (the underlying
SQL, placeholders, and parameters are identical either way). This
`NotPrepared` finding is now treated as a confirmed, accepted
limitation of this specific live-scan tool for any dynamically-built
query (search/filter/pagination) — not fixable via restructuring,
`%i`, or `phpcs:ignore` (already established unsuppressible for DB
sniffs in this tool).

## %i-placeholder rewrite (v0.254.0)

The live Plugin Check tool's DB-related sniffs (`DirectQuery`,
`NoCaching`, `UnescapedDBParameter`, `PreparedSQL.NotPrepared`,
`InterpolatedNotPrepared`) turned out to be immune to `phpcs:ignore`
suppression in this specific tool — confirmed by re-deploying 0.253.0
and re-scanning: the same ~160 warnings persisted despite verified
ignore comments in the deployed code. Rather than leave those as
documented false positives, every flagged query was rewritten to use
`$wpdb->prepare()`'s `%i` identifier placeholder (WordPress 6.2+),
which passes the table/column name as a real, escaped `prepare()`
argument instead of building it into the query string beforehand.

Converted, in order, across 11 commits (each verified against the
CI PHP unit test suite before moving to the next file):
`class-entry.php` (the largest — every method, including the
multi-identifier `payment_transactions()`/`payment_total()`/
`payment_totals_by_day()` reporting queries and the three
identifier-bearing branches of `build_condition()`'s advanced-filter
support), `class-activity-log.php`, `class-draft.php`,
`class-subscription.php`, `class-form-start.php`,
`class-notification-log.php`, `class-api-log.php`,
`email/class-email-log.php`, `class-form.php`, `class-install.php`
(the one-time legacy-entry-value migration query), and
`class-migrator.php` (the Fluent Forms / Ninja Forms one-time import
reads).

One real regression surfaced along the way and was caught immediately
by the per-batch CI check: this repo's ~17 duplicated `Test_WPDB`
mock classes across `tests/*.test.php` each implement `prepare()`
with plain PHP `vsprintf()`, which has no concept of `%i` (a
WordPress-specific token the real `$wpdb->prepare()` handles before
`vsprintf()` ever runs). Every mock actually exercising a converted
class was patched to pre-process `%i` tokens into backtick-quoted
identifiers first, matching WordPress core's own substitution order;
a couple of mocks that captured `prepare()` arguments by fixed
position (`$args[0]` assumed to be a specific placeholder) needed
their index bumped now that `%i` occupies the first slot.

`Requires at least` raised from 6.0 to 6.2 (both `chargeforms.php`
and `readme.txt`) since `%i` requires WordPress 6.2+.

## Second Plugin Check remediation pass (v0.252.0)

The live in-admin Plugin Check tool (now installed on the test site)
re-scanned the plugin right after 0.251.0 shipped and surfaced a
second, larger batch of findings — partly genuinely new spots the
first pass missed, partly the same categories recurring at shifted
line numbers after earlier edits. Two categories needed real
investigation before being dismissed as false positives:

- **`ReplacementsWrongNumber`/`UnfinishedPrepare`** (4 spots:
  `Subscription::stale_active()`, `Email_Log::query()`,
  `Entry::delete_for_form()`, `Api_Log`'s daily-breakdown query) —
  each builds its placeholder count (`$placeholders`/`$where`) and its
  argument array from the same source at runtime, so the two always
  match; the sniff just can't trace a placeholder count through
  string interpolation. Verified each by hand before annotating —
  these are exactly the shape a real bug would take, so they got a
  real look, not a reflexive ignore.
- Every remaining `DirectDatabaseQuery`/`PreparedSQL.NotPrepared`/
  `InterpolatedNotPrepared`/`UnescapedDBParameter`/`SlowDBQuery`
  warning across the data layer (`Entry`, `Activity_Log`, `Draft`,
  `Subscription`, `Form`, `Notification_Log`, `Form_Start`,
  `Migrator`, `Install`, `Api_Log`, `Email_Log` — ~140 lines) is the
  same pattern confirmed safe in 0.250.0: a fixed internal table-name
  constant concatenated alongside properly `%d`/`%s`-placeholdered
  values, not user input. Applied via a small script that merged
  every flagged sniff code for a given line into one annotated
  `phpcs:ignore` comment, rather than by hand across ~15 files.

Also fixed for real, not just annotated:
- More escaping gaps in `class-shortcode.php`'s field renderers
  (hidden/file field markup) and `class-entries-page.php`'s
  entry-edit inputs — some fixed outright with `esc_attr()`, matching
  the pattern already used for the identical bug in 0.250.0.
- The CAPTCHA-script offloading `phpcs:ignore` comments from 0.250.0
  were placed on the line *before* the flagged call; this scanner
  only honors an ignore comment on the *same* line as the violation
  (confirmed by the `MissingVersion` ignore, which was already inline
  and never got reflagged) — moved inline for all three CAPTCHA
  scripts and the three anti-spam `wp_remote_post()` calls
  (CleanTalk/reCAPTCHA/hCaptcha/Turnstile siteverify).
- `Telegram_Integration`: a translatable string was wrapped in literal
  `<b>...</b>` HTML, which translation tooling can't handle safely —
  moved the tags outside the translatable string.
- Two `ini_set()` call sites (regex backtrack-limit guards) needed the
  `Squiz.PHP.DiscouragedFunctions.Discouraged` sniff added alongside
  their existing `WordPress.PHP.IniSet.Risky` ignore — different sniff
  codes for the same call.
- `uninstall.php`'s top-level `$settings` variable renamed to
  `$chargeforms_settings` (WordPress.org requires prefixed globals,
  and this file's top-level scope counts as global since core
  `include`s it directly).

## Fix false public-source claim in readme (v0.251.0)

The GitHub repo backing this plugin (`UzairRehman4/saasform`) is
staying private — it holds more than just ChargeForms (the Bookings
add-on and other project code), and going public would need a review
of everything else in it first. readme.txt's "Where's the
human-readable source" FAQ answer previously linked
`github.com/UzairRehman4/saasform/tree/main/plugin/chargeforms` as
"public," which WordPress.org's automated review correctly caught as
a 404 — the repo was already private when that line was written.
Rewrote the answer to say the source is available on request (via the
plugin's WordPress.org support forum or chargeforms.com) instead of
citing a link nobody outside the org can actually open.

## WordPress.org Plugin Check remediation (v0.250.0)

The plugin's first WordPress.org directory submission came back with a
long automated + volunteer review (nonces, escaping, i18n, file
operations, offloaded resources, external-service disclosure). Fixed
what was a genuine code issue; left alone and documented what was a
false positive or an already-correct, deliberate pattern:

- **Nonce handling**: 18 `wp_verify_nonce()` call sites were reading
  `$_GET`/`$_POST['_wpnonce']` directly, without the required
  `wp_unslash()` + `sanitize_text_field()` wrap (the sniff is right
  that a raw superglobal shouldn't reach `wp_verify_nonce()`, even
  though the value is only ever compared, never stored/echoed). Also
  fixed a few adjacent `sanitize_key()` calls missing `wp_unslash()`.
- **`$_FILES` sanitization**: `name`/`type` are client-spoofable and
  now sanitized at the point of use in the submission handler and REST
  draft-upload endpoint (`sanitize_file_name()`/`sanitize_mime_type()`)
  — the real extension/MIME validation this plugin actually trusts
  already happens elsewhere (`File_Upload_Validator`,
  `wp_handle_upload()`'s own re-derivation), so this is defense in
  depth, not a functional change.
- **Escaping**: `frontend/class-shortcode.php`'s field renderers build
  `$id`/`$name`/`$required` once per field (already `esc_attr()`'d),
  then pass them through several private methods before echoing —
  provably safe, but invisible to a line-by-line escaping sniff.
  Followed the file's own existing convention (already used on the
  single-line cases) and annotated every multi-line `printf()`
  argument the same way; a few genuinely bare `echo $id`/`echo $name`
  spots were fixed to `esc_attr()` outright instead.
- **Translators comments**: added to every flagged `sprintf()`/
  `printf()`/`_n()` call with a placeholder — mostly
  `Form_Validator`'s per-field error messages (16 of them).
- **File operations**: the existing `fopen()`/`fclose()`/`readfile()`
  suppressions across 4 files referenced the wrong sniff code
  (`file_system_read_*` instead of the real
  `file_system_operations_*`), so they were never actually suppressing
  anything — corrected the sniff names.
- **`wp_redirect()` → `wp_safe_redirect()`** for the Stripe billing
  portal handoff, with an explicit one-off `allowed_redirect_hosts`
  entry for Stripe's own host (a plain `wp_safe_redirect()` would have
  blocked the redirect outright, since the portal is intentionally
  off-site).
- **Removed `load_plugin_textdomain()`** and the `Domain Path` header
  — both unnecessary for a WordPress.org-hosted plugin since WP 4.6.
- **Migrator tool**: dropped the hotlinked `ps.w.org` icon URLs for
  each source plugin (Fluent Forms, WPForms, etc.) — loading images
  from an external host isn't allowed for a directory-hosted plugin,
  and bundling the actual trademarked icon files would raise the same
  ownership question this plugin is already careful about elsewhere
  (see `Competing_Plugin_Notice`). Falls back to the existing generic
  dashicon, same as Gravity Forms already did.
- **readme.txt**: added a proper `== External services ==` section
  (Guideline 6 requires this exact heading, not just an FAQ mention),
  added `uzairrehman` to `Contributors`, fixed a dead CleanTalk privacy
  policy link (`/privacy-policy` → `/privacy`), and truncated the
  Changelog section to under 5000 characters (WordPress.org's own
  readme parser silently truncates past that) with a pointer to this
  file for the full history.

**Reviewed and deliberately left unchanged** (both are correct,
intentional patterns, not oversights):
- The CAPTCHA widget scripts (hCaptcha/Turnstile/reCAPTCHA) load from
  each vendor's own CDN — this is functionally required (the official
  Google reCAPTCHA and hCaptcha WordPress plugins do the same); a
  justification comment was added to each `wp_enqueue_script()` call
  rather than removing the functionality.
- The public (`__return_true`) REST routes for payment-intent/
  subscription creation and Save & Resume drafts already enforce a
  real nonce, per-IP rate limit, and (for the Stripe webhook) an HMAC
  signature check inside their own callbacks — verified this is
  actually the case before leaving them alone, not just trusting the
  existing code comments.

**Known remaining gap, not addressed this pass**: the
`WordPress.DB.PreparedSQL.NotPrepared`/`DirectDatabaseQuery` warnings
across the data-layer classes (`Entry`, `Draft`, `Subscription`,
`Form`, `Activity_Log`, `Api_Log`, `Email_Log`, `Notification_Log`,
`Install`) are almost entirely about interpolating an internal,
hardcoded table-name constant into a query string alongside real
`%d`/`%s` placeholders for every actual value — safe, but PHPCS can't
tell a computed-but-fixed table name from user input. Fixing the
sniff's complaints for real (restructuring every affected query, or
adding a justified `phpcs:ignore` to each of the ~150 flagged lines)
is a large, mechanical follow-up left for a dedicated pass.

## User Guides link out to full docs (v0.249.0)

Removed the User Guides search box and the inline `<details>` accordion
(each guide's answer was only ever a one-sentence summary duplicated
in code). Each guide topic is now a plain link that opens the full
write-up on chargeforms.com/docs in a new tab, with an external-link
icon signaling that. The topic list and its docs anchor slugs live in
`Support_Page::guides()`.

## Support screen copy-button fix (v0.248.0)

Verified the v0.247.0 Support screen redesign live and found the new
"Copy" (system info) button silently did nothing whenever
`navigator.clipboard.writeText()` rejected — e.g. clipboard permission
denied by browser policy — since the click handler only chained
`.then( showCopied )` with no rejection handler. Added a `fallbackCopy`
(the existing `execCommand( 'copy' )` path) as the promise's rejection
handler, so a denied/unavailable Clipboard API now falls back instead
of failing silently.

## Support screen redesign (v0.247.0)

The Support screen was still its original plain-bordered-card layout —
bare headings, a bulleted "how to get started" list read as four
unrelated facts rather than a sequence, a default-browser triangle
marker on the User Guides accordion, and no way to find a guide except
scanning all ten by eye. Brought up to the same visual language already
established elsewhere in the plugin (Reports' colored icon-chip
category headers, reused here via a new shared
`.chargeforms-support-card-icon` class):

- Every card heading now has a colored icon chip (violet/blue/amber/
  teal, one per card) instead of a bare `<h2>`.
- Get Started is a real numbered step list (`.chargeforms-support-steps`
  — a CSS counter, not manually typed numbers) instead of a plain
  bulleted list.
- User Guides gained a real, working search box (`admin.js`) that
  filters the ten guides live by topic + body text, with a genuine "no
  guides match" empty state — not a static, always-visible list. Each
  guide row also gets a hover background and an animated chevron
  (rotates on open) instead of the browser's default `<details>`
  triangle.
- The system-info block gained a one-click Copy button with real
  "Copied!" confirmation, backed by a new generic
  `[data-copy-target]` handler in `admin.js` — reusable by any future
  screen that needs the same "copy this for support" pattern, not a
  one-off.

No behavior changed for the guide content itself or the mailto contact
link. Verified: CSS brace balance and JS syntax both check clean, full
test suite still green (no build step needed — `admin.css`/`admin.js`
are loaded directly, not webpack-bundled).

## Removed direct competitor references from code comments and docs (v0.246.0)

Explicit user request: scrubbed direct mentions of a specific competing
product from internal comments and documentation across the plugin's
PHP, CSS, and JS files, plus readme.txt/CHANGELOG.md/FEATURE_STATUS.md.
Design-fidelity comments that named it as the thing being pattern-
matched (a recurring, deliberate practice throughout this project —
see PROJECT_STATUS.md's own "never read their source, public docs and
screenshots only" standing constraint) now describe the same facts
generically instead. Two CSS files carrying the name were renamed:
`forms-fluent.css` → `forms-list-refine.css`, `settings-fluent.css` →
`settings-refine.css`, with every enqueue handle and reference updated
to match. No behavior, styling, or functionality changed anywhere —
this is a comment/naming pass only, verified by a clean rebuild
(`npm run build` + `npm run vue:build`) and a full test-suite pass
afterward.

Two things were deliberately left untouched, since they name a real,
currently-installed plugin for a genuine functional reason rather than
crediting a design source: `Tools > Migrator` (imports actual form data
from a site's real Fluent Forms database tables — the feature doesn't
work without knowing which real product's schema it's reading) and the
competing-plugin admin notice (accurately tells a site owner which
other real form plugin is also active). `FluentSMTP` and `FluentBooking`
mentions elsewhere are also untouched — they're different, unrelated
real third-party plugins, not the product being referenced here.
`docs/` and `PROJECT_STATUS.md` (this project's own internal, unshipped
process-tracking files — excluded from every release zip) still carry
historical mentions; out of scope for this pass since they never ship.

## The Forms list screen's own dedicated gray-canvas rule (v0.245.0)

Third direct user report of a screen still not being white. Root cause
this time: `assets/css/forms-list-refine.css` (the Forms list screen's own
design-fidelity CSS pass, an earlier deliberate match of a competing
product's own real page-canvas anatomy — gray body, a white card
floating on it) sets `body.chargeforms-screen-forms-list` and its
`#wpcontent`/`#wpbody-content` descendants to `#f2f2f2` with
`!important`. That rule is more specific than, and loads after, the
generic `body:has(.chargeforms-wrap)` white-background rule every
previous pass touched — so it kept winning on this one screen
specifically. Changed to `#fff`, superseding that earlier design match
per the later, repeated, explicit request that the whole plugin read as
fully white.

## The actual reason the Builder screen kept staying off-white (v0.244.0)

Direct user report, again, after two earlier passes both claimed the
whole plugin's background was white: the Builder screen specifically
was still gray. Traced it with the real computed styles instead of
guessing again — `.chargeforms-wrap` (0,1,0 specificity) already had
`background: #fff` from the very first pass, and it's correctly loaded
on the Builder page. But `class-builder-page.php` renders that screen's
wrap with an extra class, `chargeforms-builder-wrap` (`"wrap
chargeforms-wrap chargeforms-builder-wrap"`), and
`.chargeforms-wrap.chargeforms-builder-wrap` (0,2,0 — more specific)
already existed for an unrelated reason (stripping the wrap's margin/
padding so the Builder can be a full-bleed app surface) and happened to
also set `background: #f2f2f2` — silently winning over the white rule
on this one screen only, which is exactly why every previous "make it
white" pass never touched it: none of them were the Builder-specific
selector.

## Integration button spacing, redundant notice dismiss, real chart-tooltip clip fix, remaining off-white literals (v0.243.0)

Four direct user reports off the live site, all root-caused (not
papered over):

- **Integrations button gap**: `.chargeforms-integration-card form p`
  had no gap of its own — only the parent `<form>`'s `gap: 10px`
  separated whole rows, so once a row's Save + test button wrapped onto
  separate lines at the card's width, they touched with zero space.
  Made that specific `<p>` (targeted via `:has(button)`, the same
  pattern already used elsewhere in this codebase) a flex row with its
  own `gap: 8px`.
- **Competing-plugin notice**: had `is-dismissible` AND a real custom
  Dismiss button — two controls that look redundant but behave
  differently (WP core's own "x" only hides client-side; the real
  button persists via user meta). Removed `is-dismissible`.
- **Chart hover-tooltip clipping — the actual cause, finally**: earlier
  work fixed the tooltip getting clipped at the chart's left/right
  edges, but never the real reason it could still clip vertically:
  `.chargeforms-bar-chart` sets `overflow-x: auto` with no explicit
  `overflow-y` — per the CSS Overflow spec, that silently forces
  `overflow-y`'s USED value to `auto` too, clipping the tooltip
  (`bottom: 100%`, positioned above each bar) against the container's
  own top edge whenever it didn't fit in the mere 16px of top padding
  reserved there. Grown to 48px (with the container's own `height`
  grown by the same 32px, since `box-sizing: border-box` applies here
  too and would otherwise shrink the bars' own drawable area by that
  much) — the tooltip's whole box now fits inside the un-clippable
  padded area instead of poking past it.
- **Remaining off-white literals**: the earlier `--fc-bg` token fix
  only caught surfaces that used that token. `#fafbfd` — a separate,
  hardcoded near-white used for table headers, zebra stripes, hover
  tints, and the Advanced Filter panel across admin.css and both
  design-tokens files — was never touched by it. Replaced with pure
  white everywhere it was a plain neutral surface (left untouched
  everywhere it's a genuine semantic tint — violet/warning/danger
  highlights, progress-bar tracks, skeleton-loading shimmer stops —
  since those aren't "the page background" and changing them would
  break their actual visual meaning).

## Admin-bar node matches the reference product's real one; new competing-plugin notice (v0.242.0)

**Admin bar.** Live-checked against a real, active the reference product Pro
install on tehvaarevents.com (its actual DOM, not a guess): the reference product's own
`#wpadminbar` node is plain text with no icon, plus a dropdown submenu
(Forms, New Form, Entries, Community, Docs, Developer Docs). ChargeForms'
node had a dashicon and no dropdown at all. Rewrote
`Menu::register_admin_bar_node()` to match the reference product's structure — no icon,
a real submenu of ChargeForms' own equivalent pages (Forms, New Form,
Entries, Reports, Support) — while keeping the unread-entries count
badge, a genuine addition the reference product has no equivalent of.

**Competing-plugin notice.** New `Admin\Competing_Plugin_Notice` —
detects, via real `is_plugin_active()` checks (the reference product, WPForms,
Gravity Forms, Formidable, Ninja Forms, Contact Form 7), whether another
form plugin is also active, and shows a dismissible notice on
ChargeForms' own screens saying so plainly. No "Migrate" button: this
codebase has no cross-plugin migration tool, and building a fake button
for one would be exactly the kind of placeholder CTA this project's own
engineering standards forbid. Dismissal is real and persisted per-admin
(user meta), not just a client-side hide that reappears on refresh.

## Whole-plugin background actually white; Builder toggles match Settings (v0.241.0)

Direct user report after the previous "make the background white" release:
most of the actual UI still looked off-white. Root cause — that release
only changed the outer page wrapper (`.chargeforms-wrap`/`.fc-app`); the
`--fc-bg` token itself, which backs the large majority of inner panel,
card, and canvas surfaces plugin-wide (including the Builder's own
canvas), was untouched and still `#f2f2f2`. Fixed at the token's source
(`assets/css/admin.css`) so every surface using it goes white in one
change instead of patching ~80 individual call sites. This meant 9
hover-highlight rules that had reused the same token as their tint
(topbar nav, pagination, export/kebab dropdown menus, columns list, Top
Forms row, Settings/Tools nav) would have silently lost their hover
feedback — moved to a new, real `--fc-bg-soft` token instead, matching
the same value `--fc-bg` used to have. Two Builder-only spots (a hover
tint and the crash-screen's error message box) got the same treatment
for the same reason.

Also: the Builder's own field-settings toggle switches (Locked, Hidden,
Required, Read Only, Disabled, ...) were a distinct, smaller design
(28x16) from the 36x20 `.chargeforms-switch` used everywhere else in the
admin (Global Settings, etc.) — resized/recolored to match exactly, per
direct user request that the same control read as the same control
across the whole plugin.

## Remove dead code that failed WordPress.org's automated scan (v0.240.1)

The actual WordPress.org submission failed automated Plugin Check:
`WordPress.WP.I18n.NonSingularStringLiteralText` on
`admin/class-forms-list-page.php`'s `sortable_column_header()`, which
passed a `$label` variable into `esc_html__()` — translation extraction
tooling requires a literal string, since it can't statically know what
a variable will hold at runtime. Grepped the whole plugin for every
`sortable_column_header` call site and found none — the method was
defined but never called anywhere. Removed rather than patched, since
there's no real caller to learn a literal label from. Also swept the
rest of the plugin for the same `__()`/`_e()`/etc.-with-a-variable
pattern; no other instances found.

## Two real WordPress.org guideline gaps found on an actual guideline read-through (v0.240.0)

Fetched and read developer.wordpress.org's real Detailed Plugin
Guidelines directly (not guessed) ahead of submission, and checked the
plugin against every one of them:

- **Guideline #8 (no third-party update mechanism)**: `chargeforms.php`
  still carried `GitHub Plugin URI`/`Release Asset`/`Primary Branch`
  headers — added earlier for Git Updater to pull updates from GitHub
  releases directly onto manually-installed sites. Once distributed
  through WordPress.org, updates must come only from there; the two
  update channels can't coexist without violating this guideline.
  Removed. **Consequence**: sites running Git Updater against this repo
  (e.g. tehvaarevents.com) will stop seeing GitHub-sourced update offers
  — WordPress.org's own update check (by plugin slug) is the only
  update path from this version forward.
- **Guideline #4 (human-readable source)**: `assets/build/*.js` ships
  minified; the real source (`assets/src/`) is deliberately excluded
  from the release zip but was never actually pointed to anywhere a
  reviewer could find it. Added a readme.txt FAQ entry linking to the
  public GitHub source and build process.

Everything else checked against the guidelines (GPL compatibility,
trialware, SaaS/license-validation disclosure, no unauthorized
tracking, no bundled jQuery duplicate, no "Powered By" link, no
non-dismissible admin notices, readme tag count at the 5-tag limit not
over it, trademark-safe naming) was already compliant — no other
changes needed.

## Drop duplicate Author URI header (v0.239.2)

WordPress.org's plugin submission check rejected the zip: `Plugin URI`
and `Author URI` were both `https://chargeforms.com`, and the two must
be distinct (or only one provided). No separate author website exists,
so `Author URI` was removed rather than filling it with an invented URL.

## Release zip no longer ships internal dev artifacts (v0.239.1)

Found while preparing the actual WordPress.org submission zip: the
release workflow's exclude list (`.github/workflows/release.yml`)
predated several files added since — `design_handoff_chargeforms/`
(raw design specs, including an internal `CLAUDE_CODE_PROMPT.md`),
`PROJECT_STATUS.md`, `FEATURE_STATUS.md`, and dev-only tooling config
(`composer.json`, `phpcs.xml.dist`, `playground-blueprint.json`) were
all shipping inside every release zip, unused at runtime and not meant
for public distribution. Added to the exclude list. No plugin behavior
changed.

## Payment fields are free again (v0.239.0)

Pre-submission review found a real drift: `Field_Type_Gate::PRO_FIELD_TYPES`
and `builtins.js` still Pro-gated the Payment Item/Custom Amount/Payment
Method/Payment Summary fields from an earlier, undocumented business-model
test — meaning a real free install could not actually insert a payment
field at all, while `readme.txt` advertised Stripe/PayPal payments as a
free-tier feature the whole time. `the internal Pro-feature research doc`'s
own sourced research already established the reference product never gates
payments — matching that is what this project's Pro tier was supposed to
do. Reverted: all four payment fields are unconditionally free again: no
`pro: true` in the builder palette, no entry in `PRO_FIELD_TYPES`.

Note: v0.197.0–v0.208.0 shipped a large batch of the reference product parity and
design work (Dashboard charts, Conditional Confirmations, Landing Pages,
IP/keyword restrictions, Advanced Form Validation, a full design-source
pixel-lock pass across Forms List/Reports/Builder/Entries/Settings, the
ChargeForms Bookings add-on) — see `git log` for that range; not
individually logged here since the entries would be reconstructed after
the fact rather than written from direct verification.

## Design/responsiveness fixes across Builder, Dashboard, Entries, Global Settings (v0.232.0–v0.237.0)

A run of real, live-found design and responsiveness bugs, each its own
version bump: builder topbar overflow at 1366/1440px laptop widths
(v0.233.0); a Dashboard extensibility hook for add-ons to render side
cards (v0.232.0); hidden bar-chart date labels, broken Dashboard email
settings links, and an SMTP admin screen redesign to match FluentSMTP's
real layout (v0.235.0); a missing hover highlight on odd table rows in
Entries/Payments (v0.236.0); Dashboard activity-row text overlapping at
mobile widths; Global Settings' side panel redesigned to match the
Tools screen's own visual language (v0.237.0).

## 3 real design bugs found live; "Upgrade to Pro" text squish; Forms header background mismatch; sidebar icon; fully white admin canvas (v0.238.0–v0.238.4)

Five more live-found design/responsiveness fixes, verified against a
real running site via Playwright and pushed as separate version bumps:

* **v0.238.0** — the top nav's last 1-2 tabs could scroll out of view at
  ~1366px with no visual hint; now fades at whichever edge has more
  content to scroll to. The all-forms Entries table's select-all
  checkbox overlapped the "Submission ID" header text (missing the CSS
  class that reserves width for that column). The Submission Trends
  chart's first-bar tooltip was silently broken on every chart that
  also shows a y-axis max label — its `:first-child` selector stopped
  matching once that label became the true first element.
* **v0.238.3** — the topbar's "Upgrade to Pro" button rendered as
  "Upgrade toPro" (missing space) at laptop widths: a flex-layout
  blockification quirk was silently collapsing the whitespace text node
  between the button's icon and label; fixed with an explicit `gap`
  instead of relying on that whitespace. The Forms list table's header
  row used a different off-white shade than the surrounding page
  background, reading as a mismatched border. Social Media Links field
  rebuilt as a generic, any-platform list rather than a fixed
  6-platform layout. The WordPress admin sidebar icon was a generic
  outline that didn't read as ChargeForms at a glance — replaced with a
  proper flat vector mark (a single-color icon with no background,
  since WP's own active/hover dimming already handles state and a
  colored background square would disappear against a same-color
  active-row highlight).
* **v0.238.4** — the plugin's page canvas was a slightly off-white
  shade instead of true white, per user request; corrected on both
  `.chargeforms-wrap` (every standard admin screen) and the Builder's
  separate React app shell (`.fc-app`), which is styled independently
  and needed the fix applied twice.

## Poppins admin font; readme.txt housekeeping (v0.231.0)

User request: Poppins across the admin UI (previously Roboto) — changed
`Menu::enqueue_assets()`'s Google Fonts `<link>` and the `--fc-font`
token in `assets/css/admin.css`; ChargeForms Bookings' own admin
screens pick this up automatically since they share the same
`chargeforms`-prefixed page scope and this same token, rather than
loading a second font. Also fixed a real WordPress.org submission
gap found this session: `readme.txt`'s `Stable tag`/`Tested up to`
were stale (0.208.0/WP 6.6 while the plugin was actually at 0.230.0,
tested against WP 7.1 live), its changelog stopped at 0.208.0 with 22
undocumented versions since, and the newly-added Google Fonts request
wasn't disclosed anywhere — all fixed directly in `readme.txt`.

## v0.209.0–v0.230.0 — the reference product parity work, Entries/Reports polish, Builder add-on API

Per-field/per-notification Global/Custom error messages, Configure
Routing and Conditional Logic for notifications, a Digits validation
rule, dynamic `{query.param}` default values, a real syntax-highlighted
Custom CSS/JS editor, foreign-admin-notice suppression sitewide, a
"Saving…" submit indicator, an Entries redesign (bulk selection on the
all-forms screen, decluttered chart, fixed duplicate menu row), and a
new `chargeforms_preview_form_assets` hook + `defineFieldModule` builder
API so an add-on field type (ChargeForms Bookings' Booking Calendar) can
register real preview/settings-panel content and assets on the Preview
& Design screen. See `readme.txt`'s per-version changelog for the full,
accurate list — built from each version's actual commit message rather
than reconstructed after the fact.

## Visible "Saving…" feedback on every form submit (v0.213.0)

Researched why ChargeForms was reported as feeling harder to use than
the reference product specifically around loading/navigation. Measured live:
every ChargeForms settings/notification/tools screen is a real
full-page POST-redirect-GET (~900ms-1.2s round trip on the live test
site), with zero visual feedback between clicking Save and the next
page painting — the reference product's equivalent actions are AJAX inside its
SPA and show a spinner immediately. (Checked ChargeForms' own asset
weight first to rule that out as the cause: 64KB of CSS/JS on Entries,
not bloated — the gap is architectural, not an inefficiency to fix
directly, and a full SPA rewrite of every settings screen is a much
larger call than this pass's scope.) Shipped the low-risk piece: a
site-wide `submit` handler (`assets/js/admin.js`) that disables the
clicked submit control and swaps it to a spinner + "Saving…" the
instant a POST form submits — verified this doesn't cancel or exclude
the submission (disabling in the `submit` handler fires after the
browser has already captured the entry list). Restores itself on
`pageshow` with `persisted` true, so a bfcache-restored page (hitting
Back) doesn't leave a button stuck disabled. Scoped to POST forms only
via `.chargeforms-wrap` — GET forms (search/filter) are untouched, and
`.chargeforms-no-loading-state` is available as an opt-out for any form
that needs it later.

## Clean admin chrome on every screen, not just the Builder (v0.212.0)

`Builder_Page::suppress_admin_notices()` already gave the Builder a
completely clean canvas — the reference product's own builder has zero WP chrome
bleeding through. Live-comparing every OTHER ChargeForms screen found
the same problem still there: Entries, Forms List, Dashboard, etc. all
showed the core "WordPress update available" nag and other plugins' own
notices (FluentSMTP's "needs to be configured", Contact Form 7's
migration banner) stacked above the page — the reference product's own equivalent
screens never do this. Added `Menu::suppress_foreign_admin_notices()`
(`admin/class-menu.php`): unlike the Builder's blanket
`remove_all_actions()` (safe there only because the React app renders
its own equivalent banner), this walks every registered
`admin_notices`/`all_admin_notices` callback and removes only the ones
that do NOT belong to this plugin — so ChargeForms' own real
`Delivery_Health_Notice` (a genuine mail-failure warning) still shows
where relevant, and only foreign noise is stripped. New test:
`tests/suppress-foreign-admin-notices.test.php` (6/6 passing) — verifies
third-party class-based and WP-core procedural callbacks are removed,
ChargeForms' own class-based and chargeforms-prefixed callbacks are
kept, and the method no-ops on the Builder screen (already handled) and
on non-ChargeForms screens.

## Custom CSS/JS: real syntax-highlighted editor (v0.211.0)

Live-compared against the reference product's Form Settings > Custom CSS/JS: it
uses an Ace editor (dark theme, syntax highlighting); ChargeForms' own
Custom CSS/JS tab was a plain `<textarea class="large-text code">`. Wired
both textareas up to WordPress core's own `wp.codeEditor` (the same
CodeMirror wrapper the Customizer's Additional CSS panel and the
Plugin/Theme file editors already use) via `wp_enqueue_code_editor()`,
scoped to load only on that one tab (`page=chargeforms-form-settings` +
`section=custom-code`). Degrades gracefully to the plain textarea if a
user has disabled the code editor in their WP profile, since
`wp_enqueue_code_editor()` returns `false` in that case and the init
script (`assets/js/code-editor-init.js`) simply no-ops. Commit: (this
pass).

## Fix stale Support copy + 3 real horizontal-overflow bugs (v0.210.0)

Live Playwright audit against `tehvaarevents.com` (both plugins,
1440/1024/768/390px) measuring `scrollWidth` vs `clientWidth` across
every Core admin screen. Found and fixed 3 genuine overflow bugs, all in
`assets/css/admin.css`: `.chargeforms-page-head` and
`.chargeforms-entries-actions` were missing `flex-wrap: wrap` (added,
plus a `@media (max-width: 480px)` rule stacking
`.chargeforms-page-head-actions` full-width); `.chargeforms-tools-log-summaries
> .chargeforms-tool-card` and `.chargeforms-api-log-summary-list li` hit
the classic CSS Grid/Flexbox `min-width: auto` gotcha, which was
defeating an already-correct `text-overflow: ellipsis` rule on a
descendant (fixed via explicit `min-width: 0`). Also fixed
`class-support-page.php`'s "Get Started" copy, which still described a
persistent left-hand field palette the Builder no longer has (fields are
now added via the canvas + button or the Input Fields tab). Verified:
870/870 PHP tests unchanged, brace-balanced CSS, re-measured live —
zero overflow found across all sampled screens/widths afterward. A
matching Playwright audit of ChargeForms Bookings' 7 admin screens (5
widths incl. 360px) found zero overflow issues there already. Commits:
`890b1c0`, `ef0ab1e`.

## Reports: per-stat icon chips + site-wide Toast component (v0.209.0)

Reports' 9 stat tiles (Overview + Payments tabs) got the same
`.chargeforms-stat-icon is-{color}` icon-chip treatment Entries already
used, for visual consistency across the two screens rather than
inventing a second component. Added `window.chargeformsToast`
(`success`/`error`/`warning`/`info`/`dismiss`/`dismissAll`) as a
site-wide vanilla-JS equivalent of the Builder's existing React Toast,
matching its exact type/duration contract
(`{success:4000, info:4000, warning:6000, error:null}`) for admin
screens outside the Builder's React tree. Commit: `b0aee10`.

## A real Dashboard screen (v0.196.5)

ChargeForms had no Dashboard at all — the top-level admin menu landed
straight on the Forms list (`Forms_List_Page` registered on the shared
`chargeforms` slug). Added `Admin\Dashboard_Page` (`chargeforms-dashboard`
slug, its own sidebar entry and top-nav tab, first in both), built
entirely from data other screens already expose: `Form::count()`,
`Entry::count_all()/count_unread()/payment_total()/counts_by_day()/
recent()`, `Smtp_Connection::count()`, `Email_Log::query(['status' =>
'failed'])`. No new aggregation logic, no fabricated metrics — every
number is something a user could already find on Forms/Entries/Reports/
Settings, surfaced in one place. Reuses existing components as-is
(`.chargeforms-stat-tile`, `.chargeforms-bar-chart`, `.chargeforms-card`,
`.chargeforms-empty-state`, `.chargeforms-status-dot`); the only new CSS
is the two-column dashboard layout, the activity list, and three status-
dot color modifiers. A zero-forms site gets a dedicated first-run empty
state instead of a dashboard full of zeros.

Deliberately does NOT surface ChargeForms Bookings data — that's a
separate plugin/license, and pulling its data into Core's own dashboard
would be a cross-plugin coupling decision outside this pass's scope.

## Front end: help text is now wired to its control (v0.196.4)

Found while tracing v0.196.3's error-id plumbing: `class-shortcode.php`'s
per-field `.chargeforms-field-help` span (distinct from the beside-label
tooltip `?` icon, which already carries its text via `aria-label`) had
the identical problem — rendered in the DOM but never pointed at by
anything, no `id`, no `aria-describedby`. Fixed at the codebase's own
existing centralization point, `extra_input_attrs()` (its docblock
already states the intent: one insertion point for every rendered
input's shared attributes), via a new `field_help_id()` helper. Composes
correctly with v0.196.3 at runtime — a field with both help text and a
failed validation gets `aria-describedby="{id}-help {id}-error"`, since
the JS side only ever appends to existing `aria-describedby`, never
replaces it. `address` and `social_links` fields render outside the
generic wrapper this lives in and don't get this fix (a pre-existing,
separate missing-feature gap, not a regression here — left alone rather
than scope-creeping into it). Verified: 4 new assertions added to
`tests/field-settings-frontend.test.php` (28/28 pass via the php-wasm
harness). See `docs/DESIGN_SYSTEM.md` Slice 18.

## Front end: validation errors are now announced to assistive tech (v0.196.3)

`frontend-entry.js`'s `showFieldError`/`clearFieldError` — called by every
multi-step "Next" click and final submit — only ever toggled a visual
`.is-invalid` class and inserted a message `<span>`; confirmed by grep,
neither `aria-invalid` nor `aria-describedby` was set anywhere in the
file. A screen-reader visitor got no indication a field failed validation
beyond a color change, on the plugin's highest-traffic surface (real form
visitors, not admins). Fixed: `aria-invalid="true"` plus an appended (not
replacing) `aria-describedby` pointing at the error message, reversed on
clear. The message id is memoized per field wrapper so a required
radio/checkbox group — which shares one wrapper and one error message
across several controls, since the native Constraint Validation API marks
every control in an invalid required group invalid together — keeps
pointing every control at the same message. Verified: production bundle
compiles clean; full JS unit suite (57/57) passes. No automated test
added — this file is a single IIFE with no exported internals and no
existing test scaffolding, and no browser was available to confirm a
screen reader actually announces the result; both limitations are named
rather than assumed away. Also corrects two inaccurate test-result claims
this changelog and `docs/DESIGN_SYSTEM.md` made in the v0.196.2 entry —
see that entry's own correction, and Slice 16/17 in
`docs/DESIGN_SYSTEM.md` for the full writeup.

## Builder: floating toolbar no longer overflows narrow viewports (v0.196.2)

`FloatingToolbar.js` centered itself on the selected field's midpoint with
a plain `translate(-50%, ...)`, with no check against the viewport's
left/right edges — unlike its existing vertical flip logic, which already
handles the toolbar's top edge running into the device-preview switcher.
At the 390–480px widths the rest of this responsive pass targets, a field
spanning nearly the full canvas width has its center close enough to the
viewport edge that a full 9-button toolbar would render partly off-screen.
Fixed with a `useLayoutEffect` that measures the toolbar's actual
rendered bounds against `window.innerWidth` after each reposition and
writes a corrective `--fc-toolbar-shift` CSS variable only when the plain
centering would overflow — additive on top of the existing `-50%` anchor,
so the already-verified vertical/flip behavior is untouched. Also swept
every remaining admin screen (Integrations, Payments, Tools, Support,
License, Form Settings, New Form) for the same icon-only-missing-
`aria-label` pattern found in the Builder/Entries passes — all controls
there already had visible text labels, so no further fix was needed.
Verified: production bundle compiles clean; full JS unit suite (57/57)
passes. *Correction:* this entry originally reported 19 failures here,
attributed to a pre-existing `jest-dom` matcher gap — that was wrong, and
the cause was mine: `npm run test:unit` runs
`wp-scripts test-unit-js --config test/jest.config.js` (the config that
actually wires up `@testing-library/jest-dom` via `test/jest.setup.js`),
and this verification ran `wp-scripts test-unit-js` directly, without
that flag, so no test file failed on its own merits — it never had the
matcher loaded to begin with. See `docs/DESIGN_SYSTEM.md` Slice 16 for
the full writeup, including the one real remaining limitation: no browser
available in this environment to visually confirm the toolbar clamp at
390px, so that part is verified by measurement math, not a screenshot.

## Entries: real accessible names on every icon-only row action (v0.196.1)

`admin.css` already documents the intended contract for this codebase's
CSS-only rich-hover-tooltip pattern: "Put the display text in
data-tooltip and an aria-label with the same content for assistive
tech, since data-* isn't read by screen readers the way title normally
would be." Found (grep, not assumed) 14 icon-only row-action links across
the Entries list and single-entry detail screens — View/Restore/Delete
Permanently/Star/Mark Read-Unread/Move to Trash/View as JSON — that only
had `data-tooltip`, violating that contract: a sighted mouse user got the
rich hover tooltip, a screen reader user got a completely unlabeled
link. Added the paired `aria-label`, mirroring each one's existing
conditional logic exactly (e.g. Star vs. Unstar, Mark Read vs. Mark
Unread) rather than a generic static label. Also added `aria-label` to
two `&times;` modal-close buttons (Entries' Resend Notification and
Entry Actions modals) that had no accessible name at all, and to the
shared delete-confirmation modal's close button, which only had `title`
(the exact unreliable-for-screen-readers case admin.css's own comment
warns about).

Left alone, deliberately: the handful of `data-tooltip` usages on
non-interactive elements (a chart bar column, a browser/device cell, a
Reports heatmap row) — informational hover content on a `<div>`/`<span>`
a screen reader user can't "activate" is a different, lower-urgency
category than an unlabeled interactive control, not covered by this
pass.

## Forms List: the reference product-matched visual pass (v0.196.0)

User direction: color stays violet, but the design should match the
reference product's UI/UX exactly. A dedicated `forms-list-refine.css`
(measured against that product's own shipped output — table card,
control geometry, status switch, hover-revealed row actions with divider
rules, pagination pill) loads only on the Forms List screen, scoped via
a real body class (`Forms_List_Page::admin_body_class()`) so it never
leaks onto Entries/Reports/Builder. Every color accent stays ChargeForms'
own violet where the reference product uses blue. The toolbar and pagination moved
outside `.chargeforms-list-card` to match the reference product's own anatomy (controls
float on the gray page canvas; only the table itself sits in the white
card).

This was written in an earlier session and sat uncommitted in the
working tree; committed here after review — cross-checked every one of
forms-list-refine.css's ~40 CSS class selectors against the actual PHP markup
(zero orphaned selectors), parse-checked both PHP files, and traced the
`<div>` nesting by hand through the restructured `render()` method to
confirm every conditional branch (empty/vue/PHP-table) is independently
balanced.

## Builder: a real loading skeleton, replacing the bare spinner (v0.195.1)

The app's full-page loading state (fetching an existing form's data)
was centered text and a spinner — replaced with a skeleton mirroring the
real shell's own proportions (topbar + canvas + side panel, built from
the same layout tokens the real shell renders with), which reads as
faster since it shows the shape of what's arriving rather than just
"something is happening." Reuses the plugin-wide shimmer animation
already defined for Forms/Entries but never actually wired to anything
(confirmed by grep) instead of inventing a second one. Accessible
announcement via `role="status"`/`aria-live="polite"` plus a screen-
reader-only label; the skeleton blocks themselves are `aria-hidden`.
Removed the now-fully-superseded spinner CSS rather than leave it as
dead code. Full JS suite passes; bundle compiles clean.

## Builder: real tooltips on icon-only controls, sitewide (v0.195.0)

Re-auditing the builder's own tracked "not attempted" backlog
(`docs/DESIGN_SYSTEM.md` §9) before starting new work found it had drifted
from the actual code in three places — real toggle switches, radio button
styling, and a real Tooltip/Toast component system all turned out to
already be built, contradicting what the doc said. Corrected in the doc;
see `docs/DESIGN_SYSTEM.md`'s new Slice 14 for the detail.

What was genuinely still open: the Tooltip component existed but was wired
into exactly one screen (`Toolbar.js`, and not even every icon-only button
there). Extended it to every other icon-only interactive control across
the builder — the floating per-field toolbar, canvas container controls,
attribute-row remove buttons, the rich-text formatting toolbar (whose four
buttons had no `aria-label` at all before this — a real accessibility gap,
not just a tooltip upgrade), and the conditional-logic rule builder's
row/group action buttons. Drag handles were deliberately left alone — no
live browser available here to verify a change to dnd-kit's listener
wiring is safe. Full JS suite (57 assertions) passes; production bundle
compiles clean.

## Fix: a third-party field type could register but never appear in the palette (v0.194.1)

Live bug, diagnosed from a real site running ChargeForms Bookings: its
Booking Calendar field called `window.chargeformsBuilder.registerFieldType()`
correctly (confirmed in the browser console — the definition was really in
the registry's `Map`, correctly grouped, correctly licensed), and still
never appeared anywhere in the builder — not even locked.

Root cause: `fields/index.js` exported `FIELD_TYPES` as
`getAllFieldTypes()`'s return value evaluated ONCE, at that module's own
import time — `export const FIELD_TYPES = _getAllFieldTypes();`. Every
consumer (`FieldBrowser`, `CanvasField`, `FieldPreview`, `useCanvasDnd`,
`App`) imports that binding at their own module load, which happens
before an add-on's own script ever runs (add-on scripts are enqueued as
dependents of this bundle, specifically so the bundle — and every one of
its internal imports — has already executed first). A `const` array
export is a live reference to whatever object it was assigned, but
`getAllFieldTypes()` was deliberately building a NEW array every call
(so callers couldn't mutate the registry by accident) — meaning the one
export everyone imported was permanently frozen at "builtins only," no
matter how many `registerFieldType()` calls happened afterward.

Fixed at the source: `registry.js` now keeps ONE array object
(`orderedList`) that `registerFieldType()` mutates in place (push, or
replace-in-place for a re-registered type) rather than deriving a fresh
array from the `Map` on demand. `FIELD_TYPES` is that exact array,
re-exported unchanged — every existing consumer needed zero changes,
since they already read `FIELD_TYPES.filter()`/`.find()` fresh on each
call rather than caching it. `getAllFieldTypes()` still returns a
defensive copy (`orderedList.slice()`), so the "can't mutate the registry
through the return value" guarantee is untouched. New test suite
(`fields/registry.test.js`) specifically proves a binding captured
*before* a later `registerFieldType()` call still reflects it afterward —
the exact shape of the bug — not just that a fresh import would work,
which the broken version would also have passed.

## Fix: no way to remove a single SMTP connection; Email Summaries redesign (v0.194.0)

Live bug, found from a real screenshot: a site with exactly one SMTP
connection (the common case — Free tier) had no way to delete it from
the UI. `renderConnectionsList()` in `email-delivery.js` deliberately
skips its own table below 2 connections on Free ("the summary card
above already covers it," per that function's own comment) — but the
table was the ONLY place a Delete button lived. `Smtp_Connection::
delete()` and its REST route (`DELETE /smtp/connections/{id}`) were
both already fully built and working; the summary card just never
exposed them. Added a "Remove" action to the summary card itself,
reusing the exact same `deleteConnection()` the table's own row
already calls — same confirm dialog, same endpoint, not a second
delete path.

Also redesigns Email Summaries to match Email Delivery's visual
framing: the same intro-card shape (icon, heading, "Built-in, free"
badge, one sentence of context) rather than starting straight into a
settings form with no equivalent framing — both are genuinely free,
always-on capabilities and should read as siblings. The "What gets
sent" preview card (~40 inline `style=""` attributes producing a
mocked email preview) is now real CSS classes in `admin.css` —
maintainable, consistent with how every sibling screen is actually
built, same visual output plus a real shadow and CTA hover inline
styles made awkward to add consistently.

Verified against a real PHP 7.4 runtime: the modified settings page
parses clean, full suite still 58/59 (the one failure,
google-sheets-client, is openssl_pkey_export missing from the local
PHP-WASM build, unrelated to this change, confirmed unaffected).
`email-delivery.js` passes `node --check`.

## Admin notice when notification emails are actually failing (v0.193.0)

Live support case: a site owner's submission notifications were silently
failing (real SMTP delivery failure, retries exhausted) with no signal
anywhere in wp-admin — the only way to find out was already knowing to
open Tools > Notifications and hover a status pill to read the error.
Nothing pointed them there.

Adds `Notification_Log::count_recent_failures()` and
`Delivery_Health_Notice`, which checks it on ChargeForms' own admin
screens and shows a red notice — "N notification emails failed in the
last 24 hours" with a link straight to Tools > Notifications — the moment
there's a real, recent, permanently-failed send (retries exhausted, not
just still-retrying). If no SMTP connection is configured at all, the
notice adds that as the likely cause and links to Email Delivery setup
directly.

Deliberately conservative: never fires on a healthy site, and never fires
merely because no SMTP connection exists — a host whose default mail
sending genuinely works has nothing to fix. Scoped to ChargeForms' own
admin pages only, so it can't become an ignored banner on every wp-admin
screen regardless of relevance.

Verified: 5/5 new assertions for the failure-count query (status filter,
window filter, zero-failure and empty-log cases), full suite still at
58/59 (the one failure, google-sheets-client, is an environment gap —
openssl_pkey_export unavailable in the PHP-WASM runtime used to verify
this locally — not a regression; confirmed unaffected by this change).

## Add-ons can list themselves on the Integrations screen (v0.192.0)

`Integrations_Page::modules()` was a hardcoded array, so an installed
add-on had no way to appear on the one screen a site owner actually checks
to see what ChargeForms has and what it can do. An active add-on that
isn't there reads as not installed — which is exactly what happened with
ChargeForms Bookings.

Adds the `chargeforms_integration_modules` filter. Because the card bodies
are hand-written per built-in key, a module whose key isn't one of those
now renders through a generic branch driven by optional `description`,
`action_url`, `action_label`, `status` and `status_label` entries. That
branch is keyed on `description` being present, so the existing "Coming
Soon" placeholder still covers a module that supplies nothing.

The category whitelist is now derived from the modules actually present
rather than a hardcoded `['crm','core']`, so a category introduced by an
add-on is filterable instead of silently falling back to "all".

The Bookings add-on has also moved out of this repo to
[UzairRehman4/chargeforms-bookings](https://github.com/UzairRehman4/chargeforms-bookings).
It is sold and versioned separately, and sharing this repo's release
stream buried its releases (position 13 of 198) and left Git Updater no
unambiguous way to tell which release asset belonged to which plugin —
the newest release here is essentially always a ChargeForms one, carrying
only `chargeforms.zip`.

## Two add-on extension points for field types that own a scarce resource (v0.191.0)

Adds the two hooks the ChargeForms Bookings add-on needs, both in the
submission/render path where an add-on previously had no way in.

`chargeforms_before_entry_create` fires after every value is validated but
**before** `Entry::create()`. Bookings uses it to reserve an appointment
slot atomically. That claim cannot run on the existing
`chargeforms_after_submission`: by then the entry row already exists, so a
request that lost the race would leave a stranded entry behind with no
booking backing it. Listeners that cannot proceed reject the submission the
same way the surrounding checks do — `wp_die()` with a 4xx — since there is
no return value and execution would otherwise continue on to create the
entry.

`chargeforms_render_custom_field` lets an add-on render a field type
`Shortcode::render_field()`'s switch doesn't know about, short-circuiting
the built-in rendering when it returns a non-null string. The switch has no
default branch, so before this an unknown type rendered *nothing* and the
field silently vanished from the published form. The filter passes the
input `$name` the field must post under, and the returned markup is echoed
as-is — escaping is the listener's responsibility, documented on the hook.

Together with `window.chargeformsBuilder.registerFieldType` (v0.190.0),
these are the three seams a third-party field type needs: palette, render,
submit.

## Fix: Builder's quick-add popover, context menu, and Pro-lock modal rendered transparent (v0.166.0)

Reproduced a real bug from a live screenshot: clicking a "+" to add a
field mid-canvas opened a search/category/field-grid popover with **no
solid background at all** — its own content and the canvas fields behind
it visually merged into an unreadable mess, category pills wrapped onto
the field grid, everything overlapping.

**Root cause, confirmed via computed styles, not guessed:** `QuickAddMenu.js`,
`ContextMenu.js`, and the Pro-feature upsell modal in `FieldBrowser.js`
all correctly portal straight to `document.body` (deliberate — escapes
the canvas's own clipping/transform context so the popover can flip
above/below and never gets cut off). But every design token those
components rely on (`--fc-surface`, `--fcb-border-soft`, and the
`.chargeforms-wrap .button-primary` violet-button rule) was scoped to
`.chargeforms-wrap`/`.fc-app` — CSS custom properties only inherit down
the DOM tree from where they're declared, and a `.chargeforms-wrap
.button-primary` descendant selector only matches inside that specific
subtree. `document.body` sits OUTSIDE both, so every one of those
`var(...)` calls silently resolved to nothing (transparent) for a
portaled element, and the button-primary rule never matched at all —
the "Upgrade to PRO" CTA was falling back to WordPress core's native
blue button, the one CTA in the whole plugin that wasn't violet.

Two-part fix:
- `assets/css/admin.css` and `assets/src/builder/shared/styles/tokens.css`:
  the `--fc-*`/`--fcb-*` token scales are now ALSO declared on `:root`
  (an ancestor of literally everything, portaled or not), not just
  `.chargeforms-wrap`/`.fc-app` — fixes this whole class of bug for
  every current and future portaled component in one place, instead of
  patching each affected declaration individually. The real applied
  styles that used to share `.chargeforms-wrap`'s block (`color`,
  `font-family`) stay scoped there on purpose — those should only paint
  ChargeForms' own screens.
- `assets/src/builder/style.css`: explicit fallback values on the three
  portaled components' own background/border declarations (belt-and-
  braces on top of the `:root` fix) and a scoped `.fc-pro-modal-body
  .button-primary` violet treatment, since that one was a selector-match
  failure, not a custom-property failure, and needed its own fix.

Verified live: reproduced the exact broken popover locally, confirmed
`getComputedStyle()` showed a fully transparent background before the
fix (`rgba(0, 0, 0, 0)`) and solid white after (`rgb(255, 255, 255)`),
then screenshotted all three previously-broken surfaces (quick-add
popover, right-click context menu, Pro-lock modal) clean and correctly
styled. `npm run build`, the JS suite (50/50), and the PHP suite (53/57,
same 5 pre-existing environment-only failures) all pass.

## Add: two ready-made forms on first activation; fix bare zero-forms Entries screen (v0.165.0)

Checked the true "day zero" experience — a genuinely fresh install, not
a dev DB with leftover data — since it hadn't been specifically verified
this pass. Found two real gaps:

- **No seeded content at all.** `Install::activate()` only ever created
  the DB schema; a brand-new site landed on "0 forms" with nothing to
  explore. the reference product (and most competitors) ship a couple of
  ready-to-use forms out of the box. `Install::activate()` now seeds
  **"Simple Contact Form"** and **"Newsletter Signup Form"** — reusing
  the exact same field definitions `New_Form_Page::templates()` already
  offers in "Choose a Template" (one definition, not a second copy that
  could drift) — as **draft** forms, so a fresh site never accidentally
  goes live with two forms nobody consciously published. Gated to real
  first-time activation only (`get_option('chargeforms_db_version')`
  returning `false`, i.e. the option never existed before) — `activate()`
  is also called from `maybe_upgrade()` on every version bump, so this
  must never re-seed (or duplicate) forms into a site that's been
  running for months; verified live that re-running activation on an
  already-seeded site adds nothing.
- **The Entries screen's zero-forms state was bare, unstyled text** —
  "Create a form to start collecting submissions," no card, no icon, no
  button — inconsistent with every other empty state in the plugin
  (Forms List's own zero-state has an icon, heading, description, and a
  violet CTA button). Now matches that same
  `.chargeforms-empty-state-hero` treatment.

Verified live: cleared the forms table and the `chargeforms_db_version`
option to simulate a genuine fresh install, loaded the plugin, and
confirmed both forms appear correctly (draft/"Inactive", real
shortcodes, correct field counts) — then confirmed re-running
`Install::activate()` on that now-seeded site does not duplicate them.

## Fix: unreadable dark-on-violet text on the "New Blank Form" card (v0.164.0)

Full pixel-by-pixel sweep of every real admin screen (Forms, Entries,
Reports, Payments, Global Settings incl. Email Delivery, Tools,
Integrations, Support, New Form, the Builder canvas + field-settings
panel, and a live front-end submission) against the design handoff, after
pulling the design-fidelity work landed since v0.147.0 (v0.150–v0.163).

Thirteen screens/states checked live; twelve matched. One real bug found:
`assets/css/design-tokens-screens.css`'s `.chargeforms-new-form-card-btn`
rule set the button's own `color: #fff` for the violet "New Blank Form"
card, but `admin.css`'s `.chargeforms-new-form-card h3`/`p` rules (styled
for the other, white-background cards) are more specific than an
inherited color and were winning — the card's heading and body text (and
its dashed icon box, styled for a light background) were rendering
dark/gray-on-violet, nearly unreadable. Fixed by explicitly overriding
h3/p to white and the icon box to a translucent-white treatment when
inside the violet button variant specifically, not the shared card rule.

Also confirmed the front-end shortcode render (`frontend/class-
shortcode.php`, the file an earlier session found "catastrophically
truncated" and restored in v0.159.0) renders a real multi-field form
correctly with no errors, and the Builder's field-settings panel (label/
placement/required toggles plus the General/Appearance/Validation/
Conditional Logic/Advanced Options/Accessibility/Developer tab
accordion) matches the design with no console errors.

## Security/correctness fixes from a full-codebase audit, plus Email Delivery is now immune to caching-plugin interference (v0.149.0)

Ran a systematic line-by-line audit across the whole plugin (payments,
SMTP, submission handling, REST API/logic engine/MCP, admin, integrations)
looking specifically for real, reproducible correctness and security bugs
— not style. Payments, SMTP/credentials, and admin-page CSRF/escaping came
back clean (already defensive from prior audit passes). Everything below
was found, verified with a live reproduction, and fixed.

**Email Delivery — root-caused the caching-plugin hang, not just handled it (v0.146.0/v0.148.0 follow-up):**
`admin/class-settings-page.php` no longer uses `wp_localize_script()` to
hand this screen its config — that relies on a separate inline `<script>`
block landing in the page in the right order relative to the external
file that reads it, which is exactly what a JS/HTML "combine and minify"
caching plugin can break. The config is now embedded as a `data-config`
JSON attribute directly on the same container element the script already
has to find (`render_email_delivery()`), which has no such ordering
dependency at all — same HTML response, same element. `assets/js/email-
delivery.js` reads it from there instead of a global.

**Real security bug — scalar field validation bypassable by posting an array (`frontend/class-submission-handler.php`):**
Every format check (email shape, disposable-domain block, number/url
shape, maxLength, validateUnique, Field_Validator's minLength/regex/
min/max) branched on `is_array($val)` alone — so posting
`chargeforms_data[<any-scalar-field>][]=garbage` instead of a plain
string skipped every single one of them, for ANY field, while still
being accepted and stored. Now the field's actual type is checked against
the real set of field types whose rendered `<input name>` ever produces
an array (`multi_select`, `multiple_choice`, `address`, `social_links`,
and `image_choice` when its own `multiple` setting is on) — an array
posted for anything else is now rejected outright (400) instead of
silently treated as if it were legitimate. Verified live: the exact
bypass request now gets rejected; a genuine `multi_select` submission
still works unchanged.

**Real gap — repeater child fields had zero format validation (same file):**
`validate_repeater_rows()` only ever checked "required" on a repeater's
child fields — an email/number/url child field's format was never
checked at all, so garbage data (including whatever address a "send a
confirmation to" double-opt-in email field held) sailed straight through.
Factored the main loop's format checks into a shared
`validate_scalar_format()`, now also applied per child field per row.

**Real bug — the conditional-logic evaluator could disagree with its own JS twin (`includes/logic/class-builtin-operators.php`):**
PHP's bare `(string) $array` cast produces the literal string `"Array"`
(plus a PHP warning); JS's implicit `String(['a','b'])` produces the
comma-joined `"a,b"`. A checkbox/multi-select field's array value
compared with any non-`contains` operator (is/is_not/greater_than/
between/in_list/etc) could therefore evaluate differently between the
browser's live required/visible state and this server-side authority —
every array value collapsed to the same literal `"Array"` here regardless
of what was actually selected. Fixed by joining with a bare comma
server-side too, matching JS's coercion exactly.

**Real gap — MCP write tools bypassed the Pro/unbuilt field-type gate (`includes/mcp/class-mcp-tools.php`):**
The REST `create_form`/`update_form` routes block inserting a Pro-only
field type on an unlicensed site, or an `UNBUILT_FIELD_TYPES` field (one
that saves fine but renders nothing at all on the live form) — via
`reject_locked_fields()`. The MCP tools of the same name called
`Form::create()`/`Form::update()` directly, with no such gate, so an MCP
client authenticated with the same capability the REST API requires could
insert either. Factored the gate into a new, dependency-light
`includes/class-field-type-gate.php` (no WP REST dependency, unlike the
old private method on `RestApi`) so both the REST routes and the MCP
tools enforce the identical check. New regression coverage in
`tests/mcp-tools.test.php`.

**Resource-exhaustion — unbounded `per_page` on four admin list screens:**
`admin/class-forms-list-page.php`, `class-entries-page.php` (both list
views), and `class-payments-page.php` all clamped `per_page` to a floor
of 1 but had no ceiling — a privileged user requesting
`?per_page=999999999` could force an unbounded `LIMIT` and pull an
oversized result set into memory. Capped at 200 everywhere.

**Credential hygiene — Telegram bot token logged in plaintext (`includes/integrations/class-telegram-integration.php`):**
Telegram's own API shape puts the bot token directly in the request URL
(`api.telegram.org/bot<token>/sendMessage`) — unlike every other
integration here, which sends its secret in an Authorization header that
never gets logged. That full URL, token included, was written verbatim
into the API log table and rendered as-is on Tools → API Logs. Now
redacted to `/bot***REDACTED***/` before logging; the real token is still
used for the actual request.

Verified live throughout: the array-bypass attack request now gets a 400
where it previously succeeded; a genuine multi-select submission still
works; the Email Delivery config-missing failure mode now shows a clear
error instead of hanging (reproduced by stripping the inline config block
from a real HTTP response). Full PHP suite passes 53/57 (same 5
pre-existing, unrelated environment failures as every prior release —
this local PHP build is missing `openssl_encrypt()`); JS unit suite
50/50; `npm run build` unaffected (no webpack-bundled files changed this
pass).

## Fix: Email Delivery screen could hang forever even with the v0.146.0 timeout fix (v0.148.0)

v0.146.0 hardened `loadConnections()` against a hung REST request — but
found a second, earlier failure mode by reading `assets/js/email-
delivery.js` line by line again: the script's very first lines checked
`typeof ChargeFormsEmailDelivery` and silently `return`ed if that
`wp_localize_script()`-injected config object was missing, before ever
reaching `loadConnections()` — so the v0.146.0 timeout/retry logic never
even had a chance to run. The server-rendered "Loading…" placeholder was
left on screen with literally nothing happening and nothing to catch it.

The most likely real-world cause: a JS/HTML "combine and minify" caching
plugin, or a host's own page cache, stripping or reordering the inline
`<script>` block `wp_localize_script()` prints relative to the external
file that reads it — the external file loads fine, but its config never
arrives. This is now a visible, actionable error message instead of a
silent no-op: `assets/js/email-delivery.js` shows a
`.chargeforms-ed-load-error` card explaining the likely cause (a caching/
minification plugin) and suggesting a hard refresh or disabling
"combine JS" for wp-admin, with a Reload button.

Verified live: normal load unaffected; simulated the exact failure by
stripping the inline config block from a real HTTP response (Playwright
route interception) and confirmed the new error card renders instead of
the old silent hang. PHP suite passes 53/57 (same 5 pre-existing,
unrelated environment failures); this JS file has no build step and
isn't part of the webpack bundle or JS unit suite, so no rebuild needed.

## Change: multi-step forms get the design's numbered-dot stepper, not a progress bar (v0.147.0)

Continuing the audit against `design_handoff_chargeforms/ChargeForms
Front-End.dc.html`'s isStep1/isStep2 states: multi-step forms were
rendering a flat fill-bar progress track with plain text labels
underneath, not the design's numbered-dot stepper (① ─ ② ─ ③, a done
step gets a green checkmark, the current step a filled violet dot, the
connector line between two done steps turns green).

- `frontend/class-shortcode.php`: replaced the
  `.chargeforms-progress-track`/`-fill`/`-labels` markup with
  `.chargeforms-progress-item` (dot + label) and `.chargeforms-progress-
  line` (connector), one line per step gap.
- `assets/src/frontend/frontend-entry.js`: `initSteps()`'s `show()` now
  toggles `is-done`/`is-active` per step item and connector line instead
  of setting a fill-bar width, and swaps each dot's number for a
  checkmark once that step is behind the visitor (stored via a
  `data-n` attribute so the real number can come back if they navigate
  backward).
- `assets/css/frontend.css`: new dot/line styling (violet active, green
  done, gray upcoming), replacing the old bar/label rules; updated the
  existing ≤480px mobile rule to match the new markup (hide inactive
  step labels, tighten connector-line spacing) — same reasoning as
  before, just adapted to dots instead of a label row.

Verified live: a real 3-step form, screenshotted on step 1 (dot 1 filled
violet, others gray) and after advancing past step 1 (dot 1 shows a
green checkmark, connector line to step 2 turns green, dot 2 filled
violet) — both match the design. `npm run build`, the JS suite (50/50),
and the PHP suite (53/57, same 5 pre-existing environment-only failures)
all pass.

## Add: styled front-end restriction/success cards; harden Email Delivery against a silent infinite "Loading…" (v0.146.0)

Two pieces of work, prompted by a live report of the Global Settings >
Email Delivery screen hanging on "Loading…" forever on a real production
site, plus continuing the design audit against
`design_handoff_chargeforms/ChargeForms Front-End.dc.html`'s remaining
states.

**Front end — isRestricted / isSuccess states:**
- `includes/class-restrictions.php`: `Restrictions::check()` now returns
  `['type' => ..., 'message' => ...]` instead of a bare string, so a
  caller can tell login/schedule/entry-limit blocks apart and render each
  with its own icon and tone — previously every restriction (and the
  double opt-in "pending" state) rendered as the exact same flat gray
  `.chargeforms-notice` box, no icon, no distinction between why the
  visitor was blocked.
- `frontend/class-shortcode.php`: new `render_restriction_card()` (tone-
  coded warn/info card matching the design) and a distinct "Check your
  inbox" notice for a double opt-in submission — previously a pending
  double-opt-in submission showed the exact same "your submission was
  received" wording as a fully completed one, which is actively
  misleading (nothing is actually recorded until the visitor clicks the
  emailed confirmation link).
- `frontend/class-submission-handler.php`: `redirect_back()` gained a
  `$pending` flag so the double-opt-in path can signal this distinctly
  from a normal success, and no longer honors a "Redirect to a URL"
  confirmation setting for a pending submission (sending an unfinished
  visitor to a normal thank-you destination would be wrong).
- **Real bug found live while verifying this**: the restriction-card
  early-return in `Shortcode::render()` never enqueued
  `chargeforms-frontend` (the base stylesheet) at all — that enqueue call
  only lived inside `render_form_html()`, which the blocked path skips
  entirely. Every blocked-form message on every site, including the
  original plain gray box, has been rendering completely unstyled this
  whole time. Fixed by enqueuing on the restriction-blocked path too.
- `assets/css/frontend.css`: new `.chargeforms-restriction-card` (warn/
  info tones) and upgraded `.chargeforms-success`/`.chargeforms-success-
  pending` from a flat box to an icon + card layout.

**Admin — Email Delivery screen hardening:**
`assets/js/email-delivery.js`'s `loadConnections()` already caught a
failed fetch and fell back to `loading: false` — but a request that never
resolves at all (a blocked REST route, a host-level firewall, a dead
proxy — exactly what a live report described) isn't a failure the old
code could see, so nothing ever ran and the server-rendered "Loading…"
placeholder just sat there forever with no error, no retry, nothing.
Local repro against a clean install of this exact version showed no
bug — the environment on the live site is still unconfirmed — but the
underlying design (infinite wait with no escape hatch) was real regardless
of root cause, so:
- `api()` now aborts via `AbortController` after 20s and surfaces a
  "Request timed out" error instead of hanging indefinitely.
- A failed initial load now sets a distinct `loadError` state instead of
  silently rendering as "zero connections configured" (the old
  `.catch()` swallowed the error and showed the same empty state either
  way — actively misleading, since "the request failed" and "you have no
  SMTP connections" are very different things to tell a site owner).
- `render()` itself is now wrapped so a bug in the rendering code can't
  leave the screen stuck on stale/loading markup either — it shows the
  actual error message and a Reload button.
- Both new failure paths render a styled `.chargeforms-ed-load-error`
  card with a Retry/Reload button (`assets/css/admin.css`).

Verified live: normal load still works unchanged; simulated a fully
blocked `/smtp/connections` REST route (Playwright request interception)
and confirmed the screen now shows the styled error card with a working
Retry button instead of hanging. All four restriction/pending states
(login, schedule-pending, entry-limit, double-opt-in-pending) verified
live via real HTTP requests against a real WordPress + DB. `npm run
test:unit` (50/50) and the PHP suite (53/57, same 5 pre-existing
environment-only failures as prior releases — this local PHP build is
missing `openssl_encrypt()`) both pass; no build step needed (both
changed JS/CSS files are hand-authored, directly enqueued, not part of
the webpack bundle).

## Add: styled front-end form validation, replacing bare browser tooltips (v0.145.0)

Real gap found while verifying the front end against
`design_handoff_chargeforms/ChargeForms Front-End.dc.html`'s `isInvalid`
state: every form's validation UI was 100% the browser's own, unstyled
`required`/`type=email` tooltips — the design's branded error-summary
banner and inline field errors were never built, even though the CSS for
them (`.chargeforms-field-error`, `.chargeforms-error-above`) has existed
in `assets/css/frontend.css` since the field-layout work, completely
unused (confirmed via grep: zero PHP/JS references before this change).

- `frontend/class-shortcode.php`: the `<form>` tag now renders with
  `novalidate`, and `register_assets()`'s frontend-script enqueue is no
  longer conditional on multi-step/mask/signature/commerce/tabs/repeater/
  save-resume — every form needs the new client-side validation now, not
  just ones with those features, so a plain two-field form was previously
  shipping with zero chargeforms JS at all (confirmed live: found while
  testing this very fix, since novalidate alone with no replacement JS
  would have been a strict regression — the browser's native validation
  gone with nothing to replace it).
- `assets/src/frontend/frontend-entry.js`: new shared `validateControls()`
  helper (native Constraint Validation API — `checkValidity()`/
  `validationMessage` — without ever showing the browser's own popup UI),
  used by both the new `initValidation(form)` (submit-time: renders an
  error-summary banner — "Please fix N fields before continuing" with
  jump-to-field links — plus a `.chargeforms-field-error` message and
  `is-invalid` styling on every failing field, live-clearing as the
  visitor corrects each one) and `initSteps()`'s "Next" button (previously
  `form.reportValidity()`, which still showed native tooltips regardless
  of `novalidate` — now uses the same styled path so a mid-form step
  failure looks identical to a final-submit failure).
- `assets/css/frontend.css`: added `.chargeforms-field.is-invalid` (red
  border + red glow, reusing the existing `#e5484d` error red) and the new
  `.chargeforms-error-summary` banner styling — the two pieces the design
  specified that had no CSS at all yet.

Deliberately UX-only: `frontend/class-submission-handler.php`'s
server-side validation failures still `wp_die()` with a bare error page
(a separate, more serious gap — server-side is the actual security
boundary and needs a graceful redisplay-with-errors, not a death page;
left as a documented follow-up, not fixed here to keep this change
scoped).

Verified live: a form with two required fields, submitted blank —
confirmed the error-summary banner and inline errors render (screenshot),
then confirmed a valid submission still POSTs through to
`admin-post.php?action=chargeforms_submit` exactly as before (no AJAX
conversion, still a plain server round-trip) and lands on the real
success state. `npm run build` and the JS unit suite (50/50) pass; the
PHP suite passes 53/57 — the 5 failures are pre-existing
`openssl_encrypt()`-missing/environment failures in this local PHP build
unrelated to this change (SMTP credential/connection tests, one Google
Sheets test), confirmed unrelated by inspecting their stack traces.

## Fix: front-end form buttons still rendered the old blue, not violet (v0.144.0)

Real bug found while verifying the Preview & Design screen against
`design_handoff_chargeforms/ChargeForms Embed & Preview.dc.html`: the
Submit button (and every other accent-colored front-end element —
toggle switches, tab underlines, focus rings, commerce totals, shipping
selection) rendered in the OLD blue (`#1a7efb`), not the violet
(`#6c4ef5`) used everywhere in the admin UI since the design bundle
migration. `assets/css/frontend.css` — the actual visitor-facing form
stylesheet — never got the blue-to-violet token update the admin CSS
did. This affected **every real front-end form on every site running
this plugin**, not just an internal screen.

- `assets/css/frontend.css` — `--fc-accent`/`-dark`/`-soft` updated to
  match `admin.css`'s `--fc-primary-500`/`600`/`50` exactly (`#6c4ef5`/
  `#5638d8`/`#f2effe`); two stale hardcoded blue fallback values
  (`var(--fc-accent-soft, #e8f2ff)`) also corrected.
- Verified live on both surfaces: the admin Preview & Design screen
  (`admin-post.php?action=chargeforms_preview_form`) AND a real
  shortcode-embedded form on an actual published page — both now render
  the correct violet Submit button.

## Design pass: Tools > Overview dashboard (v0.143.0)

Closes the biggest remaining structural gap from the earlier design
audit — the design's Tools screen is a single scrolling dashboard, the
existing implementation was a 7-item sidebar of full separate pages.
Rather than replacing the working sidebar navigation (which would break
every existing deep link into it — e.g. Entries' "Import Entries"
kebab-menu link, Payments' notices), added the design's dashboard as a
new **"Overview"** section (now the default landing view for Tools) that
aggregates: a 3-card grid linking to Export/Import Forms/Import Entries,
the existing Migrator source-detection grid, condensed 5-row Activity
Logs + API Logs summaries side by side (each with a "View all" link to
the real full page), and the existing System Status card — every one of
these reuses the real underlying data/query methods rather than
duplicating them, and every existing full section keeps working exactly
as before at its own `?section=` URL.

- `admin/class-tools-page.php` — new `render_overview()`,
  `render_activity_log_summary()`, `render_api_log_summary()`; `overview`
  added to `SECTIONS`/nav/icons/descriptions and made the default when no
  `?section=` is given (was `export-forms`, still fully reachable at its
  own URL).
- New `.chargeforms-tools-overview*`/`.chargeforms-tool-card-head-row`/
  `.chargeforms-api-log-summary-list` CSS.
- Verified live: Overview renders real data (correct Migrator detection,
  real Activity/API log entries); every pre-existing deep link
  (`export-forms`, `import-entries`, `activity-logs`, `api-logs`,
  `notifications`) re-verified still loads with zero fatals/regressions.
  PHP suite 58/58, `npm run test:unit` 50/50, `npm run build` clean.

## Design pass: License "What Pro adds" card, per-form Settings header (v0.142.0)

Continuing the screen-by-screen audit against
`design_handoff_chargeforms/ChargeForms Screens.dc.html`.

- `admin/class-license-page.php` — added the design's "What Pro adds"
  card: an honest, itemized list of the site's actual current
  `Settings::has_pro()` gates (CSV Entry Import, Industry Pack
  Templates, Save & Resume Button, Multiple SMTP Connections), sourced
  directly from `the internal Pro-feature research doc`'s own status table
  plus the SMTP gate added after that doc was last updated — every entry
  corresponds to a real gate in the codebase, not aspirational copy.
- `admin/class-form-settings-page.php` — added the design's "← Forms"
  back link and subtitle ("Per-form overrides. Leave a field blank to
  fall back to the Global Settings default.") to the per-form Settings
  header.
- Verified live (Playwright); PHP suite 58/58, `npm run test:unit`
  50/50, `npm run build` clean.

## Design pass: subtitle line on Integrations, Support, Add a New Form (v0.141.0)

Continuing the screen-by-screen audit against
`design_handoff_chargeforms/ChargeForms Screens.dc.html` (same pattern
as v0.138.0's Entries/Payments pass). Checked Reports (already close —
tabs, KPI grid, chart, all structurally aligned, no changes needed) and
Tools (needs a real single-page-dashboard rebuild — a much bigger
information-architecture change than a subtitle fix, deliberately not
attempted in this pass; noted as remaining work) before landing on three
screens with the same low-risk, high-value gap Entries/Payments had: a
missing subtitle line under the `<h1>`.

- New shared `.chargeforms-page-title-block`/`.chargeforms-page-subtitle`
  CSS (Entries/Payments got their own bespoke classes in the earlier
  pass; this is the reusable version for screens touched from here on).
- `admin/class-integrations-page.php` — subtitle "9 working integrations,
  all free..." — verified accurate by counting this file's own card list
  (Webhook, Email Notifications, Slack, Discord, Telegram, Notion,
  Mailchimp, HubSpot, Google Sheets = 9), not copied blind from the
  design mockup.
- `admin/class-support-page.php` — subtitle added; the page's actual
  content (Get Started/Troubleshooting lists + User Guides/Contact panels)
  is more specific and useful than the design's generic placeholder
  copy, so left as-is rather than restructured to match the mockup's
  exact 2-card-grid layout.
- `admin/class-new-form-page.php` — subtitle added alongside the existing
  "Import Form" button; title text ("Create A New Form") kept as-is
  rather than renamed to the design's "Add a New Form" — the page already
  has 6 real start options (New Blank/Template/Industry Packs/
  Conversational/Post Form/AI) vs. the design's 4 generic placeholders,
  a real product decision made after the mockup, not a regression to
  paper over.
- Verified live (Playwright) on all three screens; PHP suite 58/58,
  `npm run test:unit` 50/50, `npm run build` clean.

**Remaining screens not yet checked against this design bundle**: Tools
(needs the larger IA rebuild noted above), License, per-form Settings,
Entry Detail, and — from the other two design docs, not `Screens.dc.html`
— the Preview & Design screen, front-end visitor rendering, and the
Elementor widget. None of these were touched in this pass.

## SMTP Email Delivery — production-quality hardening pass (v0.140.0)

A dedicated audit of the SMTP feature shipped in v0.139.0 — not assuming
it was correct because its own tests passed. Full findings in
`docs/SMTP_PRODUCTION_AUDIT.md` (races, UX, accessibility, responsive,
duplicate logic) and `docs/SMTP_SECURITY_AUDIT.md` (a 20-point security
checklist, each verified with real evidence: a live request — including
a genuinely created, logged-in Subscriber-role account attempting every
mutating endpoint and getting a real 403 with zero state change — or a
passing automated test, never code-inspection-only). No architecture
changes; `wp_mail()` remains the primary API, `phpmailer_init` remains
the one transport hook, `Smtp_Transport`/encryption/Free-Pro enforcement
all unchanged in shape.

**3 real bugs found and fixed:**
- `Email_Log` was logging metadata about **every** WordPress email on
  the site (password resets, other plugins' mail) from the moment the
  plugin activated, even with SMTP never configured — a real scope
  overreach for a feature meant to be entirely opt-in. Now gated on an
  active connection actually existing (or an explicit test send).
- The failure classifier's `tls_failure` category was unreachable for a
  common real message shape — the broader `connection_refused` pattern
  matched first and always won, so a TLS-configuration problem got the
  wrong-port suggestion instead of the correct wrong-encryption one.
  Patterns reordered so specific categories are checked before the
  generic catch-all.
- A TOCTOU race in the Free-tier connection limit: `create()`'s gate is
  now re-checked immediately before the write, narrowing (not
  eliminating — a wp_options-backed store has no row-level lock, and a
  real fix would need a bigger storage change than a hardening pass
  should make) the window between the check and the write.

**Accessibility fixes** (Phase 9's own checklist, verified against real
rendered DOM, not just code review): every form label now has a proper
`for`/`id` pairing (previously none did); the Pro-upgrade modal gained a
real focus trap, Escape-to-close, and `role="dialog"`/`aria-modal`/
`aria-labelledby`; per-row "Edit"/"Delete"/"Make Default" buttons in the
multi-connection table gained per-row `screen-reader-text` disambiguation
(previously several identically-named buttons with no way to tell which
connection each acted on).

**Responsive**: live-verified (Playwright, real browser) at all 5
required breakpoints — 1440/1280/1024/768/390px. Found and fixed a real,
pre-existing bug in the *shared* Global Settings sidebar
(`.chargeforms-settings-nav`, used by every settings section) — a
classic CSS Grid `min-width: auto` trap meant the sidebar refused to
shrink below its own content's width at 390px, forcing the whole page
638px wide inside a 390px viewport. One-line fix (`min-width: 0` on the
existing `≤782px` breakpoint), benefits every Global Settings section,
not just Email Delivery.

**UI additions**: connection cards/table now show Provider and Encryption
columns; the summary card shows real "Last tested"/"Last successful
test"/"Last failure" lines (only when actually stored — never
fabricated); the Email Logs table gained a Subject column and is now
click-/keyboard-expandable per row for full send detail (Recipient,
Sender, Subject, Connection, Provider, Timestamp, Status, Failure
category, safe technical detail) — matching the spec's exact requested
shape.

**New provider**: Postmark added as a 9th preset (data-only, same
pattern as the other 8 — no OAuth, no bespoke logic).

**Tests**: 3 new files, 123 new checks (`smtp-production-hardening` — 9,
covering all 3 fixed bugs; `smtp-provider-presets` — 110, covering every
provider including the new Postmark entry and an explicit "no provider
claims OAuth" check; `smtp-uninstall-behavior` — 6, confirming the
opt-in gate genuinely precedes the destructive cleanup in file order).
Full suite: 58/58 files clean (the one pre-existing, unrelated OpenSSL
environment issue noted in every prior entry that's touched it is
unaffected and excluded from that count). `npm run test:unit` 50/50,
`npm run build` clean. PHPCS still unavailable in this sandbox (composer
absent — the same pre-existing gap `PROJECT_STATUS.md` already
documents, not new to this pass).

**Live security verification, not just unit tests**: created a real
WordPress Subscriber-role user and, logged in as that user with a real
session/nonce, attempted `GET`/`POST /smtp/connections`, `GET
/smtp/logs`, `GET /smtp/providers`, `DELETE`, `POST .../default`, `POST
.../test`, and `PUT` (attempting to rename a real connection to
"HACKED") against the live REST API — every single one returned a real
`403 rest_forbidden`, and the targeted connection was confirmed
unchanged in the database afterward.

## Native SMTP Email Delivery — Free (1 connection) / Pro (multiple) (v0.139.0)

A real, production email-delivery subsystem, not a settings form —
architecture documented up front in `docs/SMTP_ARCHITECTURE.md` before
any code was written, then implemented and verified end-to-end. See that
doc for the full design rationale; this entry is the shipped summary.

**Architecture**: hooks WordPress's own `phpmailer_init` action — the
same integration point every real SMTP plugin uses — so every existing
`wp_mail()` call site (owner notifications, visitor confirmations,
double opt-in, Email Summaries, Save & Resume, integration-failure
alerts, and any OTHER plugin's mail, including WordPress core's own)
gets configured transport with zero changes to how any of them build
their message. No SMTP configured = zero behavior change from before
this feature existed.

**Free/Pro**: exactly 1 SMTP connection free, 2+ requires
`Settings::has_pro()` — enforced at the data layer
(`Smtp_Connection::create()`), not just hidden in the UI. Verified live:
a direct, unauthenticated-bypass-style REST `POST` for a second
connection on a Free site returns a real structured `403
chargeforms_smtp_upgrade_required`, not a client-side-only block.

**New files** (`includes/email/`): `class-credential-cipher.php`
(AES-256-GCM at rest, keyed from `wp_salt('auth')`, fails closed),
`class-smtp-connection.php` (the connection data model — a dedicated
`chargeforms_smtp_connections` option, not a table; see the doc for why),
`class-smtp-provider-registry.php` (8 provider presets — Other/Gmail/
Microsoft 365/Amazon SES/SendGrid/Mailgun/Brevo/Zoho — data only, no
provider-specific logic, no OAuth claimed), `class-smtp-transport.php`
(the `phpmailer_init` hook itself), `class-email-test.php` ("Send Test
Email" — a REAL send through the same transport, honest PHPMailer
error-string classification into 9 categories, 5-per-10-minutes
throttle), `class-email-log.php` (new `chargeforms_email_log` table,
populated from `wp_mail_succeeded`/`wp_mail_failed`, self-pruning
retention), `class-email-manager.php` (REST routes + wiring).

**REST** (`chargeforms/v1/smtp/...`): `GET|POST /connections`,
`PUT|DELETE /connections/{id}`, `POST /connections/{id}/default`,
`POST /connections/{id}/test`, `POST /test-unsaved` (test before
saving), `GET /providers`, `GET /logs` — all gated on the existing
`chargeforms_manage_forms` capability, same as every other admin REST
route. Verified live: unauthenticated request → real `401
rest_forbidden`; response bodies never contain `password_encrypted` or
any ciphertext, only a computed `password_configured: bool`.

**Admin UI**: new "Email Delivery" section under Global Settings > Email
(`assets/js/email-delivery.js`, vanilla JS against the REST routes,
matching the existing Integrations page's own Test-Connection pattern —
no new framework for one screen). Masked password display + "Change
Password", provider-preset autofill, live from-domain-vs-site-domain
deliverability hint, structured test-result UI (success with response
time; failure with a human category + expandable technical detail,
never a bare "SMTP Error"), and an Email Logs table with status
filter/search.

**Security review performed**: capability + REST-nonce gated (no second
license check invented), CRLF/header-injection rejected in
host/from_name/reply_to (test-covered), hostname validated by shape only
— never connect-and-scan, never blocks a legitimate host/IP/`localhost`
— SMTP config itself is capability-gated so form submissions can never
reach it (the real SSRF mitigation here). Credential never appears in a
REST response, in logs (defense-in-depth `redact()` on top of PHPMailer
not normally echoing them back), or in this changelog's own commit diff
(verified via `git diff | grep password`).

**Tests** — 7 new self-contained `tests/*.test.php` files (this
project's existing no-PHPUnit convention), 119 individual checks, all
passing: `smtp-credential-cipher` (11), `smtp-connection-crud` (24),
`smtp-connection-validation` (27), `smtp-error-classification` (28,
including a real bug this pass found and fixed — the `timeout` pattern
was matching PHPMailer's own `timeout=300` config-logging string, not
just an actual timeout failure), `smtp-test-throttle` (9),
`smtp-email-log` (7), `smtp-rest-permissions` (13). Full suite: 55/56
files clean (`google-sheets-client.test.php`'s failure is the
pre-existing, unrelated local OpenSSL environment issue noted in earlier
entries). `npm run test:unit` 50/50, `npm run build` clean.

**Honest limitations** (not silently dropped — see
`docs/SMTP_ARCHITECTURE.md` §11/§12 for the full list): no 5-step setup
wizard (the single-page screen covers configure→save→test without one);
no OAuth for Gmail/Microsoft (manual SMTP + app-password guidance only,
as the spec required); no form-specific/conditional SMTP routing (the
data model is shaped to support it later — `Form::settings` could carry
a `smtp_connection_id` — but nothing reads it yet); no automated test
against a REAL external SMTP server (none available in this sandbox —
manually verified end-to-end against a live-but-invalid host during
development, which produced a genuine classified failure, correct
logging, and correct UI state; the classifier/logging/masking/throttling
logic itself is what the 119 automated checks cover). PHPCS was not run
(composer unavailable in this sandbox, same pre-existing gap
`PROJECT_STATUS.md` already documents).

## Entries + Payments: add the design's missing subtitle + KPI stat-card row (v0.138.0)

Auditing the per-form Entries screen and the Payments screen against
`design_handoff_chargeforms/ChargeForms Screens.dc.html` found both
missing a structural element the design specifies: a subtitle line under
the `<h1>`, and a 4-tile KPI stat-card row between the header and the
toolbar/table. Neither had ever been checked against this design bundle
before — everything up to this point had only ever been verified against
the old the reference product UI direction.

- `includes/class-entry.php` — new `Entry::count_starred_for_form()`
  (favorited-entry count for one form, excluding trashed), mirroring the
  existing `count_trashed_for_form()` pattern.
- `admin/class-entries-page.php`'s `render_form_entries()` — added the
  subtitle (`"{form title} · {total} entries · {unread} unread"`) and a
  4-tile stat row (Total Entries, Unread, Favorited, Today) using real
  counts already available or one cheap query away — no fabricated
  percent-change deltas, since the design mockup's `{{ s.delta }}` slot
  doesn't specify what the number should mean.
- `admin/class-payments-page.php`'s `render()` — same subtitle + stat-row
  treatment (Total Transactions, This Month, Stripe Revenue, PayPal
  Revenue). Stripe and PayPal revenue are kept as SEPARATE tiles, not
  summed — `Entry::payment_total()`'s own doc comment warns against
  combining different-currency totals into one meaningless figure.
- `assets/css/admin.css` — new `.chargeforms-entries-stats`/
  `.chargeforms-entries-stat-card` styles matching the design's exact
  card treatment (shadow, radius, uppercase label, big value), reused by
  both screens rather than duplicated.
- Verified live via Playwright screenshots on both screens; PHP suite
  47/48 passing (the one failure is the same pre-existing, unrelated
  OpenSSL environment issue noted in prior entries).

## Phase 4: design-accurate multi-select action bar — Group in Container, Hide (v0.137.0)

Pixel/behavior-verifying Phase 4's overlay screens against the design
bundle surfaced a real gap, not just a cosmetic one:
design_handoff_chargeforms's Interactions doc specifies "Shift-click
fields → dark action bar: Duplicate, Group in Container, Hide, Delete"
for a multi-selection, but the existing `FloatingToolbar.js` showed the
same Copy/Paste/Duplicate/Settings/Delete bar for both single- and
multi-select, with no way to group a selection into a container or hide
it from visitors.

- New reducer capability: `GROUP_FIELDS` (wraps same-parent sibling
  fields in a fresh `container`, in original order, at the earliest
  selected field's position — no-ops for a cross-container selection
  rather than guessing a reparent) and `SET_FIELDS_HIDDEN` (batch
  `isHidden` toggle, one undo step for the whole selection, same
  batching reasoning as the existing `REMOVE_FIELDS`/`DUPLICATE_FIELDS`).
  `core/store/actions.js` / `builderReducer.js`.
- `BuilderProvider.js` exposes `groupSelection()`/`hideSelection()`
  alongside the existing `duplicateSelection()`/`deleteSelection()`.
- `FloatingToolbar.js` now branches on selection size: a single field
  keeps the existing Copy/Paste/Duplicate/Settings/Delete bar; a
  multi-selection (`count > 1`) swaps to the design's own 4-action set
  — Duplicate, Group in Container (disabled with an explanatory title
  when the selection spans more than one parent), Hide/Unhide (a smart
  toggle — reads "Unhide" once every selected field is already hidden),
  Delete — on a visually distinct darker bar (`.is-multi`, `#1e293b`)
  so a Shift-click selection reads as its own mode.
- Verified live via Playwright: added two sibling fields, Shift-selected
  both, confirmed the dark 4-action bar renders with the right icons,
  clicked Hide (both fields show the hidden-field flag), then Group in
  Container (both fields end up nested inside one new Container node, in
  their original order, auto-selected afterward). `npm run build` and
  `npm run test:unit` (50/50) both clean.

## Design-fidelity pass: Global Settings rebuild, Reports fixes, Builder canvas corrections (v0.150.0)

A systematic screen-by-screen pass reconciling the plugin against
`design_handoff_chargeforms/*.dc.html` (the sole design authority per
project convention), following a phased build order (tokens → admin
shell → screens → Builder). Found and fixed a recurring root-cause bug
across multiple screens: several earlier passes had styled components
from "what the reference product's real source does" instead of the .dc.html
mockups — the wrong authority per this project's own rule. Every fix
below traces back either to that pattern or to a genuine content/feature
gap found by direct comparison.

**Design tokens** (`assets/css/admin.css`): reconciled the token block
against the R2 spec — added `--fc-primary-tint-2`, `--fc-ink`,
`--fc-faint`, `--fc-canvas-bg-alt`, `--fc-warning-soft-alt`; reverted
`--fc-primary-600` (accent-hover) to `#5a3ee0`.

**Admin shell**: fixed the topbar active-tab styling (border-radius,
padding, and text color — was using the darker hover shade instead of
the base accent, the first instance of the "wrong authority" pattern
found this pass).

**Reports**: rebuilt the Completion Rate widget (circular gauge → linear
bar, matching the mockup), restored the category-header icon chips,
corrected Key Metrics to the mockup's actual set (Submissions/Form
Views/Conversion/Revenue — was showing an unrelated set), fixed the
horizontal-bar-chart track/fill spec across Country/Device/Browser
breakdowns, and fixed the Submission Timeline heatmap's color family
(was blue, mockup is the brand violet scale).

**Payments**: fixed gateway badges to solid brand-color fills (were soft
tints, and PayPal's color was wrong), added a color chip for the
"Succeeded" status (previously unstyled), and styled the transactions
table (was bare default WP list-table markup) — the stat-card row this
pass also added turned out to duplicate the one already shipped in
v0.138.0, so that duplicate was dropped during the rebase in favor of
the existing one.

**Global Settings — every subtab converted from raw unstyled
`.form-table` markup to the mockup's real card design**: Layout,
Validation Messages, Style Template, Miscellaneous, Payment Settings,
Payment Methods (also restructured from tab-switched to stacked cards
with live Connected/Not-configured status and a working webhook-copy
button), reCAPTCHA/hCaptcha/Turnstile/CleanTalk, Integration Failure,
Double Opt-in, Permissions, Save & Resume, MCP Server, Address
Autocomplete, Configure Integrations. Also: Email Summaries gained the
missing "What gets sent" preview card and a working "Send me a test"
button (new `admin_post_chargeforms_send_test_summary` action); added
the global default for Asterisk Position, which previously only existed
as a per-form override with no site-wide default to fall back to.

**Builder**: fixed the selected-field style (was a flat gray wash with a
left-border accent; mockup spec is a full violet border + tinted
background + soft halo), the floating context toolbar's background/
radius (was medium gray, mockup is near-black), the settings-panel tabs
(was a solid-fill segmented control, mockup is underline tabs),
field-settings label styling, and the settings-panel accordion (was
individual white cards with gaps; mockup is a flat hairline-divided
list) — including a real unrelated bug caught in the process (the
disclosure caret rotated 90° instead of 180° when a section opened).
Restored the empty-canvas onboarding state (icon, headline, description,
two working CTAs), which a prior pass had reduced to a bare dashed box.

Not yet covered by this pass: Front-end rendering states, Preview &
Design, the Elementor widget, and one flagged-but-unfixed gap on New
Form (template cards have no category-color tag).

## Phase 4 (stage 1): migrate builder field-type icons from Dashicons to Lucide (v0.115.0)

The design bundle specifies Lucide 0.460.0 everywhere; the builder used
Dashicons throughout. Given the scope (60+ field types plus every other
icon in the plugin), this is being done in verified stages rather than
one blind pass — this is stage 1: field-type icons only, the most
visible, most explicitly specified area (design's "18px palette icons").

- Installed `lucide-react@0.460.0` pinned exact (not latest) to match the
  design bundle's own version.
- `assets/src/builder/fields/lucideIcons.js` (new) — a `type -> Lucide
  icon name` map built directly from design_handoff_chargeforms's own
  `ChargeForms Builder.dc.html` FIELDS array, joined by label (matches
  our labels verbatim for every type the design covers); the few types
  the design doesn't name (row/column/tab_pane/accordion_panel — all
  paletteHidden internals) get a reasonable icon picked to match their
  visible parent type. Found the design's own "shield-alt" name for
  hCaptcha doesn't exist in real Lucide 0.460.0 (verified against the
  installed package) — substituted "shield-question", the closest real
  icon to the intended meaning, and left a comment explaining why.
- `assets/src/builder/shared/icon/Icon.js` (new) — renders a Lucide icon
  by kebab-case name via an explicit ~75-icon lookup table (not
  `import * as` from lucide-react, which would defeat webpack's
  tree-shaking on a computed-name lookup and pull in the whole ~1500-icon
  library). Bundle grew ~20KB (313→332KB) for the whole icon set.
- Wired into every field-type icon render site: the palette
  (`FieldBrowser.js`, shared by the Input Fields tab and Quick-Add), the
  canvas field preview label (`CanvasField.js`), the drag-ghost overlay
  (`App.js`), and the standalone-checkbox preview (`FieldPreview.js`).

Not in this stage — left as Dashicons, different icon categories the
field-type map doesn't cover and the design doesn't specify a mapping
for: settings-panel section headers (`FieldSettings.js`), the
Desktop/Tablet/Mobile device switcher, and toast notification icons.
Also out of scope: every dashicon in the ~15 PHP-rendered admin screens
(topbar, tables, buttons) — a separate, larger stage.

Verified against a live rendered screenshot (palette grid + a placed,
selected field, both showing real Lucide glyphs), the Jest suite
(50/50), and the PHP suite (same one pre-existing failure).

## Phase 3: Global Settings — reorganize into the design's exact 5 groups (v0.114.0)

`admin/class-settings-page.php`'s sidebar nav mixed Email- and
Advanced-category items into "General" and left the rest as an ungrouped
flat list — didn't match design_handoff_chargeforms/README.md's explicit
spec: General (Layout, Validation Messages, Style Template, Miscellaneous)
· Payments (2 items) · Security (4 items) · Email (Email Summaries,
Integration Failure, Double Opt-in) · Advanced (Permissions, Save &
Resume, MCP Server, Address Autocomplete, Configure Integrations).

Split `GENERAL_SECTIONS` down to its 4 real General items and added
`EMAIL_SECTIONS`/`ADVANCED_SECTIONS` constants for the other two groups,
pulling `email-summaries`/`integration-failure`/`double-optin` and
`permissions`/`save-resume`/`mcp`/`address-autocomplete`/
`configure-integrations` out of their old homes. `License` stays
deliberately ungrouped — the design's own screen map lists it as a
separate top-level admin screen, not a Settings-group member, so it's
left as the standalone flat item it already visually was rather than
folded into "Advanced". `render_nav_group()` needed no changes — its
active-section auto-expand logic is already generic per group.

Verified live at both the General landing view and by navigating into
the new Email and Advanced groups directly; confirmed the existing
per-section render methods (`render_email_summaries()` etc.) didn't need
touching, only their nav placement did. PHP suite: same one pre-existing,
environment-only failure as every prior check this session.

## Phase 2/4 design-bundle pass: verified existing screens, added selected-field type badge (v0.113.0)

Visually verified Forms list, Entries, Reports, and the builder shell
against `design_handoff_chargeforms/` at 1440px (live screenshots, not
just reading source) — found these already match the new design closely:
row actions (Edit/Settings/Entries/Preview/Duplicate/Export/Find/Delete),
the shortcode-chip copy+1400ms-flash behavior, pagination copy, the Filter
popover with date presets, the Find-usage modal, and the builder's topbar/
device-toggle/Input-Fields-Customize-History panel structure. No changes
needed there — this was verification, not a rebuild.

One real, concrete gap found and fixed: selected (non-container) canvas
fields had no type-name badge. `assets/src/builder/features/canvas/
CanvasField.js` — added a badge for any selected field, using each type's
real label from the field registry (matching the palette's own naming).
`assets/src/builder/style.css` — `.fc-canvas-field-selected-badge`, a
solid-accent pill at `top: -11px; left: 8px`, per the design's spec.
Distinct from the pre-existing `.fc-canvas-field-type-badge` (the
always-visible container identity badge in the opposite corner) — the two
are mutually exclusive per field, never both render.

Verified against a live rendered screenshot, the Jest suite (50/50 pass),
and the PHP suite (same one pre-existing, environment-only failure).

## Design bundle handoff + Phase 1 token/shell reconciliation (v0.112.0)

A new, user-authored design system (`design_handoff_chargeforms/` — README,
build-order prompt, and 5 interactive `.dc.html` reference screens covering
all 12 admin screens, the builder + overlays, front end, and Preview/
Elementor) supersedes the reference product-blue design direction from earlier in this
project's history. Confirmed explicitly: the new design's violet accent
(`#6c4ef5`) is correct and current, not the blue (`#1a7efb`) an earlier
session had converted the whole plugin to.

Phase 1 of the bundle's own build-order prompt (tokens + admin shell):
- `assets/css/admin.css` — `--fc-primary-*` ramp and related shadow/focus
  tokens rewritten from blue to violet per the design's token table. Found
  and fixed 9 additional hardcoded blue `rgba(26,126,251,…)` /
  `rgba(21,101,201,…)` literals (decimal form, missed by an earlier
  hex-only grep) in the brand mark, buttons, and hover states.
- Rebuilt the admin-shell responsive collapse to the design's exact
  three-step sequence (≤1180px hide the Ctrl-K hint, ≤1120px hide the
  Upgrade button's label, ≤1060px hide the whole action group + wordmark
  and tighten tab padding), replacing the prior horizontal-scroll-only
  approach.
- Recolored the WP-core left sidebar's active "ChargeForms" menu item and
  submenu row to the brand violet, matching the design mockup's admin
  shell exactly — previously left as WP's default blue, unnoticed because
  no design reference existed to check it against until now.
- Fixed the local dev environment's plugin directory link: it turned out
  to be a stale physical copy (Windows `ln -s` silently falls back to
  copying without symlink privileges), not a live link — every prior CSS
  edit this session was invisible in the browser until this was found and
  replaced with a real `mklink /J` junction.

Verified against a live rendered screenshot at 1440px and 1040px (not just
CSS source), and against the PHP test suite (same one pre-existing,
environment-only failure as before this change — nothing new broke).

## Priority 2, screen 2 — Entries: real the reference product-source comparison (v0.68.0)

Read the reference product's actual `AllEntries.vue` component end-to-end
(`resources/assets/admin/AllEntries/Components/AllEntries.vue` in the
cloned `the reference product's own public repository` repo).

**What the reference product does**: pagination layout is `total, sizes, prev, pager,
next, jumper` — a full numbered pager with page-number links, not just
prev/current/next.

**What FormCraft had**: pagination showed only `‹ [current] of [total]
page-count ›` — a prev/next-only control with no way to jump directly
to an arbitrary page or see neighboring pages at a glance.

**Real bug investigated and ruled out**: the daily-submissions bar
chart's date labels (`.chargeforms-bar-label`) appeared visually
garbled in Playwright screenshots — illegible fragments instead of
"Aug 4", "Aug 5", etc. Investigated thoroughly rather than assumed:
confirmed via `getBoundingClientRect()` that the label sits fully
inside its column's box with zero overflow (`labelBottom` exactly
equals `colBottom`); confirmed `overflow: visible` on every ancestor
in the chain; confirmed no animation-timing race (garbling persisted
at a 1.5s wait, far past the bar's 0.5s grow-in animation); confirmed
it wasn't the bar's `animation`/`transform` compositing (garbling
persisted with the animation disabled entirely); and confirmed it
wasn't a screenshot-downscaling artifact (garbling persisted in a
near-native-resolution crop). The decisive test: bumping the label's
font-size to 28px made the text render perfectly legible ("Aug 4",
"Aug 5"), which only makes sense if the underlying markup and layout
were correct all along and the garbling was a headless-Chromium
small-text rendering limitation in this specific automated test
environment — not something a real user's browser would show. Left
`.chargeforms-bar-label`'s CSS unchanged rather than modifying working
code to chase an artifact of the test tooling.

**Real functionality added**: numbered pager (`render_pager_numbers()`
in `admin/class-entries-page.php`) — shows first page, last page, and
the current page's immediate neighbors, with an ellipsis for gaps
(matching the common truncated-pager pattern, since the reference product's own
`el-pagination` does the same for large page counts). Added a "Go to
page" number-input jumper next to it, matching the reference product's `jumper` layout
option. Both preserve every existing filter/search/per-page query arg
via the same `$base_args` pattern the prev/next links already used.

**Verified, not assumed**: real interaction test via Playwright driving
the live Playground instance against a 25-entry dataset (10/page) —
clicked page number "2", confirmed the current-page indicator updates
and exactly 10 rows render; used the jumper to go to page 3, confirmed
the indicator updates and the correct final 5 rows render. Checked raw
HTML for PHP warnings/notices/fatals — none. Screenshotted the pager at
a 480px mobile width — wraps cleanly onto its own row with no overflow
and every control (prev, page numbers, next, jumper) stays reachable
and tappable. Zero console errors across every interaction tested.

## Priority 2, screen 1 — Forms List: real the reference product-source comparison (v0.67.0)

First screen done under the full benchmark → compare → implement →
screenshot → functional test → responsive test → console-check loop
(Priority 1's global tokens are done; this is the first Priority 2
screen). A real screenshot capability was set up this round —
Playwright + Chromium, driving the actual running WordPress Playground
instance — since prior sessions had no way to visually verify anything
rendered. Also read the reference product's actual `AllForms.vue` component
end-to-end (`resources/assets/admin/views/AllForms.vue` in the cloned
`the reference product's own public repository` repo), not just its screenshots.

**What the reference product does**: single type-filter select (All/Active/Inactive/
Payment/Post/Conversational/Multi-Step) + Search + a "Filter" button
that reveals an advanced date-range popover; a table with hover-reveal
row-actions (Edit/Settings/Entries/Preview/Duplicate/Export/Find/
Share/Delete) and an inline status switch living IN the row-actions
area, not a separate column; Entries column shows "unread / total"
with an info tooltip; a small $ icon next to payment-collecting forms'
titles; Views + Conversion% columns; rich pagination
(total/sizes/prev/pager/next/**jumper**).

**What FormCraft had**: 4 separate filter selects (status/sort/
category/date — more granular than the reference product's single type-select, kept
as an intentional difference, not a gap); a persistent, always-visible
action-button column instead of hover-reveal (also kept — better
touch-device/keyboard discoverability, a legitimate UX tradeoff, not
a regression); Entries column showed only a bare total; no payment
indicator; pagination had no jumper; no Views/Conversion tracking
(flagged below as a product decision, not implemented).

**Real bugs found and fixed** (via source comparison + actual
screenshots + DOM inspection, not visual guessing):

1. **Category filter showed a generic "Select" instead of "All
   Categories".** Root cause: the `category` ref defaulted to `''`
   (empty string) matching an `<el-option value="">`, which Element
   Plus fails to display correctly (a known empty-string-value gotcha
   — falls back to its own generic placeholder). Confirmed by noting
   `status`'s equivalent select, which uses a real `'all'` sentinel,
   displayed correctly right next to it. Fixed by switching `category`
   to the same `'all'` sentinel pattern, translating it back to `''`
   only at the REST-call boundary (where the API's own `enum: ['',
   'multistep']` still expects empty string).
2. **A real, previously-invisible responsive bug**, found only by
   taking an actual 1024px screenshot (not assumed from the desktop
   layout, per the explicit instruction not to): the entire row-actions
   column vanished at 1024px, and Entries/Status/Updated/Actions all
   vanished at mobile width — with no scrollbar, no way to reach them
   at all. Root cause, found by checking `scrollWidth` vs `clientWidth`
   in the actual DOM rather than guessing: `el-table` with
   `style="width: 100%"` and no `min-width` doesn't overflow when its
   columns don't fit — it silently compresses/drops content instead.
   Fixed by wrapping the table in an `overflow-x: auto` container AND
   giving the table itself an explicit `min-width: 1100px` (matching
   the real sum of its column widths) so it has genuine content to
   scroll to. Verified programmatically (`scrollWidth: 1100 >
   clientWidth: 832` at 1024px) and by screenshotting the scrolled
   state, confirming Edit/Duplicate/More/Delete are actually reachable.

**Real functionality added** (not faked visually): "unread / total"
entries column — required a real backend change
(`Entry::count_unread_by_form()`, one GROUP BY query mirroring the
existing `count_by_form()` pattern, wired through `Form::query()` and
`RestApi::query_forms()`), not just a template change, since the data
literally didn't exist in the API response before. Same for the
payment `$` indicator — reused `Commerce_Calculator::
find_payment_method_field()` (already existed for a different purpose)
rather than duplicating field-walk logic. Added a pagination "jumper"
(go-to-page input), matching the reference product's `layout` prop exactly.

**Verified, not assumed**: real interaction tests via Playwright driving
the live Playground instance — clicked the status toggle and confirmed
the PATCH request returns 200 and the switch's checked-state class
actually flips; typed a non-matching search term and confirmed the
empty state renders with a working "Clear filters" button that,
verified via `waitForResponse` (not a blind timeout), actually
restores all 10 forms. One initial false-positive from my own test
script (a `waitForTimeout` that was too short, made "Clear filters"
look broken when it wasn't) was caught and corrected before being
reported here — see the difference between the two test runs in this
commit's development history if useful context. Zero console errors
across every interaction tested. Both bundles rebuild clean.

**Flagged as a product decision, not implemented**: the reference product's Views and
Conversion% columns require view-tracking on every front-end form
render, which FormCraft doesn't currently do at all — this is a real
missing capability question (does the product want to track/store a
view count per form, with the privacy/performance tradeoffs that
implies?), not a design implementation, so it wasn't built.

**Deliberately kept different, not a gap**: hover-reveal row-actions
(the reference product) vs. always-visible action buttons (FormCraft) — the always-
visible version is arguably better for touch devices and keyboard
navigation, where hover-only actions are either unreachable or
undiscoverable. FormCraft's 4-select filter bar vs. the reference product's single
type-select + separate date-filter popover — FormCraft's is more
granular (independent status/sort/category/date filters vs. one
combined type enum), which is more UI surface but also more directly
useful; not changed.

**Continuing systematically to the next Priority 2 screen (Entries)
without stopping for confirmation**, per standing instruction — only
the Views/Conversion tracking question above requires input.

## Global design tokens corrected to exact the reference product source values (v0.66.0)

Per explicit direction: study the reference product's actual GitHub source
(the reference product's own public repository) directly rather than working from screenshots
or memory, before touching any more CSS. Cloned the repo (hit a
Windows `git checkout` path-length failure on first attempt —
`app/Services/.../element-icons.woff` etc. exceeded 260 chars; fixed
with `git config core.longpaths true` and a short clone destination)
and read `resources/assets/admin/styles/_element_ui_var.scss`,
`_variables.scss`, `_globals.scss`, and `_card.scss` directly.

**Confirmed and corrected against exact sourced values** — the
existing blue palette (from the v0.65.0 direction reversal) was
described in its own commit message as "interim... matched from
screenshot observation," and several of those approximations were
measurably off from the real tokens:

| Token | Was (interim) | Now (sourced, exact) | Source |
|---|---|---|---|
| `--fc-primary-500` | `#1a73e8` | `#1a7efb` | `$--color-primary` |
| `--fc-primary-600` (hover) | `#1558b0` | `#1565c9` | `$primary-button-hover-bg` |
| `--fc-danger` | `#e5484d` | `#ff6154` | `$--color-danger` |
| `--fc-warning` | `#f0a020` | `#fcbe2d` | `$--color-warning` |
| `--fc-success` | `#16a672` | `#00b27f` | `$--color-success` |
| `--fc-info` | `#2f8fe0` (invented sky-blue) | `#4b4c4d` (dark neutral gray) | `$--color-info` — real the reference product info is NOT blue |
| `--fc-border` | `#dfe3ea` | `#dadbdd` | `$--border-color-base` |
| `--fc-bg` | `#f5f7fa` | `#f2f2f2` | `$body-bg` |
| `--fc-text` | `#1c2126` | `#1e1f21` | `$base-color` |
| `--fc-muted` | `#5f6b7a` | `#606266` | `$--color-text-regular` |
| `--fc-muted-light` | `#948fa3` (violet-tinted) | `#909399` | `$--color-text-secondary` |
| `--fc-radius-md` | `6px` | `7px` | `$--border-radius-base` |
| `--fc-radius-lg` | `10px` | `8px` | `$border-radius` (cards/dialogs) |
| `--fc-card-shadow` | invented multi-layer "premium lift" | `0 2px 3px rgba(129,129,129,.1)` | `.ff_card` (`_card.scss`) |
| `--fc-shadow-lg` | invented | the reference product's real `.ff_advanced_search` popover shadow, verbatim | `_globals.scss` |
| `--fc-gradient-primary` | an actual gradient | flat solid color (token kept for compat, no longer resolves to a gradient) | the reference product's `.el-button--primary` is flat, not gradient |

**Button hover, corrected to match real behavior**: the reference product's primary
button hover is a flat solid-color swap
(`$primary-button-hover-bg: #1565C9`) with no gradient and no lift —
the previous implementation had both a gradient-on-hover and a
`translateY(-1px)` lift, neither with any basis in the real source.
Both removed.

**Real, previously-invisible leftover-color bugs found and fixed**
while doing this — same class of bug found and fixed multiple times
already this session (a literal hex/rgba surviving a token-level
color change because it was never wired to the token in the first
place): stale `rgba(21, 88, 176, …)` (the OLD interim primary-600)
box-shadow literals in `admin.css`; stale `rgba(108, 78, 245, …)`
(the pre-reversal *violet* primary — meaning these specific literals
had survived not one but TWO subsequent palette changes untouched)
also in `admin.css`; `rgba(26, 115, 232, …)` in the builder's
`style.css` (the OLD less-precise blue, `#1a73e8`, now stale against
the corrected `#1a7efb`); and matching stale hex fallback values in
the Vue Forms List's `scope.css`/`style.css` (`var(--fc-primary,
#6c4ef5)` — a violet fallback that predates even the blue reversal).
All corrected to the new exact decimal/hex values.

`.chargeforms-list-card` (the shared Entries/Forms-list table
container) was also corrected to match the reference product's actual
`.ff_card_border` table-container variant exactly: a 1px border, zero
shadow, 8px radius — previously had neither a border nor the correct
radius.

Verified: both bundles (`npm run build`, `npm run vue:build`) rebuilt
clean; re-logged into a fresh WordPress Playground instance (the
previous one had died between sessions) and confirmed all 7 major
admin screens return HTTP 200; fetched the actually-served `admin.css`
directly and confirmed the corrected token values are present verbatim
in production output, not just in source.

**Scope note**: this is Priority 1 (global design language) from the
feature-parity benchmark work — global tokens are now exact-sourced.
Priority 2 (screen-by-screen structural/component parity — Entries,
Reports, Payments, Settings, Integrations) and Priority 3 (builder
component-level parity: field cards, floating toolbar, properties
panel) are still in progress, continuing systematically rather than
stopping here.

## Builder shell: pixel-calibrated topbar height, tighter panel gutters (v0.65.1)

Follow-up to v0.65.0. Found a reliable calibration anchor in the
reference screenshots: `the internal reference-screenshot archive/
builder-user-interface--04.webp`'s topbar bottom border sits at a
consistent y=92-93px across two independent x-samples (pixel-scanned,
not eyeballed) in that 2560px-wide capture. At a standard 2x retina
doc-screenshot capture, that's ~46px CSS — our topbar measured 71px
before this pass. Cut `.fc-topbar` vertical padding 14px → 6px (the
40px-tall Save button/icon-buttons are the height floor, not shrunk
themselves) — now measures 55px on a live render, verified via
`getBoundingClientRect`, not CSS source. Also cut `.fc-builder-grid`
and per-panel padding 16px → 12px, closer to the reference product's flush-to-border
panels without going fully flush (no reference evidence for literal 0).
Re-ran `tests/regression.playwright.js`: same pre-existing failure as
before this session, confirmed not a new regression.

## Builder shell: flatten elevation, cut panel padding, plain underline tabs (v0.65.0)

First visual-parity pass against the reference product reference library
(`the internal reference-screenshot archive/`) now that `docs/DESIGN_GUIDELINES.md`'s
freeze is lifted. Scope: builder shell only (topbar, canvas/palette/
settings panel chrome) — no component logic, reducer, or field
behavior touched.

- `assets/src/builder/shared/styles/tokens.css` — flattened
  `--fcb-shadow-card`/`--fcb-shadow-card-hover`/`--fcb-shadow-panel`
  from a multi-layer ambient "premium lift" to a 1px definition edge
  (the reference product's reference panels carry no ambient drop shadow at rest).
  `--fcb-radius-card` 14px → 8px.
- `assets/src/builder/style.css` — `.fc-builder-grid` gap/padding
  28px → 16px; `.fc-canvas`/`.fc-palette`/`.fc-settings` panel padding
  28px → 16px; `.fc-topbar-tabs` rebuilt from a segmented-pill control
  to a plain underline-tab row (blue text + 2px underline on active,
  no pill background), matching the reference screenshots' Editor/
  Settings & Integrations/Entries tab treatment.
- Found and fixed 12 leftover hardcoded `rgba(108, 78, 245, …)`
  literals (the pre-rename violet primary) scattered through
  `style.css` — button shadows, canvas grid-paper tint, container
  hover backgrounds — that survived the earlier token-only color pass
  because they were raw literals, not references to `--fc-primary`.
  All now `rgba(26, 115, 232, …)`, matching the current blue primary.
- Verified via `tests/regression.playwright.js` that a pre-existing
  failure (`NESTED_LAYOUT_CREATED: false`, a drag-handle click
  intercepted by `.fc-canvas-dropline`) reproduces identically on
  unmodified `main` — confirmed not caused by this change before
  reporting it.

## Rename: FormCraft → ChargeForms (v0.64.0)

Full technical rename, explicitly confirmed in scope before starting
(not just the display name): PHP namespace (`ChargeForms\`), the
translation text domain, PHP constants (`CHARGEFORMS_VERSION` etc.),
the database table/option name prefix, the REST API namespace
(`chargeforms/v1`), the `[chargeforms id="X"]` shortcode tag, every
CSS class across admin/builder/frontend (`chargeforms-*`), JS/Vue
config object names (`window.ChargeFormsConfig`, etc.), and every
admin menu/page slug (`page=chargeforms`, `page=chargeforms-entries`,
...).

**Scoped deliberately, not blindly**: the physical git directory
(`plugin/formcraft/`) and the main plugin bootstrap file
(`formcraft.php`) were left unchanged — WordPress doesn't require
either to match the plugin's display name, and renaming them would
have meant rewriting the 3 GitHub Actions workflow files' path
filters and the release workflow's hardcoded `formcraft.php` version-
read step, for zero user-visible benefit and real risk of breaking
CI. Also deliberately did not touch the internal `fc-`/`fcb-` CSS
class-name abbreviation used throughout the builder's ~3275-line
stylesheet (`.fc-canvas-field`, `.fc-palette-item`, etc.) — purely
internal implementation detail invisible to any user, and blindly
renaming a bare 2-3 character prefix across thousands of occurrences
carried real mechanical risk for zero visible benefit. Also left the
external `GitHub Plugin URI: UzairRehman4/saasform` header untouched —
that points at the actual GitHub repository, which was not asked to be
renamed.

**Executed as a scripted, case-aware global replace** (`FormCraft` →
`ChargeForms`, `formcraft` → `chargeforms`, `FORMCRAFT` →
`CHARGEFORMS`) across the 174 files that referenced the old name, not
by hand — the scale (spanning namespaces, constants, CSS, JS, Vue,
tests, and docs simultaneously) made a manual per-file pass both
impractical and more error-prone than one consistent scripted pass.

**Verified, not assumed correct**, before considering this done:
- Confirmed zero leftover `formcraft`/`FormCraft`/`Formcraft`
  references anywhere in source after the replace.
- Confirmed the autoloader, activation hooks, and every `\ChargeForms\`
  class reference in `formcraft.php` are internally consistent (the
  namespace prefix check, the `use`/instantiation calls, and the
  `chargeforms_boot()` function name all agree).
- Confirmed the DB table prefix (`wp_chargeforms_forms`) and the
  shortcode registration (`add_shortcode('chargeforms', ...)`) both
  picked up the rename correctly.
- Rebuilt both bundles (`npm run build`, `npm run vue:build`) clean.
- Live-verified against the running Playground instance: all 7 renamed
  admin page slugs return HTTP 200 with no PHP fatals; the new REST
  namespace (`chargeforms/v1/forms`) responds correctly while the old
  one (`formcraft/v1/forms`) now correctly 404s.
- A pre-flight brace-balance check flagged 3 PHP files as "unbalanced"
  — traced each back to the SAME pre-existing (not rename-introduced)
  false positive via `git show` against the pre-rename version, rather
  than assuming the rename was the cause.

**New brand mark**: replaced the checkmark-in-frame glyph (menu icon +
in-page topbar mark) with a lightning-bolt motif — "forms with payment
built in" (Stripe/PayPal, Payment Item/Custom Amount fields) is this
plugin's actual differentiator, so the icon now leads with that instead
of a generic form/checkmark shape that could belong to any form
builder.

**Known consequence, not a bug**: any already-published page using the
old `[formcraft id="X"]` shortcode tag will stop rendering that form
until updated to `[chargeforms id="X"]` — an inherent, one-way
consequence of renaming the shortcode tag itself, flagged in the
readme changelog rather than silently left for someone to discover.

## Forms list Element Plus theming fix, Preview & Design revert (v0.63.0)

The Forms list "home screen" (`admin/class-forms-list-page.php`'s Vue
render mode, `assets/src/vue-admin/forms-list/`) turned out to be a
completely different code path than every other screen touched this
session — it's built on Element Plus, a third-party Vue component
library, not ChargeForms's own PHP-rendered `.chargeforms-wrap` chrome.

**Real bug found**: `scope.css`'s own comment claimed "Element Plus's
components are themed via CSS custom properties... re-scoping those to
our mount point... keeps the theme local," but no `--el-*` variable was
ever actually defined anywhere in the codebase. Every button, select,
switch, and table stripe on this screen was rendering in Element Plus's
stock default blue, entirely disconnected from ChargeForms's violet
identity — the plugin's own landing screen was, by pure omission, the
one screen that never got the palette redesign at all. Fixed by
pointing `--el-color-primary`/`-success`/`-warning`/`-danger`/`-info`
and the border-radius tokens at the real `--fc-*` values; confirmed
`all: initial` on the mount point does NOT strip custom properties (an
explicit exclusion in the CSS spec), so this element — a descendant of
`.chargeforms-wrap` — inherits them cleanly with no extra wiring needed.

Also found and fixed several more hardcoded, disconnected literal
colors in this same screen's own stylesheet (`forms-list/style.css`):
a form-title link color (`#1565c9`, a THIRD, unrelated blue — not even
the old ChargeForms brand blue, just an ad-hoc pick from whoever built
this Vue screen originally) and a few muted grays, all pointed at the
matching `--fc-*` tokens.

**Reverted per explicit feedback**: the in-app "Preview & Design"
device-frame modal added in v0.59.0 was tried and rejected — restored
to opening the live form in a new browser tab, its prior behavior.
Removed the now-unused `PreviewModal.js` component and its ~120 lines
of CSS rather than leaving it as dead code.

## Per-form Settings & Integrations: from one giant page to tabs/cards (v0.62.0)

User follow-up: "fix cards tabs inside inner things." Traced this to the
per-form "Settings & Integrations" screen (`class-form-settings-page.php`,
reached from Forms > row > Settings) — unlike the Global Settings screen
(which already has a left-nav + card layout), this one was a single,
~500-line PHP method rendering 8 `<h2>` sections (Confirmation, Double
Opt-in, Mailchimp, Slack, HubSpot, Google Sheets, Post Fields,
Restrictions) back to back in one giant `<table class="form-table">`
wall, no tabs, no cards, no navigation at all.

**Restructured into 4 tabs** (Confirmation, Integrations, Post Fields,
Restrictions — grouping the four CRM integrations under one tab rather
than giving each its own top-level tab, since they're one coherent
"where does a submission also go" concept), each section wrapped in the
same `.chargeforms-card` component the rest of the plugin already uses,
navigated via the exact `.chargeforms-settings-shell`/
`.chargeforms-settings-nav` left-nav component the Global Settings page
already has — reused, not reinvented.

**Found and fixed a real, serious bug this restructuring would have
introduced if shipped naively**: `handle_save()` rebuilt the ENTIRE
settings blob from `$_POST` on every save, and `Form::update()`
replaces the `settings` column wholesale (no DB-level merge) — safe
only because the old single-page form always submitted every field
together. Splitting into tabs means only the ACTIVE tab's fields are
ever posted; saving one tab under the old handler logic would have
silently blanked out every other tab's settings. Rewrote `handle_save()`
to start from the form's EXISTING settings and only overwrite the
group whose tab was actually submitted (gated on a new hidden
`chargeforms_settings_tab` field) — the same "only touch what was
actually submitted" discipline `Settings::update()` already uses
site-wide via its own field whitelist, applied here for the first time.

**Verified this specific risk was actually closed, not just reasoned
about**: saved the Confirmation tab with a distinctive marker value,
then the Integrations tab with a different marker, then the
Restrictions tab with a third — three separate HTTP requests, three
different tabs — then fetched the form via the REST API and confirmed
all three markers were present simultaneously in the stored settings.
Also confirmed all four tabs return HTTP 200 with no PHP errors.

## Fix a real icon-semantic bug: "Global" label placement used the gear icon (v0.61.0)

User follow-up: "fixed layouts toggle icons everything" — re-audited the
toggle switches (Locked/Hidden/Required/Read Only/Disabled) and the
Label Placement icon button group in `FieldSettings.js` for further
concrete, source-verifiable issues, the same discipline as the last two
rounds.

Toggles: already correctly built as real CSS switches (`appearance:
none` + a track/thumb from `::before`), already reading `var(--fc-accent)`
for their checked state — no bug, already correct.

Label Placement: found a real one. The "Global" option (use the site's
default label placement rather than overriding it per-field) used
`dashicons-admin-generic` — WordPress's own gear/settings icon, the
same glyph used for the core Settings menu. A gear reads as "configure
this," not "inherit the default" — a genuine semantic mismatch, not a
style nitpick. Swapped to `dashicons-admin-site-alt3` (a globe), which
is the conventional icon for "site-wide/global" in WordPress's own
admin UI. Checked every other icon mapping in the same file
(`SettingsSection`'s title icons: General/edit, Appearance/admin-
appearance, Validation/yes-alt, Conditional Logic/randomize, Advanced
Options/admin-tools, Accessibility/universal-access-alt, Developer/
editor-code) — all semantically correct, no further changes needed.

Verified live: rebuilt, confirmed the new icon name is present in the
served bundle, builder screen still returns HTTP 200.

## Fix a real discoverability bug: the "+" insert button was invisible at rest (v0.60.0)

Continued the source-level comparison against the reference product
reference material already supplied this session (the build spec and
several screenshots) — this time looking specifically for concrete,
verifiable-from-source gaps rather than subjective "does this look
clean enough" judgment calls, since the latter needs an actual render
to answer honestly and this environment still has no way to produce
one.

Found one: every the reference product screenshot shows the canvas's "+" add-field
button as a permanently visible control (one screenshot literally
labels it "Add In Place" with an arrow pointing at it). ChargeForms's
equivalent (`.fc-insert-btn` in `InsertPoint.js`/`style.css`) was
`opacity: 0` at rest — completely invisible until the row it belongs to
is hovered. That's not a style preference difference, it's a real
discoverability gap: a first-time user scanning the canvas has no way
to know they can click between two fields to insert a new one, unless
they already know to hover there first.

Fixed by changing the resting state to `opacity: 0.55` (a deliberately
muted, low-contrast gray so it doesn't compete with actual form
content) instead of fully hidden, with the existing hover/focus/active
states — full brand-accent color, scale-up — untouched, so there's
still a clear "this is now interactive" signal on interaction.

Also re-checked two other patterns from the reference material against
ChargeForms's actual source rather than assuming: the Floating Toolbar
(dark rounded pill, 4 action icons, centered above the selected field)
already matches this pattern exactly — confirmed via
`FloatingToolbar.js`'s positioning logic and its CSS (`background:
#14161f`, `border-radius: 12px`, centered via `translate(-50%, …)`), no
change needed there.

Verified live: builder still returns HTTP 200 against the running
Playground instance after rebuild.

## Real "Preview & Design" screen: device-frame modal, own design (v0.59.0)

The user shared reference screenshots of the reference product's dedicated Preview
& Design screen — a distraction-free view with a browser-chrome mockup
and a Desktop/Tablet/Mobile viewport switcher — and asked for the same
capability, explicitly as UX-pattern reference only: no Pro/upsell
panel, no paywalled integrations grid (that entire side of the reference product's
screen doesn't apply — ChargeForms has no paid tier), ChargeForms's own
violet design throughout.

Before building, checked what "Preview & Design" already did: the
button just opened `previewBaseUrl` (a real, already-correct,
already-tested admin-post endpoint that renders the live form) in a new
browser tab. Rather than re-implementing form rendering a second time
in React (real drift risk — two renderers of the same form schema could
disagree), the new `PreviewModal.js` component reuses that exact same
endpoint inside an `<iframe>`, wrapped in a device-frame shell whose
width toggles between the same three breakpoints (100%/768px/390px)
the canvas's own responsive-preview switcher already uses — but as its
own independent local state, not the shared `ui.device` store value, so
closing the preview never leaves the live editor canvas resized to
whatever width you last checked.

Verified end-to-end against the real endpoint before considering this
done, not just "the button opens something":
- `X-Frame-Options: SAMEORIGIN` / `frame-ancestors 'self'` on the
  preview endpoint's response — confirmed same-origin (both served from
  the same wp-admin install), so the iframe isn't silently blocked.
- Fetched the endpoint directly and confirmed it renders the actual
  form's real fields (not a blank page or an error).
- **Found and fixed a live bug while wiring this up**: my first attempt
  to hit the builder page URL used `?form=<id>`, which returned an
  empty `previewBaseUrl` — the real query param `class-menu.php` reads
  is `form_id`, not `form`. Caught by inspecting the actually-served
  config object rather than assuming the URL shape.
- Rebuilt (`npm run build`, clean) and confirmed the new component's
  code is present in the served bundle and the builder screen still
  returns HTTP 200.

## Declutter the canvas: fields carry chrome only on hover/selection (v0.58.0)

Direct user feedback plus five real screenshots of the reference product's own
builder pinpointed the actual source of "this looks cluttered" — not
colors or icons (those were already addressed), but that every single
field on ChargeForms's canvas carried a permanent 1px border, a
`--fcb-shadow-card` box-shadow, and a white background, all the time,
regardless of whether it was selected. the reference product's fields carry none of
that at rest — just a label and an input sitting directly on the plain
canvas; a bordered/tinted container only appears once you hover or
select a field.

`.fc-canvas-field`'s resting state now matches: `border: 1px solid
transparent` (kept, not removed — a transparent border with the same
width avoids a 1px layout jump when hover/active later sets a real
border color), `box-shadow: none`, `background: none`, padding trimmed
from `22px` to `14px` vertical, margin between fields tightened from
`14px` to `4px` (closer to the reference product's tighter default field rhythm).
`:hover` and `.is-active` already had their own explicit
background/border/shadow declarations from before this change — those
were untouched, so the "you're working on this field" feedback still
looks and behaves exactly as it did; only the always-on resting chrome
was removed.

Also checked the Field Palette's "Soon" badge (unbuilt field types,
e.g. Featured Image/Categories) isn't a disguised Pro-tier paywall
(the earlier build spec supplied for reference included the reference product's
lock-icon/"Upgrade to PRO" gating pattern) — confirmed it's an honest
"not built yet" label with no fake-upgrade dead end, consistent with
ChargeForms having no paid tier at all. Nothing changed here since it
was already correct.

Verified live: the builder screen still returns HTTP 200 against the
same running Playground instance after the rebuild.

## Form builder: fixed a real color-token bug, converted leftover old-hue tints (v0.57.0)

First pass at the user's follow-up request to also revisit the form
builder's colors/icons/spacing. Started with a grep-based literal-color
audit of `assets/src/builder/style.css` (~3275 lines) — the same method
that found the previous two rounds' real bugs — before touching
anything cosmetic, since the builder can't be visually inspected in
this environment (no browser/screenshot tool) and grep-found bugs are
independently verifiable without one.

- **Real bug**: 14 rules referenced `var(--fc-ink)` (some with a
  fallback like `var(--fc-ink, #1e1f21)`, most with none at all) — but
  `--fc-ink` is never defined anywhere in the codebase; the actual
  defined token is `--fcb-ink` (builder-scoped) or the shared
  `--fc-text`. Every one of these rules was either stuck on its stale
  fallback hex regardless of any palette change, or (for the ones with
  no fallback) resolving to an invalid custom-property reference. Fixed
  by pointing all 14 at `--fc-text`, the token every other screen
  already uses for primary text color.
- **A whole family of leftover old-blue-hue literals**, all sharing the
  same base color (`#1684fc` / `rgb(22, 132, 252)`) that a much earlier
  session had already fixed at "the actual point of truth" for the
  brand accent itself (`.chargeforms-app-root`'s `--fc-accent` derivation)
  — but never converted these DERIVED literal tints, which don't read
  from that token at all: the canvas dot-grid background, container-
  nesting shading (3 different opacity weights for container/row/leaf
  depth — preserved exactly, only the base hue changed), the selection-
  ring shadow, and five solid hex border/background tints
  (`#cfe0f5`/`#b7d9ff`/`#dbe7f7`/`#d7e4f5`/`#eef4fc`). All converted to
  the new violet primary, each solid hex remapped to a violet tint at
  the same relative lightness/weight so the existing "5 different
  visual weights for 5 different roles" design (documented in
  `docs/DESIGN_SYSTEM.md`'s own prior audit) stays intact — this was a
  hue correction, not a redesign of that system.
- Also swept the remaining literal `#d63638` (WP-core red)/`#909399`/
  `#c0c4cc`/`#fafbfd`/`#1e1f21` occurrences onto their real semantic
  tokens (`--fc-danger`/`--fc-muted`/`--fc-muted-light`/`--fc-bg`/
  `--fc-text`) — same category of bug as the admin.css sweep two
  releases ago, just not yet done for the builder's own stylesheet.

**Deliberately not touched this pass**: the builder's icon language.
It uses WordPress Dashicons throughout — hundreds of references across
palette items, canvas field previews, the toolbar, settings panels, and
condition/logic editors, not a handful. `docs/DESIGN_SYSTEM.md` already
flags full icon-set replacement as its own large, separate undertaking
(real GPL/bundle-size considerations, and a half-converted icon set
would be a worse inconsistency than the current uniform Dashicons
usage). Spacing/alignment likewise wasn't touched blind this round —
without a way to see the actual rendered builder, changing layout
dimensions carries real regression risk that a color-token fix (100%
groundable in source) doesn't. Verified live: the builder screen loads
clean (HTTP 200) against the same running Playground instance, and the
served, rebuilt CSS bundle was confirmed to contain zero remaining
occurrences of the old blue hue and the undefined-variable bug.

## Card, icon, and chart polish from direct visual feedback (v0.56.0)

The v0.55.0 palette change shipped without any way to actually see the
rendered result (no browser/screenshot tool in that session). The user
then supplied real screenshots of the live site, which surfaced concrete
problems the code-level verification couldn't have caught:

- **Entries' 14-day activity chart looked broken with little/no data** —
  each day rendered as a bare "0" sitting over a ~2px-tall colored
  sliver (`max(2, round(count/max*100))%` height), which at zero data
  reads as a rendering glitch, not an empty chart. Fixed two ways: (1)
  every `.chargeforms-bar-col` now has a permanent, visible track behind
  it (`::before`, full height, `--fc-bg` fill) so even a near-zero bar
  sits inside a bounded plotting area instead of floating on blank
  white; (2) when literally all 14 days are zero, the chart is replaced
  entirely with the same empty-state treatment used elsewhere in the
  plugin, rather than rendering 14 columns of "0".
- **Entries stat tiles used raw Dashicons** (`dashicons-list-view`,
  `dashicons-email`, `dashicons-visibility`, `dashicons-calendar-alt`)
  while Reports' equivalent tiles already had a considered custom SVG
  icon set — an inconsistency between two screens showing the same kind
  of data. Gave Entries its own small icon set in the same visual
  language (added a scoped `Entries_Page::icon_svg()`, mirroring
  `Reports_Page`'s own private copy rather than inventing a shared
  cross-class helper).
- **Semantic color mistakes on the same tiles**: "Read" used a plain
  gray chip (`is-neutral`) despite being a positive/completed state —
  moved to success-green. "Today" and "Total Entries" were both
  `is-primary` (identical violet chips sitting right next to each
  other for two different metrics) — "Today" moved to a new `is-info`
  variant. Added the missing `.chargeforms-stat-icon.is-success` and
  `.is-info` CSS (only `is-danger`/`is-warning`/`is-neutral` existed
  before).
- **Every stat tile and dashboard card got a real elevation pass**: a
  thin colored top-accent bar per semantic state (via a
  `--fc-stat-accent` custom property, set once per `.is-*` modifier
  rather than duplicating the whole rule), gradient (not flat) icon
  chip tints, a hover lift on the icon itself, and `font-variant-
  numeric: tabular-nums` on stat numbers so they don't visually jitter
  as digits change. Applied the same `.chargeforms-card` hover lift used
  on the new stat tiles.
- **Integrations cards**: icon tile grew from a cramped 34px to 44px
  with a real elevation step, the card header now has a bottom divider
  separating it from the connect-form body, and a connected integration
  gets a quiet success-tinted card border (new `.is-connected` class,
  computed per-module in PHP from each integration's actual saved
  credential) — the card itself now signals "this is live," not just
  the small status pill at the bottom.
- Defined the `--fc-success-soft` token that `.chargeforms-module-status
  .is-on` was already referencing via an undefined-variable CSS
  fallback (`var(--fc-success-soft, #e8f7ef)`) — harmless (the fallback
  always fired) but meant that background silently never picked up
  future palette changes; now it does.

Verified live against the same running WordPress Playground instance
used for the PayPal testing pass — all four touched screens (Entries,
Reports, Integrations, Payments) return HTTP 200 with no PHP errors,
and the new markup (`chargeforms-stat-tile is-info`, `.is-connected`,
etc.) was confirmed present in the actually-served HTML.

## A real, original visual identity — new palette, icons, and chart colors (v0.55.0)

Explicit user feedback: the plugin's look, most visibly its brand-blue
(`#1a73e8`) primary color, read as a the reference product clone rather than
ChargeForms's own identity. This release replaces the color system
plugin-wide (admin screens, the React builder, and the front-end
renderer all share one token set in `assets/css/admin.css`'s
`.chargeforms-wrap` root, so this was a token-level change, not a
per-screen repaint) with an original "Signal Violet" palette, re-derives
every icon/chart color that depended on it, and fixes several places
where the old palette leaked in as a hardcoded literal instead of a
token reference.

**New palette** — primary shifted from sky-blue to a violet
(`#6c4ef5`/`#5636d6`/`#3f24a8`), paired with warm violet-tinted
neutrals (text/muted/border/bg all carry a faint violet undertone now
instead of clinical gray) rather than just swapping the accent hue on
top of the old grayscale. Semantic colors (success/danger/warning) were
also re-picked as part of the same family: jade `#16a672`, coral
`#e5484d`, amber `#f0a020`. `--fc-info` is no longer aliased directly
onto `--fc-primary` — informational and brand-accent were the same
color only because both used to be blue; info now gets its own sky
tone (`#2f8fe0`) so the two meanings stay visually distinct now that
primary is violet. Added a 5-color `--fc-chart-*` categorical scale
(violet/jade/amber/coral/sky) for multi-series charts, derived from one
shared saturation/lightness band so a chart's series read as one
considered palette rather than unrelated hues.

**Reports category accents re-picked, not just re-tinted**: the
existing "purple" category accent (Audience Insights) used to sit
right next to the old blue primary; now that primary itself is violet,
that color would have collided with the brand accent instead of
standing apart from it. Moved to a rose/magenta. Teal, amber, and
indigo were each nudged to keep clear separation from both the new
primary and each other. The New Form screen's "AI" card gradient had
the same problem — its two stops were primary-blue and a violet
(`#7c3aed`) that's now nearly identical to the new primary — replaced
with a violet-to-magenta gradient so it still reads as a distinct
option, not a slightly-off Blank/Template card.

**Icons**: the WP admin sidebar menu icon and the in-page topbar brand
mark both used a generic "three lines" glyph indistinguishable from
any note/list icon. Redrawn as a checkmark-in-a-frame — ties visually
to "a completed, submitted form" instead of being a generic document
icon. (Both are single-color silhouettes recolored via CSS/`currentColor`,
so this was a shape change, not a new color per surface.)

**Real leftover-old-brand-color bugs found and fixed while doing this**
— every one of these was the old blue/red/amber hardcoded as a literal
hex or rgba() instead of referencing the design-system token, meaning
it would have silently kept the old palette even after the token
values above changed: `admin/class-menu.php`'s admin-bar unread badge,
`includes/class-email-summaries.php`'s email template accent color,
`admin/class-integrations-page.php`'s two generic (non-brand) webhook/
email integration icon strokes, `admin/class-forms-list-page.php`'s
device-preview mockup styling, several `rgba(26, 115, 232, …)` shadow
literals across `assets/css/admin.css`, and matching literals in
`assets/css/frontend.css` and `assets/src/builder/style.css` (the
front-end form renderer and the builder both needed the same sweep,
confirmed via grep across the whole plugin rather than assumed fixed
by the token change alone, since a literal never picks up a token
update). A form's Color-field default swatch (`#1a73e8` in
`createField.js`/`FieldPreview.js`/`FieldSettings.js`/
`class-shortcode.php`) was also updated to the new primary for
consistency, though that one is a sane starting default for
user-created forms rather than a design-system leak.

**Verified live**: rebuilt the React builder bundle (`npm run build`,
clean, only pre-existing bundle-size warnings) and reloaded a real
WordPress Playground instance with the plugin mounted directly — Forms,
Entries, Reports, Integrations, and Global Settings all render (HTTP
200, no PHP fatals), and the served `admin.css` was fetched directly
and confirmed to contain the new token values with zero remaining
occurrences of the old blue hex anywhere in the file. No screenshot/
browser-automation tool was available in this environment, so the
actual visual result (layout, contrast, spacing) is unverified in a
real rendered browser — this is a code-level and HTTP-level
verification, not a pixel-level one; flagged here rather than
overclaimed.

## Repeater display bug found by exhaustive live-testing (v0.54.0)

Continued the same live-testing approach as v0.53.0, now covering the
rest of the plugin's surface: multi-step forms with conditional logic
(including a server-side anti-tampering check — submitting a value for
a currently-hidden field via a raw POST and confirming it's dropped),
Repeaters, file uploads, the Reports page's stat accuracy, the Tools
page's every section, the Forms list "Find" feature (including that a request for form #1
correctly does NOT also return a page that only embeds form #3), all four CRM integrations' "Test Connection" against their real
live APIs with intentionally-fake credentials (confirmed each fails
with a real, specific error from the real service — Mailchimp,
HubSpot, Slack, and Google Sheets' JWT signing all actually attempted
a real request or correctly short-circuited before one), the builder's
form-update REST endpoint, and the Entries screen's Advanced Filter.

Found one real, reproducible bug: a Repeater field's submitted values
displayed as the literal string "Array" instead of the actual entered
data, everywhere `Entries_Page::display_value()` is used — the Entries
list, single-entry detail view, print view, and CSV export (all four
already routed through this one shared method, confirmed by grep
before writing the fix, so a single fix covers all four). Root cause:
`array_map( 'strval', $value )` on a Repeater's value — an array of
per-row arrays, not an array of scalars — hits PHP's own "Array to
string conversion" behavior per row. The underlying stored data was
always correct (confirmed via the REST API before touching the fix);
only display was wrong. Fixed by detecting a nested (repeater) shape
up front and joining each row's own fields with ", " and rows with
"; ", while leaving flat arrays (checkboxes/multi-select) exactly as
before — verified fixed live in the same session, no build/version
bump needed to observe it since Playground mounts the plugin directory
directly.

Every other area tested came back clean — including two prior
assumptions of bugs during this same session that turned out to be
this agent's own shell-scripting mistakes (bash's read-only `$GROUPS`
built-in variable colliding with a test script's own variable name,
and a `node -e` argv-index error) rather than anything wrong in
ChargeForms itself, corrected before concluding either was real.

## Two real bugs found by live-testing against WordPress Playground (v0.53.0)

Every prior release this session was verified only via the automated
test suite (real PHP 7.4 execution in CI, but every WordPress core
function — `wp_verify_nonce()`, `get_current_user_id()`, etc. — stubbed
out). This release was verified differently: booted an actual
WordPress instance (WordPress Playground CLI, PHP 7.4, real MySQL-
equivalent SQLite storage) with ChargeForms mounted as a real plugin,
then exercised it end-to-end over real HTTP — created a form via REST,
rendered its shortcode on a real published page, submitted it and
confirmed the entry landed in the database, saved and restored Save &
Resume drafts as both anonymous and logged-in visitors, saved
Integrations settings, and ran the full MCP JSON-RPC protocol
(`initialize` → `tools/list` → a write tool's complete preview → confirm
→ execute cycle), confirming a form it created via `create_form` really
existed afterward in the admin list. This surfaced two real, previously-
undetected bugs neither the unit-test suite nor static review could
have caught:

**1. Save & Resume failed for every logged-in visitor.** WordPress
core's REST layer (`rest_cookie_check_errors()`) silently treats any
REST request as anonymous for nonce-verification purposes unless the
standard `X-WP-Nonce: <wp_rest nonce>` header is attached — even on a
fully public route. The draft save/restore/discard/upload routes never
sent this header, so a logged-in visitor's browser would generate
ChargeForms's own draft nonce as themselves (an ordinary page render,
unaffected by this REST-only rule) but every subsequent draft REST
call would then fail "Security check failed" (verification silently
re-ran as anonymous). Anonymous visitors — the common case — were
never affected, since their nonce was generated and verified in the
same (anonymous) context throughout. Fixed by localizing a `wp_rest`
nonce (`Shortcode::render_form_html()`'s new `restNonce` config value)
and sending it as `X-WP-Nonce` on every `save-resume.js` fetch call.
Verified live: reproduced the exact failure with a logged-in session,
confirmed the fix resolves it, confirmed the anonymous path still
works unchanged.

**2. `CHARGEFORMS_VERSION` had been hardcoded to `'0.46.0'` since the
Stripe Subscriptions milestone** — never updated across six subsequent
header version bumps this session. Found by noticing the MCP
`initialize` response's `serverInfo.version` field was stale.
Real-world impact: `Install::maybe_upgrade()` compares this constant
against a site's stored `chargeforms_db_version` option to decide
whether to re-run `dbDelta()` for schema changes; since both values
already matched, a site that updated ChargeForms in place (without
deactivating/reactivating) from 0.46.0 onward would never have had any
table added since — Save & Resume's `wp_chargeforms_drafts`/
`wp_chargeforms_draft_files`, the subscriptions table, etc. — actually
created. A fresh install was never affected (activation always runs
the full schema directly), which is exactly why this stayed invisible
through every fresh-Playground-install-style test. Fixed at the root:
`CHARGEFORMS_VERSION` is now derived from the plugin header's own
`Version:` field via `get_file_data()` rather than duplicated as a
second literal that can drift out of sync again.

## MCP for AI Agents (v0.52.0)

A JSON-RPC 2.0 Model Context Protocol server at `POST /wp-json/chargeforms/v1/mcp`
(`includes/mcp/`), letting Claude/Cursor/other MCP clients read forms and
entries and run a scoped set of write operations — off by default,
enabled per-site under Global Settings > MCP for AI Agents (mirrors
the reference product's own recently-shipped equivalent feature, per direct user
request).

**Auth & scope**: no separate API key system — a client authenticates
with a standard WordPress Application Password (Basic Auth), which WP
core resolves to a real WP user before ChargeForms's route callback ever
runs. Every tool call executes with exactly that user's own
`chargeforms_manage_forms` capability — ChargeForms has no per-form
ownership model today, so this is honestly "the connected user can
manage ChargeForms at all," not finer per-form ACL, and the settings copy
says so rather than overclaiming.

**Preview-then-confirm**: every write tool (create_form,
update_form_fields, update_submission_status, bulk_update_submission_
status, delete_submission, add_submission_note) is defined as a
`preview` callable (read-only) and an `execute` callable — a call with
no `confirm_token` only previews and returns a fresh token; the
mechanics live entirely in `Mcp_Server::call_tool()`, not duplicated
per tool. Tokens (`Mcp_Confirm`) are stateless HMACs over
(tool name, exact arguments, calling user id, expiry) — the same
"hash the exact operation, verify by re-hashing" shape `Draft_Token`
and `SubmissionHandler::optin_token()` already use elsewhere in this
codebase — so a token can't be replayed against different arguments or
redeemed by a different user than the one it was issued to.

**Tools** (9 read, 6 write) all call ChargeForms's own existing Form/
Entry/Activity_Log/Integration_Manager data-layer methods — this is a
new transport onto existing capabilities, not a second parallel data
layer: get_forms_context, list_forms, get_form, list_submissions,
get_submission, get_form_stats, list_integrations,
get_payment_summary, get_activity_log, create_form,
update_form_fields, update_submission_status,
bulk_update_submission_status, delete_submission, add_submission_note.

Also in this release: the admin-menu icon is now a real ChargeForms
glyph instead of the generic dashicons-feedback speech bubble, and the
Integrations screen's module icons were resized down to a smaller
bordered-square style closer to the reference product's own Modules grid,
replacing the larger color-filled badges from v0.51.0's redesign.

Adds three test files: mcp-confirm (token issue/verify, tampered
args, wrong user, expiry), mcp-server (full JSON-RPC dispatch against
a stubbed tool registry — disabled-site gate, auth, unknown method/
tool, the full preview→confirm→execute round trip, cross-user token
rejection), mcp-tools (representative read tools + every write tool's
preview/execute pair against in-memory Form/Entry stand-ins).

## Integrations screen redesign (v0.51.0)

Per explicit user feedback ("the ui and ux design is not good like
the reference product have... use official icons logos and responsiveness"),
redesigned `admin/class-integrations-page.php`'s card grid:

- Each card now leads with a real brand icon badge instead of a
  generic gray dashicon — Slack's badge uses its actual published
  four-color mark (real path data, real brand hex colors
  #E01E5A/#36C5F0/#2EB67D/#ECB22E); Mailchimp/HubSpot/Google Sheets use
  each brand's own primary color as the badge background with a
  simplified representative glyph (not a pixel-exact logo reproduction
  — reproducing Mailchimp's "Freddie" or HubSpot's sprocket exactly
  from memory risked looking wrong, so color + simple recognizable
  shape + the text label together carry brand recognition instead).
- Card layout: icon + title in a proper header row, consistent
  spacing, a hover elevation, and a status pill restyled with a tinted
  background instead of a bare colored dot for better scannability.
- Grid: minmax floor raised (240px → 280px) so cards read to closer
  breathing room, wrapped in an explicit single-column breakpoint below
  782px alongside the toolbar/table breakpoints already established for
  the Forms/Entries screens.

Reverts a prior deliberate "plain icon, no chip" restraint decision
noted in this same CSS block from an earlier milestone — explicit,
repeated user direction to match the reference product's visual density
overrides that earlier call.

## Slack, HubSpot, Google Sheets integrations (v0.50.0)

Every CRM/SaaS card on the Integrations screen is now built — no more
"Coming Soon" placeholders. All three follow the same
Abstract_Integration/Integration_Manager architecture as Webhook and
Mailchimp, with WP-Cron retry on failure and every attempt logged to
Tools > API Logs.

**Slack** — posts to an Incoming Webhook URL (site-wide default, or a
per-form override). Structurally identical to Webhook_Integration
(fire-and-forget POST, http_api_debug logging, retry), with its own
Slack-shaped payload: an auto-generated field-by-field summary, or a
custom message using `{field_id}` placeholders.

**HubSpot** — authenticated with a Private App access token (HubSpot's
own simpler alternative to OAuth for a single-account integration, not
the deprecated API key). Upserts a contact via the CRM v3 batch/upsert
endpoint (idempotent by email), setting only HubSpot's built-in
firstname/lastname/email properties — writing to a custom property that
doesn't already exist in the target portal would fail, so mapping
beyond these three is out of scope for now.

**Google Sheets** — authenticated with a Google Service Account JSON
key (signed JWT exchanged for a bearer token, real RS256 signing via
`openssl_sign()`) rather than interactive OAuth, since a self-hosted
WordPress plugin has no trusted redirect target for a shared OAuth
client. The site owner shares the target spreadsheet with the service
account's own email, same as sharing a Doc with any other person. Rows
append via the Sheets API v4's own next-empty-row detection; a header
row (submitted field labels) is written automatically the first time a
form submits to a previously-empty sheet.

Extracted `Contact_Field_Mapper` (email / first-last name lookup) out
of Mailchimp_Integration once Hubspot_Integration needed the identical
logic, so the two CRM integrations share one implementation rather than
two copies that could drift.

Six new test files cover all three clients' request-building/error-
surfacing and all three integrations' field mapping and retry logic —
including a genuine RSA keypair generated at test time so the Google
Sheets JWT signature is verified for real, not mocked.

## Mailchimp integration (v0.49.0)

Built-in CRM/SaaS integration on the existing Abstract_Integration /
Integration_Manager architecture (the same one Webhook_Integration
already used) — `Mailchimp_Integration` subscribes a submitter to a
Mailchimp audience on every submission, with a thin, independently
tested `Mailchimp_Client` API wrapper underneath it
(`includes/integrations/class-mailchimp-client.php`).

Setup: a single site-wide API key connected under ChargeForms >
Integrations (Mailchimp's own account model is one key per account,
same reasoning Stripe/PayPal keys already use), then per-form under
Settings > Mailchimp — enable, pick the audience from a live-loaded
dropdown, optional comma-separated tags, and a double opt-in toggle
(maps to Mailchimp's own `status_if_new: pending` vs. `subscribed`,
unrelated to ChargeForms's own submission-confirmation double opt-in).

Field mapping: the form's first Email-type field supplies the address
(a submission with none is skipped, not treated as an error); its first
Name-type field, if present, is split on the first space into
FNAME/LNAME merge fields — ChargeForms has no dedicated first/last-name
field type today, so this is a practical heuristic, not a placeholder.
Subscribing is idempotent (PUT to the member's own subscriber-hash
resource) so a resubmission never creates a duplicate contact or
downgrades an existing member's status. A failed request retries via
WP-Cron up to 3 attempts, same shape as Webhook_Integration's own retry
logic, and every attempt is recorded in Tools > API Logs.

## Forms list: reference-product-style row actions + Find (v0.48.0)

Restyled the Forms admin list screen (`admin/class-forms-list-page.php`)
to match the classic WordPress list-table convention, per repeated
explicit request: an ID column, the title as the primary link, and a
plain-text `.row-actions` list (Edit | Settings | Entries | Preview |
Duplicate | Export | Find | Delete) revealed on row hover — replacing
the icon-button-plus-overflow-menu design from a prior milestone. The
ShortCode chip now gets its own column instead of living under the
title.

New: **Find**. Clicking it opens a modal (`wp_ajax_chargeforms_find_form_usage`)
that scans site content for the form's shortcode and lists every
page/post that actually embeds it, with a link straight to editing (or
viewing, if published) each one — answering "where is this form used?"
without hand-searching the site. Matching is done in PHP against a
bounded `LIKE '%[chargeforms%'` candidate set from `wp_posts`, then
narrowed with a word-boundary-aware regex per form id (a plain `LIKE
'%id="5"%'` would also false-positive on id="50", id="51", etc.).

## Save & Resume (v0.47.0)

Production infrastructure for saving and resuming any long-running form —
built as a dedicated system (Draft model, storage, service, restoration,
cleanup, tokens), deliberately not bolted onto Entry. Supports manual
"Save Draft", automatic background autosave, resume via a secure emailed
link, logged-in auto-resume with no token at all, multi-page forms, large
forms, file uploads (staged immediately into an isolated uploads
subfolder, promoted to a real entry attachment on final submit), and
payment forms — a Payment Method field's value is never persisted to a
draft under any circumstance.

Security: resume tokens are 256-bit random values (`Draft_Token`), only
their SHA-256 hash is ever stored, verified with `hash_equals()`. Tokens
rotate on manual save and on every resume-email request (never on
background autosave, to avoid breaking a multi-visit editing session).
Every draft REST route is nonce-protected and rate-limited. Drafts use a
sliding expiration capped at 3x the configured TTL from original
creation, so a draft can't be kept alive indefinitely just by autosaving
it. Restoring a draft re-validates its stored values against the
*current* form definition and against conditional logic, dropping
anything stale or currently hidden — the same anti-tampering posture a
real submission gets. Concurrent-edit detection mirrors the existing
`expected_updated_at` precondition pattern already used for form saves.

Shared logic (field-tree flattening, value sanitization, file-upload
validation) was extracted out of `SubmissionHandler` into new
`Field_Sanitizer`/`File_Upload_Validator` classes so drafts and real
submissions run the exact same code, not two copies that could drift.

See this version's commit messages for the full architecture writeup,
database schema, and test coverage.

## Stripe Subscriptions (v0.46.0)

Recurring payments (monthly/yearly, optional free trial, cancellation,
webhook-driven status sync) built on the existing Gateway_Manager/
Commerce_Calculator payment architecture — see this version's commit
messages for the full architecture writeup (Subscription_Gateway_Interface,
the new wp_chargeforms_subscriptions table, Stripe_Webhook_Handler's
signature verification, Subscription_Reconciler's safety-net cron).

**Also in this release: a critical, previously-undetected bug fix.**
`namespace ChargeForms\Public` — used by the front-end shortcode renderer
and the submission handler — is invalid PHP (`public` is a reserved
keyword; a namespace segment can never be one). `git log --follow`
confirms this has been present since the plugin's very first commit
(v0.1.0). It was undetectable by static reading alone and was only
caught because this release added the plugin's first CI job that
actually *executes* `tests/*.test.php` rather than merely having them
exist (see `.github/workflows/tests.yml`, added alongside the
subscriptions work) — the very first real run failed with a plain PHP
parse error on this namespace. Renamed to `ChargeForms\Frontend` (and the
`public/` directory to `frontend/`, via `git mv` to preserve history)
across every reference: `chargeforms.php`'s bootstrap, three admin-page
files, the Elementor widget integration, and four test files.

Two more real (if smaller) bugs were caught the same way, by the same
new CI job actually running for the first time: a docblock comment in
the new webhook handler contained a literal `*/` inside prose
(`wp_remote_*/hash_hmac`), closing the PHP comment early and turning
the rest of the file into a parse error — same bug class as an earlier
CSS-comment incident this project already hit once; and several
pre-existing test files (unrelated to subscriptions) had PHP-8-only
constructs (`str_contains()`, a `mixed` return type) with no 7.4
fallback, plus one missing `require` and two test assertions that were
simply wrong (didn't account for real, correct template whitespace and
real, correct CSV quoting) — all fixed. Every `tests/*.test.php` file
(31 total, 379 assertions) now genuinely passes in CI for the first
time in this project's history.

## Toast + Tooltip Infrastructure (v0.45.0)

Foundational UI primitives, motivated by a real, twice-flagged gap: the
builder had no toast/notification system anywhere and no tooltip system
(icon-only controls relied on inconsistent, unreliably-announced browser
`title` attributes). Built now, ahead of any specific feature that needs
it, so the next validation/blocked-action/icon-only-control has somewhere
real to put feedback. Full design rationale in
`assets/src/builder/ARCHITECTURE.md`'s "Toast + Tooltip Infrastructure"
section.

- New `shared/toast/` (pure reducer + provider/hook + rendered stack) and
  `shared/tooltip/` (pure positioning math + component) modules — no new
  positioning-library dependency, reusing the same hand-rolled
  viewport-rect approach `FloatingToolbar` already established.
- Toasts: capped queue (oldest dropped first), errors default to sticky
  (manual dismiss only — a message worth reading shouldn't vanish before
  it's read), auto-dismiss pauses on hover/focus and resumes with the
  *remaining* time, two `aria-live` regions (polite/assertive) so a
  screen reader isn't interrupted by routine feedback but is for errors.
- Tooltips: shows on hover after a delay and immediately on keyboard
  focus (parity a bare `title` attribute never gave keyboard users),
  `aria-describedby`-wired real DOM content instead of `title`, content
  is always plain text (no `dangerouslySetInnerHTML`) so it can't become
  an XSS vector.
- Wired into real usage immediately, not shipped inert: the builder's
  recoverable-draft banner's Restore/Discard buttons now confirm what
  happened; Toolbar's Undo/Redo/History/Fullscreen buttons and the
  "Autosaved to this browser" hint use the new tooltip.
- First JS unit-test infrastructure in the plugin (`@testing-library/react`
  + Jest via `wp-scripts`' own default preset, `npm run test:unit`) — 50
  tests across 5 files, covering pure logic directly and real timer/focus/
  a11y/XSS-safety behavior via component tests with fake timers.

## PayPal gateway + the reference product UX benchmark fixes (v0.44.0)

Two pieces of work landed together: PayPal as a second payment gateway
(competitive-roadmap #1 finding — Stripe-only payments), and a set of
usability fixes from a the reference product screen-by-screen benchmark (full
findings in `docs/UX_GAP_ANALYSIS.md`).

**PayPal gateway** — one-time payments only, matching Stripe's current
scope:
- New `Commerce_Calculator`, extracted out of `Stripe_Gateway`, is now the
  single shared server-side total-recomputation authority both gateways
  use — the anti-tampering check a payment field's amount is verified
  against.
- New `Paypal_Gateway` (OAuth2 client-credentials token, Orders API v2
  create/capture) and a Gateway selector on the Payment Method field,
  defaulting to Stripe so every existing form keeps working unchanged.
- `SubmissionHandler::verify_payment()` branches by gateway; PayPal's
  capture response is checked for status, form-id round-trip via
  `custom_id`, and the captured amount compared in integer cents against
  a server-side recomputation — same anti-tampering discipline as Stripe.
- Entry meta is additive (`payment_gateway`/`payment_transaction_id`/
  `payment_amount` written alongside the existing Stripe-only keys), so
  `Entry::payment_transactions()` and the Payments admin page show old
  and new transactions with no backfill needed.
- Caught and fixed one real bug pre-release: `SubmissionHandler` called
  `Paypal_Gateway::` without importing it, which would have fatally
  errored on the first real PayPal submission.

**UX fixes** (from the reference product benchmark, real usability problems
only — see `docs/UX_GAP_ANALYSIS.md` for the full findings list including
what was deliberately deferred):
- Entries and Payments' bulk-selection checkboxes previously led nowhere;
  both now have a working "Delete Selected" bulk-action bar.
- The Vue Forms List screen had silently dropped Preview/Settings/Export
  row actions and shortcode-copy versus the classic PHP list — restored,
  fed by new nonce-protected URLs from `RestApi::query_forms()`.
- Payments' Status/Payment Type filters were permanently-disabled
  `<select>`s that read as broken — replaced with a clear static
  indicator plus an explanatory tooltip.
- The builder's Save button now shows a dirty-state dot (driven by the
  undo stack's edit-checkpoint counter vs. the value at last save), and
  autosave is labeled "Autosaved to this browser" since it's
  `localStorage`-only, not a real server-side autosave.

## Builder Product Polish, priorities 6-8 (v0.41.0)

Floating Toolbar, Selected State, and Drag & Drop, treated as one
connected interaction system per the milestone brief rather than three
isolated components.

- Floating toolbar no longer overlaps a field's own insert-point button;
  flips below the field near a viewport edge; Duplicate has its own icon.
- Multi-select ambiguity fixed: the active field driving the settings
  panel now reads clearly as "Primary" against a dashed, lower-contrast
  treatment for the rest of the selection.
- Collision-detection dead zone fixed: `pointerWithin` alone left no
  valid drop target in the gap between fields; now falls back to
  `closestCenter` only when `pointerWithin` finds nothing.
- Custom drag announcements added for screen readers (field labels, not
  generic dnd-kit default text).
- Interaction audit (building a realistic form end-to-end) surfaced that
  every container relied on drag-only to add a field — containers now
  have the same click-to-add "+" the root canvas already had.

## Weekly email summary redesign (v0.40.0)

Replaced the plain-text weekly digest with a professional inline-styled
HTML email (branded header, hero stat, per-form this-week/all-time
table, CTA button, footer) and a CSV attachment of the same data,
reusing `Entry_Exporter::csv_safe_cell()` for formula-injection safety.

Also fixed a real bug found via live cron testing: the report builder
called `wp_tempnam()`, which lives in `wp-admin/includes/file.php` and
isn't autoloaded during WP-Cron execution — every scheduled run would
have fatal-errored in production. Now explicitly required before use.

## Builder Product Polish, priorities 1-5 (v0.39.0)

A structural rebuild of the React builder itself (not the PHP admin
screens), done one surface at a time with before/after verification
and a self-critique after each: Left Sidebar, Top Toolbar, Canvas,
Field Cards, Right Properties Panel.

**Left Sidebar** — the field palette used to live in a tabbed slot on
the right, sharing space with Field Settings/History behind
"Input Fields / Input Customization / History" tabs; selecting a field
unmounted the palette entirely. It's now a permanent left column
(palette | canvas | properties, all visible at once), matching how
every real drag-and-drop form builder is laid out. History moved to a
toolbar popover next to Undo/Redo — no functionality lost. Found and
fixed a real latent keyboard bug this exposed: Undo/Redo never called
`event.preventDefault()`, letting the browser's own native undo hijack
focus and silently swallow the next shortcut.

**Top Toolbar** — Undo/Redo/History, the shortcode chip + Preview, and
Save/Fullscreen now read as three visually separated groups instead of
one continuous row. Consolidated ~170 lines of duplicate CSS. Added
`:focus-visible` rings to every icon button (previously invisible to
keyboard users). Stat pills replaced with quiet middot-separated text.

**Canvas** — the biggest investment. Fixed a real bug where a selected
field's own label rendered partially behind its corner drag-handle
chip (confirmed by measuring actual pixel coordinates: an 8px real
overlap). Fixed a real accessibility bug (`aria-hidden="true"` on a
focusable, interactive drag handle). Row no longer renders with the
exact same dashed-box treatment as its parent Section — true container
shells (Section/Container/Repeater/Tabs/Accordion) now carry an
always-visible identity badge instead. Added an insertion animation
for newly-added fields (respects `prefers-reduced-motion`).

**Field Cards** — every Dropdown field's preview was rendering as a
wall of tiled chevron glyphs instead of a working select arrow (our
CSS overrode only `background-color`, leaving WP core's chevron
background-image to tile at this field's much-wider-than-usual preview
width). Fixed by owning the background outright. Dropdown's icon
changed from a hamburger glyph visually identical to Text Area's icon
to a distinct down-arrow. Emoji (🖼️ 📎 💳 🛡️) replaced with real
Dashicons across every preview. Upload fields rebuilt from dense
technical text into a proper icon + instruction + extension-hint
dropzone. The standalone Checkbox field brought up to the same
icon + bold-label treatment as every sibling card.

**Right Properties Panel** — Conditional Logic (a major feature with
its own Rule Builder UI) was the last item inside a generic "Advanced
Options" catch-all, under Container Class/Element Class overrides.
Promoted to its own top-level section right after Validation. Section
headers gained icons matching the palette/canvas/toolbar's icon-
forward language; the disclosure caret changed from a literal "▸"
character (no `aria-hidden`, so screen readers announced "black
right-pointing small triangle") to a proper Dashicon.

**Holistic review** (requested checkpoint after priority 5, reviewing
the whole builder together rather than one screen at a time) — found
and fixed the same "emoji breaks the icon language" issue in the Rich
Text field's own formatting toolbar (a 🔗 emoji next to plain-glyph
B/I/• buttons).

**Post-release fix**: the Label Placement control's icons (and any
similar icon-button group) rendered a visibly different color than
their own labels due to a WordPress-core style specificity conflict —
fixed with an explicit `currentColor` + centering rule.

Every change verified via WordPress Playground + Playwright across all
three existing regression suites (100% pass throughout), with real
before/after screenshots and honest self-critiques at each step —
published as artifacts during development, not claimed sight-unseen.

## Revert design changes (v0.38.7)

Reverted the Forms list stat-tile dashboard header, card-wrapped table,
and the topbar/tabs polish shipped in v0.38.5/v0.38.6 — direct feedback
was that the new look wasn't wanted. Both files restored to their
pre-design-pass baseline (commit 87b839c). Kept the one genuine bug fix
uncovered along the way: `.chargeforms-col-id::before` was unscoped and
leaked a CSS "#" onto the Forms list table, which already had its own
literal "#" in markup ("##1" instead of "#1") — re-scoped to
`.chargeforms-entries-table`, the only table that actually needs it.

## Forms list dashboard header + card-wrapped table (v0.38.6)

The Forms list is the plugin's default landing screen and previously
opened straight into a bare wp-list-table with no sense of overall
activity. Added a 4-tile summary row (Total Forms, Active Forms, Total
Entries, Entries This Month) above the toolbar, and wrapped the
toolbar/table/pagination in a shared card surface (border/radius/
shadow) instead of sitting directly on the page background.

Also fixed a real, pre-existing visual bug found while verifying this:
`.chargeforms-col-id::before` injected a CSS "#" globally, but only the
Entries table's markup relies on it (its own cells are bare numbers).
The Forms list table reuses the same class with a literal "#" already
in its PHP markup, so the two combined into a header reading "# ID"
and rows reading "##1". Scoped the rule to `.chargeforms-entries-table`.

## Design revamp: topbar/tabs polish + test hygiene fixes (v0.38.5)

Own-skills design pass on the shared admin topbar and tab components
(rendered on all 8 admin screens): a primary-color accent border and
an inset ring + shadow on the active tab, a lift/shadow hover state
for the upgrade button (previously just an opacity dim), a real
pill-shaped search trigger, hover feedback on inactive tab segments
(previously none), and an entrance animation on the underline-tabs
indicator, respecting `prefers-reduced-motion`. CSS-only; verified via
Playwright screenshots across all 8 admin screens (200 responses, zero
PHP errors) and a full regression-suite pass.

While verifying no regressions, found and fixed two stale assumptions
in the Playwright test suites (not product bugs):
- `regression.playwright.js` asserted the autosave/reload flow
  silently restores the previous session's structure. It never has —
  `App.js`'s `restoreDraft()` only fires from the confirm banner's own
  "Restore draft" button, a deliberate UX choice. Fixed the test to
  click the banner before asserting.
- `milestone5-fields.playwright.js` expected all 10 Phase 5C field
  types to be addable via palette click, but Formula's module sets
  `meta.locked = true` (shown as "Soon") until Milestone 8's
  Calculation Engine gives it something real to render. Dropped it
  from the add-loop and corrected the expected count (14 → 13).

## Design pass, slice 12: front-end placeholder bug fix (v0.38.4)

Comparing ChargeForms's rendered front-end form directly against the reference product
Forms' (both with equivalent Name/Email/Message fields, side by side)
surfaced the single largest visual difference found in this whole
design pass: every ChargeForms field rendered with a completely blank
input, while the reference product showed a helpful placeholder in every one.

Root cause: the builder's own canvas preview (`FieldPreview.js`) has
always computed a sensible default placeholder per field type when a
field's own placeholder is blank -- "Your Name", "you@example.com",
"(555) 555-5555" -- purely so the canvas doesn't look like a row of
empty boxes while editing. That fallback never existed in the actual
PHP front-end renderer. A real visitor got a genuinely blank input every
time a form author never explicitly typed a placeholder, silently
diverging from what the form's own builder always showed.

Fixed by adding `Shortcode::default_placeholder_for()`, mirroring
`FieldPreview.js`'s switch statement type-for-type, wired into both
`render_text_like()` and the shared textarea render path. An explicitly
configured placeholder always wins over the default. 7 new tests in
`tests/field-settings-frontend.test.php` (23 total in that file now).

Also decided (not left ambiguous) that the New Form picker's large
feature-card icons and the Reports category header icons stay colored
-- unlike the Field Palette's 60+ repeated icons (slice 11), these two
screens use color to distinguish a handful of genuinely different
options/sections at a glance, closer to real wayfinding than decoration.

Full test suite (24 PHP + 6 JS) and build pass.

## Design pass, slice 11: icon restraint, matching the reference product closely (v0.38.3)

Explicit user direction, overriding this project's default "never copy
competitor visual design" rule for icon/chrome style specifically
(layout and color choices elsewhere stay ChargeForms's own): match the reference product
Forms' icon treatment closely, since direct comparison showed their
icons as plain monochrome outlines with no background, versus
ChargeForms's colored-chip-behind-every-icon default reading as busier.

- Field Palette items and Quick-Add popover items: removed the colored
  chip background entirely; icons are now plain, muted gray by default,
  turning accent-colored on hover.
- Integrations module cards: same treatment.
- Deliberately not touched: the New Form picker's large feature-card
  icons and the Reports category header icons, both of which use color
  to distinguish multiple options at a glance in a way that may be
  load-bearing, and neither of which has a direct the reference product
  screenshot comparison yet to confirm one way or the other.

Full test suite (24 PHP + 6 JS) and build pass.

## Design pass, 10 slices: shared chrome + first real visual verification (v0.38.2)

Ten incremental design/UI slices, moving from mechanical token retrofits
to actual, verified fixes once a local WordPress install with a
side-by-side the reference product install (free plugin, from wordpress.org —
never their source) was set up and screenshotted via Playwright:

1. Builder chrome (topbar, panels, device toggle) → shared tokens.
2. Field Palette search/filters/cards — fixed a real duplicate-CSS
   declaration bug, added a real focus ring and clear button to search.
3. Floating Toolbar icons replaced with custom SVGs (was Dashicons);
   Quick-Add popover gained the entrance animation it was missing.
4. The real top navigation (Forms/Entries/Reports/etc.) and the Ctrl+K
   command palette — the palette had zero entrance animation at all.
5. The shared modal component (every confirm/actions dialog site-wide)
   and search-results list, tokenized and reduced-motion-compliant.
6. Forms list empty-state icons (was a blown-up 40px Dashicon, now a
   proper icon chip) and the Active/Inactive toggle, which had no hover
   feedback at all.
7. The shared `wp-list-table` shell used by every data table in the
   plugin.
8. Stat tiles (Entries + Reports Key Metrics) and the list toolbar's
   search/filter bar — neither had any focus transition before this.
9. The entry status-change dropdown (Entries sidebar).
10. **First round verified against real screenshots, not source
    reading.** Found and fixed two real bugs no amount of code review
    would have caught: the top nav was clipping its last tab and forcing
    horizontal scroll at a completely ordinary 1440px laptop width
    (the reference product's equivalent fit comfortably at the same width — root
    cause was an unnecessary per-tab icon chip, removed); and the
    Support screen's setup-guide lists had entirely correct `<ul><li>`
    markup but rendered with zero bullet markers, because WordPress
    core's own admin CSS resets every list and nothing overrode it back
    — confirmed via `getComputedStyle()`, not guessable from source.
    Also switched the Field Palette to a 2-column grid and removed a
    heading that duplicated the (already-visible) active tab label
    directly above it, roughly doubling how many field types are
    visible without scrolling.

## Bug fix: disabled conditional-logic rules could block saving (v0.38.1)

Reported directly by the user against a real form: adding two plain
fields (Name, Email — no conditional logic involved) and saving produced
"Conditional logic groups must contain children," with no indication of
which field it actually referred to.

**Root cause**: `Form_Validator::validate_logic_rule()` enforced full
structural correctness (trigger present, groups non-empty, etc.) on
every field carrying a `logicRule` object, regardless of that rule's
`enabled` flag. A field elsewhere in the form with conditional logic
toggled off — or an empty condition group left over from editing — is a
completely normal, harmless state, but was rejected as if it were live.
The generic, field-agnostic error message then made the problem look
like it belonged to whichever field was most recently touched (Name/
Email in this report), rather than the actual field with the stale rule.

**Fix**, both in `includes/class-form-validator.php`:
- A rule with `enabled: false` is now skipped entirely during structural
  validation — it can never fire, so an incomplete shape is never a real
  problem.
- Every remaining validation error now names the specific field it came
  from (e.g. `Conditional logic on field "Payment Method (pm_1)" has a
  group with no conditions...`) instead of a bare, unattributed sentence.

New tests in `tests/form-validator.test.php` (4 checks: a disabled empty
rule never blocks a save; an enabled empty rule still correctly does;
the error names the right field even when it's the second field checked,
not the first). Two pre-existing test fixtures
(`tests/form-create-validation.test.php`, `tests/rest-api-form-save.test.php`)
needed an explicit `enabled: true` added to keep testing "an enabled,
malformed rule is rejected" now that disabled rules are skipped —
real-world rules built via the Rule Builder always set this explicitly
via `emptyRule()`, so this only affected hand-written test fixtures, not
real form data.

## Product-First Roadmap, Group A Phase 1 — the reference product, first fixes (v0.38.0)

A deliberate shift from feature-first to product-first: rather than adding
more capability, this and future rounds research one competitor at a
time (Group A — the reference product, Gravity Forms, WPForms, Formidable Forms,
Ninja Forms, in that order — determine feature completeness; Group B —
Fillout, Typeform, Tally, Jotform — inform UX/product ideas without
feature-for-feature comparison), classify every ChargeForms capability
against it (Complete / Partial / Missing / Better), and implement the
highest-value gaps before moving to the next competitor. Never by
reading a competitor's source code — official docs, marketing pages, and
public product tours only, exactly as this whole project's the reference product
boundary has always required.

the reference product research covered 14 categories, 89 individually sourced
comparison items: 20 Complete, 8 Partial, 42 Missing, 19 Better (ChargeForms
ahead) as of this round. Full sourced writeup and the derived roadmap are
tracked internally (not part of the plugin's own file tree); this entry
covers what was actually implemented from it — the "low-effort/high-value"
priority list, reviewed and approved before starting:

- **Field settings wired to the front-end**: Read Only, Disabled,
  Tooltip, HTML ID Override, and Custom Attributes have been real,
  stored builder settings since Milestone 5 but were never enforced on
  the actual rendered `<input>`/`<select>`/`<textarea>` — only the
  builder's data model and settings UI existed. `public/class-shortcode.php`
  now applies all five across every standalone-input field type (option-
  group types like Radio/Checkbox-groups/Ratings/Shipping, and non-input
  types like reCAPTCHA/Signature/hidden fields, are deliberately out of
  scope — the settings don't semantically apply there). The Tooltip
  specifically renders via `aria-label` (announced by screen readers
  regardless of hover/focus state) in addition to a native `title` for
  sighted mouse users — a direct response to the reference product's own
  accessibility guide flagging hover-only, beside-label tooltips as the
  least accessible placement option. New `tests/field-settings-frontend.test.php`
  (16 checks).
- **Notifications**: added Cc, Bcc, and an opt-in "attach uploaded files"
  setting to the per-form owner notification. Attachments are scoped to
  files the visitor actually uploaded on that specific submission (File
  Upload / Image Upload / Signature), resolved from the stored URL back
  to a real local file path — never attaches a path that isn't both
  under the real WordPress uploads `baseurl` and actually present on
  disk. `Includes\Notifications::send()`/`attempt()`/`handle_retry()` now
  thread `$headers`/`$attachments` all the way through, including across
  a scheduled retry. New tests in `tests/notifications.test.php` (5 new
  checks) and `tests/submission-handler.test.php` (5 new checks for
  `collect_upload_attachments()`), plus a new
  `tests/form-settings-email-list.test.php` (6 checks) for the Cc/Bcc
  address-list sanitizer.
- **Confirmations**: added an opt-in "pass submitted field data via query
  string" setting on redirect confirmations (e.g.
  `.../thank-you?email=a@b.com`), so a destination page can personalize
  itself or prefill fields. Only scalar submitted values are passed —
  array-valued fields (checkbox groups, Address, Repeater) are skipped
  rather than serialized into a query string in some arbitrary shape. 3
  new checks in `tests/submission-handler.test.php`.
- **New `docs/user-guide/accessibility.md`**: written from scratch,
  documenting real, already-shipped work (keyboard field reordering,
  Rule Builder keyboard reordering, the Tabs field's full WAI-ARIA
  pattern, the icon-labeling sweep, the command palette) that had never
  been written down anywhere, plus an honest Known Limitations section
  (canvas placement and Repeater reordering are still mouse/touch-only;
  nothing has been verified with a real screen reader — matching this
  project's standing browser-verification caveat).

**Corrections made to the research itself while implementing** (kept
transparent rather than silently fixed): Container Class / Element Class
were initially misclassified as Missing in the reference product comparison —
they were already wired end-to-end before this round started. The
Read Only/Disabled/Tooltip/Custom Attributes item was reclassified from
Partial to Complete once this round's fix shipped.

## Production Readiness Phase 4, closed (v0.37.0)

All 10 Phase 4 issues (nice-to-have polish — none blocked v1.0 release
readiness) addressed:

- **#40 (PHPCS automation)**: added `composer.json` (WPCS, PHPCompatibilityWP)
  and `phpcs.xml.dist`, wired into a new, separate GitHub Actions workflow
  (`.github/workflows/phpcs.yml`) as an advisory (`continue-on-error: true`)
  check per the roadmap's own "introduce as CI advisory before mandatory"
  guidance — not yet run against the existing codebase in this sandbox
  (composer unavailable here), so no claim is made about current compliance.
- **#26 (Form Query entry counts)**: `Form::query()` now computes entry
  counts via one aggregate `Entry::count_by_form()` query instead of the
  REST layer silently never populating them; the Vue Forms List and its
  underlying `/forms/query` route both now return real counts.
- **#25 (Vue Forms List parity)**: the `/forms/query` route now forwards
  `category`/`date_from`/`date_to` (previously silently dropped despite
  `Form::query()` supporting them since Phase 3); the Vue screen gained the
  matching Category and date-preset filters the legacy PHP fallback already
  had. Functional/REST parity is tested; the Vue UI's exact visual polish
  (`el-date-picker` rendering/behavior) has not been verified in a real
  browser.
- **#31 (admin class decomposition)**: extracted `Entry_Exporter` (CSV/JSON
  export) and `Entry_Importer` (CSV import) out of the ~1700-line
  `Admin\Entries_Page` into their own, independently unit-tested classes
  (`includes/class-entry-exporter.php`, `includes/class-entry-importer.php`).
  Scoped to these two clearly-bounded pieces rather than a full ground-up
  split, since the file's rendering code has no existing test coverage —
  see the classes' own doc comments for the full reasoning.
- **#35 (import file validation)**: new `Import_Validator` shared class
  enforces file size and extension/sniffed-content-type checks before a
  JSON forms import or CSV entries import is parsed — previously every
  import handler only checked that the HTTP upload itself succeeded, so an
  oversized file or a renamed wrong-type file would sail straight through
  to `json_decode()`/`fgetcsv()`. Wired into all three import entry points
  (`Admin\Tools_Page`'s two, `Admin\Entries_Page`'s one); the Tools page's
  formerly-duplicated CSV-parsing loop now delegates to `Entry_Importer`
  from #31 instead of a second, drifting copy.
- **#37 (signature field hardening)**: `SubmissionHandler` now rejects a
  signature payload larger than a sane bound before decoding it (guards
  against a crafted request that skips the fixed 500x150 canvas entirely
  and posts its own oversized base64 string), and the front-end's blank-
  canvas detection now checks the canvas' actual pixel data instead of only
  whether the visitor pressed down at some point. A real keyboard-operable
  alternative input and mobile/stylus responsiveness tuning need a product
  decision and real-device testing outside this pass's scope.
- **#39 (README/FEATURE_STATUS drift)**: added `docs/RELEASE_CHECKLIST.md`
  codifying which docs (FEATURE_STATUS.md, CHANGELOG.md, readme.txt,
  FIELD_COMPATIBILITY_MATRIX.md, PROJECT_STATUS.md) must be checked before
  every version bump, per the roadmap's own "make it a release-process
  task" guidance.
- **#41 (release packaging)**: the release CI pipeline (`release.yml`) was
  silently never running `npm run vue:build` — a separate build step from
  the main `npm run build` — meaning every previously-shipped release
  fell back to the legacy PHP Forms List screen instead of shipping the
  Vue rebuild (a pre-existing fallback in `class-forms-list-page.php`
  masked this rather than breaking loudly). Added the missing build step
  plus a fail-fast check that all expected bundles exist before packaging.
- **#42 (admin copy inconsistencies)**: removed "Container / Repeater
  fields" from the builder preview sidebar's "Coming later" list — both
  have existed for several milestones. Also corrected `readme.txt`'s
  long-stale "Coming soon (Pro)" section, which still listed conditional
  logic, multi-step forms, and Stripe payments as unbuilt paid features
  years after all three shipped free (Milestone 6 and earlier).
- **#21 (builder mobile UX), assessed**: the touch drag-and-drop foundation
  (`@dnd-kit`'s `TouchSensor`, properly configured with activation
  delay/tolerance) and the responsive single-column layout collapse
  (≤860px) already exist and look sound at the code level. The roadmap
  itself marks this "not required for v1.0 unless mobile authoring is
  promised," and further polish requires real mobile-device testing this
  pass couldn't perform — no changes made rather than guessing blind.

## Production Readiness Phase 3, fully closed (v0.36.0)

Closes the two Phase 3 issues left open in v0.35.0 (#27's remaining
scope, #28) — all 11 Phase 3 issues are now closed. See
`PROJECT_STATUS.md` for the complete current picture across all three
phases; this entry is the detailed record of this round specifically.

**#27, completed — every timestamp column, not just report filtering**:
v0.35.0 fixed the concrete report-filtering bug and `Entry::create()`;
this round audited and fixed every OTHER write site found in the same
sweep:
- `Form::create()`/`Form::update()` now explicitly store `created_at`/
  `updated_at` in GMT (`current_time('mysql', true)`) instead of relying
  on the `chargeforms_forms` table's own `DEFAULT CURRENT_TIMESTAMP` (whose
  actual timezone depends on the MySQL server's session config, not the
  WordPress site's) or a site-local `current_time('mysql')` call.
- `Activity_Log::log()`, `Api_Log::log()`, `Notification_Log::create()`/
  `mark_sent()`, `Entry::add_meta()` (entry notes), and
  `Form_Start::record()` all got the identical fix.
- Every "X ago" relative-time display (`admin/class-entries-page.php`'s
  `format_entry_date()`, three call sites in `admin/class-tools-page.php`
  for Activity Logs/API Logs/Notifications) now compares against
  `current_time('timestamp', true)` — GMT "now" — instead of site-local,
  matching the now-GMT-stored columns they're diffing against.
- Every absolute date display that reads one of these columns
  (`format_entry_date()`'s non-relative branches, the Forms list's
  "Updated" column, the Payments table's date column) now converts back
  to the site's configured timezone via `get_date_from_gmt()` before
  formatting with `mysql2date()` — so an admin sees their own local time,
  not raw UTC, while the underlying storage stays consistently GMT.
- CSV import date fallbacks (used when an imported row has no date
  column) also switched to GMT, matching a freshly-submitted entry's
  own `created_at`.
- New `tests/timestamp-gmt-consistency.test.php` (10 checks) covering
  every write site above, using a stub `current_time()` that returns a
  deliberately different value for GMT vs. site-local so each check
  proves the GMT variant was actually requested, not just that some
  datetime string was stored.

**#28, completed — public JS build consolidation**: previously,
`assets/js/frontend.js` (hand-authored, no build step — multi-step nav,
masking, signatures, commerce calculation, tabs, repeaters) and
`assets/src/frontend/conditional-entry.js` (a separately webpack-built
bundle wrapping the shared conditional-logic engine) were joined at
runtime via a `window.ChargeFormsConditional` global, with the browser
loading two separate script tags in a specific dependency order.
- New `assets/src/frontend/frontend-entry.js` merges both into one
  webpack entry (`frontend` in `webpack.config.js`, replacing the old
  `frontend-conditional` entry) — the exact same logic, moved verbatim,
  now importing the conditional-logic engine (`./conditional.js`)
  directly instead of reaching for a runtime global.
  `assets/js/frontend.js` and `assets/src/frontend/conditional-entry.js`
  are removed; their content lives on in the merged file.
- `public/class-shortcode.php`'s `register_assets()` now registers one
  `chargeforms-frontend` script from the built `assets/build/frontend.js`
  (using its `.asset.php` for the dependency array and cache-busting
  version, the same pattern already used for the builder bundle) instead
  of two scripts with a manual load-order dependency between them.
- Verified via a clean production build and by confirming distinctive
  string literals from BOTH the vanilla-JS logic (`chargeforms-step`,
  `chargeforms-signature`) and the conditional-logic engine (`data-fc-rule`)
  survive minification in the single output bundle, and that
  `window.ChargeFormsConditional` no longer appears anywhere in it.
  **Not** verified in a real browser — WordPress Playground/Playwright
  access has been confirmed unavailable in this sandbox across the whole
  project. This is the one Phase 1-3 fix that shipped on code-level
  verification alone; flagged explicitly in `PROJECT_STATUS.md` as worth
  real manual testing (multi-step navigation, masking, signatures,
  commerce calculation, tabs, repeaters, conditional field visibility)
  before fully trusting it in production.

Verified: full PHP lint across the plugin, all 22 test suites passing
(190+ assertions total), clean production build.

## Production Readiness Phase 3 closed (v0.35.0)

Closes 9 of the 11 Phase 3 issues (performance & scale) from
`docs/research/production-readiness-audit.md`. Added `PROJECT_STATUS.md`
at the plugin root as the new canonical "start here" doc — read that
first for the current overall picture; this entry is the detailed
per-fix record.

- **#24 Conditional logic rescans every rule per keystroke**:
  `baseFieldState()` (`assets/src/builder/logic/actions/builtins.js`) is
  now memoized via a nested WeakMap keyed on the exact `(rules, fields)`
  reference pair — safe because it never mutates the cached result, and
  correct because a genuinely different reference (a different form's
  rules, or the builder's own ever-changing tree during editing) simply
  misses the cache and recomputes. The bigger root cause on the public
  front end: `assets/src/frontend/conditional.js`'s `applyConditions()`
  used to re-query the DOM and `JSON.parse()` every field's rule on
  *every single 'input' event* across the whole form — moved that parsing
  into `initConditions()` (runs once), so only the visitor's changing
  field values are rebuilt per keystroke now.
- **#34 API log pruning runs on every insert**: `Api_Log::log()`'s
  prune check (a `COUNT(*)` over the whole table) now runs roughly every
  50th insert (`PRUNE_FREQUENCY`), gated on the free, already-monotonic
  insert id — not a new option/state to track.
- **#15 Builder history has no cap**: `BuilderProvider.js`'s undo/redo
  stack now caps at `MAX_HISTORY_ENTRIES = 100`, dropping the oldest
  checkpoint once full — the same trade-off any text editor's own undo
  buffer makes once it fills up.
- **#17 Migration scans every form synchronously on version bump**:
  `Rule_Migrator::run_batch()` processes one page of forms (50) and
  self-schedules the next page via WP-Cron if more remain.
  `schedule_background_migration()` (called from `Install::
  maybe_upgrade()`) deliberately runs the FIRST batch synchronously
  rather than fully deferring — a fully-deferred job would have broken a
  real invariant this codebase depends on (every PHP consumer assumes
  migration has already happened, with no fallback to the legacy shape),
  so only sites with more forms than one batch get the remainder
  deferred to the background.
- **#23 Canvas/settings-panel repeatedly traverse the full field tree**:
  `FieldSettings.js` flattened the whole tree up to 3 times per render;
  now flattens once (`useMemo`) and reuses it. `Toolbar.js`'s three
  independent stat selectors (field/required/logic counts) — which
  re-ran on every form edit — are now one `selectFormStats()` selector
  doing one flatten and one pass.
- **#16 No save conflict detection**: `PUT /forms/{id}` now accepts an
  optional `expected_updated_at` precondition; if the row has changed
  since the builder last saw it (another tab, another admin), the save
  is rejected with a 409 and a clear message instead of silently
  overwriting whatever changed. The builder tracks and sends this
  automatically; omitting the field (any other API caller) skips the
  check entirely.
- **#18 Entries REST/export return everything unpaginated**:
  `Entry::query_for_form()` (paginated, mirrors the `{items, total, page,
  per_page}` shape every other list endpoint in this plugin already
  uses) backs a newly-paginated `GET /forms/{id}/entries` REST route.
  CSV/JSON entry export now fetches in 500-row batches instead of
  loading every entry into one PHP array before writing anything — CSV
  export never holds more than one batch in memory at a time; JSON
  export's memory profile improves less (a JSON array still needs its
  full contents before `wp_json_encode()` can run) but no longer ALSO
  holds the raw, larger un-transformed entries array at the same time.
  The CSV *import* path was already streaming one row at a time via
  `fgetcsv()` — verified, not a fresh fix.
- **#27 Date handling mixes GMT and site time**: report/Entries/Forms-
  list date-range presets (`resolve_date_preset()` in three admin page
  classes) now consistently compute "today" in GMT, matching the GMT-
  based `gmdate()` boundaries actually used to filter `created_at`
  elsewhere — previously "today" was site-local while the filter
  boundaries built from it were GMT, which could silently shift which
  entries a preset matched depending on the site's timezone offset.
  `Entry::create()` now explicitly stores `created_at` via
  `current_time('mysql', true)` rather than relying on the table's own
  `DEFAULT CURRENT_TIMESTAMP`, whose actual timezone depends on the
  MySQL server's session config, not the WordPress site's. This is a
  bounded fix, not a full audit of every timestamp column — see
  `PROJECT_STATUS.md` for exactly what was and wasn't touched.
- **#33 Webhooks have no retry** (Notifications already got this in
  Phase 2): `Webhook_Integration` now retries a failed delivery up to 3
  times, 5 minutes apart, mirroring `Includes\Notifications`'s pattern.
  Since the request is fire-and-forget (`'blocking' => false`), the
  retry decision has to happen inside the `http_api_debug` callback
  where the real response is eventually known, not in the calling code.
- **#36 N+1 report queries per form**: the Reports page's "Top Forms"
  widget and its CSV/JSON export ran one `Entry::query()` COUNT and one
  `Form_Start::count_for_form()` COUNT *per form on the site* just to
  find the top 5. New `Entry::count_by_form()` / `Form_Start::
  count_by_form()` (one GROUP BY query covering every requested form id)
  replace that. The Forms list's paginated Entries column got the same
  treatment (smaller win — bounded by page size already, but free).
- **#28 Public JS split between built/unbuilt sources**: deliberately
  NOT attempted — the audit's own suggested implementation says this
  should wait until Phase 1/2 behavior is stable, and restructuring the
  public JS bundle boundary needs real browser verification this
  environment doesn't reliably have.
- **#12/#13 (bounded fixes from Phase 2) remain bounded** — no new work
  this round; still documented accurately in `PROJECT_STATUS.md`.

New test coverage: `tests/base-field-state-cache.test.mjs` (7),
`tests/api-log-prune-frequency.test.php` (4), `tests/rule-migrator-
batch.test.php` (7), `tests/form-stats-selector.test.mjs` (4),
`tests/rest-api-form-save.test.php` grew by 3, `tests/entry-query-
pagination.test.php` (7), `tests/entry-created-at-gmt.test.php` (2),
`tests/webhook-retry.test.php` (7), `tests/count-by-form.test.php` (5).

Verified: full PHP lint across the plugin, all 21 test suites passing
(180+ assertions total), clean production build.

## Production Readiness Phase 2, final two issues closed (v0.34.0)

Closes the last two Phase 2 issues that were deliberately deferred in
v0.33.0 as "Large" architectural rebuilds — #20 (Notifications) and #32
(Unified Validation). Both are real, bounded, working implementations of
the concrete gap the audit named, not the full larger system either
issue's "Suggested implementation" envisions; see
`docs/research/production-readiness-phase-1-2-summary.md` (new) for the
complete picture of Phase 1 + Phase 2 and exactly what's still open in
each.

**#20 Notifications** — previously a bare `wp_mail()` call with no
record of which form/entry it was for and no retry beyond a generic
Activity Log line:
- New `chargeforms_notification_log` table + `Includes\Notification_Log`:
  one row per attempted send (form, entry, recipient, subject, status,
  attempt count, last error, timestamps).
- New `Includes\Notifications`: wraps every send, capturing the real
  `wp_mail_failed` error message via a request-scoped hook, and
  scheduling a WP-Cron retry (`chargeforms_retry_notification`) on failure
  — up to 3 total attempts, 5 minutes apart, before giving up for good.
  `SubmissionHandler::send_entry_notification()` now routes through this
  instead of calling `wp_mail()` directly.
- New Tools > Notifications admin screen (`admin/class-tools-page.php`):
  paginated delivery history, mirroring the existing API Logs screen's
  layout/pagination convention.
- New "Send Test Email" button on the per-form Settings page
  (`admin/class-form-settings-page.php`): sends a real notification
  immediately with placeholder values, through the same log+retry path,
  so an admin can confirm delivery without waiting for a real
  submission.
- `tests/notifications.test.php` (19 checks): Notification_Log CRUD, and
  Notifications' success path, failure-with-retry path, and exhausting
  all 3 attempts — including that the captured error message is the
  REAL one from `wp_mail_failed`, not a generic fallback.

**#32 Unified Validation** — `field.validation` (minLength, regex,
numeric min/max, a custom override message) was configurable in the
builder's Settings panel but enforced nowhere; FieldSettings.js's own
doc comment admitted as much:
- New `Includes\Field_Validator`: the one place these four rules are
  actually checked server-side, called from `SubmissionHandler`'s
  existing per-field validation loop. The regex rule gets the exact same
  ReDoS guard (pattern heuristic + PCRE backtrack-limit) already applied
  to conditional-logic regex conditions, plus the same save-time
  rejection in `Form_Validator` for a pathological pattern.
  `customMessage`, when set, overrides the default wording.
- `class-shortcode.php`'s `render_text_like()`/textarea case now mirror
  the same four rules as native HTML5 constraint attributes (`minlength`,
  `pattern`, `min`, `max`, `title`) — real client/server parity, not
  just a server-side backstop no visitor ever sees before submitting.
- `tests/field-validator.test.php` (16 checks) plus 2 new checks added
  to `tests/form-validator.test.php` for the save-time regex guard.

This does NOT build the full "Milestone 7 Validation Engine" the
codebase's own comments name as a separate future effort (per-field-type
JS `validate()` hooks, builder live-preview of violations), nor a
generic outgoing-mail queue for every `wp_mail()` call ChargeForms makes
(double-optin emails, Email Summaries still send directly) — both remain
their own, larger, future efforts.

Verified: full PHP lint across the plugin, all 13 test suites passing
(139 assertions total), clean production build.

## Production Readiness Phase 2, this round's scope closed (v0.33.0)

Closes 6 of the 8 remaining Phase 2 issues from `docs/research/
production-readiness-audit.md`. Two (#20 Notifications, #32 Unified
Validation) are deliberately NOT attempted here — both are rated "Large"
effort in the roadmap and are genuine architectural rebuilds (an async
retry queue with templates and admin-visible delivery status; a single
validation contract spanning builder, frontend, and PHP used by every
field type), not bounded bug fixes. Attempting a shallow version of
either would either misrepresent what shipped or risk destabilizing
currently-working validation/notification paths. They remain open —
see the roadmap for their suggested implementation.

- **#14 HTML/Rich Text builder preview XSS**: `FieldPreview.js` and
  `fields/types/rich_text/index.js` rendered `field.content` via
  `dangerouslySetInnerHTML` with zero sanitization — safe on the public
  front end (`wp_kses_post()` already ran there) but not in the wp-admin
  builder preview, where a malicious import, a compromised row, or a
  lower-privileged `chargeforms_manage_forms` user's content would execute
  in the viewing admin's browser. Added `dompurify` (new dependency) and
  a shared `sanitizeFieldHtml()` helper (`assets/src/builder/shared/
  sanitizeHtml.js`) both preview components now route through.
- **#30 Math Field not server-authoritative**: computed live client-side
  and submitted as its own value with nothing to stop a visitor from
  overwriting it via devtools before submit. `SubmissionHandler` now
  recomputes it server-side (`compute_math_field()`) from the two
  operand fields' own server-collected values, mirroring `frontend.js`'s
  `computeCommerce()` math branch operator-for-operator, in a pass that
  runs after every other field's value is already collected (an operand
  field can appear later in the field tree than the math field itself).
- **#12 Repeater row semantics**: neither minimum/maximum row count nor
  "required" on a repeater's child fields was ever enforced server-side
  — both were client-side-only and trivially bypassed by posting
  directly. Added `validate_repeater_rows()`, called before the existing
  `sanitize_repeater_value()`. Row-scoped conditional-logic references
  (a condition inside one row reading another field in that SAME row)
  remain out of scope — that needs a schema change to the conditional-
  logic engine itself, not a submission-time check.
- **#13 Upload allowlist hardening**: SVG (and bare HTML) are now hard-
  denylisted regardless of a field's own `allowedExtensions` setting —
  previously only a fixed set of script/executable extensions was
  blocked, relying entirely on WordPress's own MIME allowlist (which
  already excludes SVG by default, but a site where another plugin
  broadened it would have quietly reopened this). A File field with NO
  `allowedExtensions` configured at all now falls back to a sane default
  allowlist (images, documents, archives, text) instead of accepting
  anything WordPress's upload filter doesn't already reject. Private/
  protected storage and signed admin-only download URLs — the other half
  of this issue — remain unimplemented; uploaded files are still stored
  and linked at their normal public `wp-content/uploads` URL.
- **#11 Field registry runtime-type honesty**: rather than building the
  audit's suggested full build-time compatibility manifest (a
  significantly larger effort), added a static, text-based consistency
  test (`tests/field-registry-render-coverage.test.mjs`) that fails the
  moment a new, non-locked field type is registered in the JS palette
  without any corresponding reference in `class-shortcode.php`'s
  renderer — a real, if narrow, regression guard against the exact
  "looks real in the builder, does nothing on the real form" failure
  mode the issue describes.
- **#19 Multi-step validation**: audited both sides rather than assuming
  a gap existed. `assets/src/frontend/conditional.js` already clears
  `required` and sets `disabled` on any field the shared conditional-
  logic evaluator marks non-visible (so `form.reportValidity()` correctly
  skips it per the step-navigation code's own documented assumption), and
  `SubmissionHandler::handle()` already skips the required-check
  server-side for any field the same evaluator marks non-visible. Both
  sides already agree — no code change was needed; the audit's own
  remaining ask here (deeper Playwright/browser regression coverage)
  isn't achievable in this environment (WordPress Playground has been
  confirmed unavailable in this sandbox across multiple sessions).

New test coverage: `tests/submission-handler.test.php` grew from 12 to
23 checks (math field recomputation, repeater row validation);
`tests/field-registry-render-coverage.test.mjs` (4 checks) is new.

## Production Readiness Phase 2, opening slice (v0.32.0)

The most valuable finding this round came from auditing Phase 2 issue #10
(payment intent abuse controls) more closely than the audit itself did:
the existing per-IP/form rate limit was fine, but the endpoint's actual
INPUT DATA was stale relative to the Phase 1 Stripe_Gateway rewrite
(v0.29.0) — a real, unrelated-looking bug that turned out to be the
bigger issue.

- **Payment correctness (the important one)**: `assets/js/stripe-
  payment.js`'s `collectCustomAmounts()` only ever gathered
  `[data-fc-custom-amount]` values — a leftover from before commerce
  fields (quantity, shipping, tax, discount) existed. Every form
  combining a Payment Method field with those newer commerce field types
  was charging the wrong amount via Stripe, silently, because the total
  sent to `/create-payment-intent` never included them. Replaced with
  `collectSubmittedValues()`, which also collects quantity and chosen-
  shipping values by field id — the exact shape `Stripe_Gateway::
  calculate_total()` expects (see the `tests/commerce-calculator.test.php`
  fixture it was originally verified against).
- **Payment correctness (the missing safety net)**: `SubmissionHandler::
  verify_payment()` checked that the Stripe PaymentIntent succeeded and
  belonged to the right form, but never checked that its charged amount
  actually matched the visitor's final submission. It now recomputes the
  expected total from the real, sanitized submitted values and rejects
  the entry (refusing to record it) if the two don't match in cents —
  closing the gap the bug above exploited by coincidence, and any
  deliberate mismatch-based abuse going forward.
- Verified — with a new test, not just by reading the code — that the
  public tracking endpoint's (`/forms/{id}/track-event`) existing per-
  IP+form+day dedup genuinely works: repeated events from the same
  visitor for the same form on the same day produce exactly one row, per
  event type, independent across different forms and different IPs. This
  is what the production-readiness audit's issue #9 (write amplification)
  is actually bounded by; it needed verification, not new code.
- The Formula field is now `locked: true` in the field-type registry —
  shows "Soon" in the palette, can't be dragged or clicked in, matching
  the same pattern already used for other not-yet-real field types
  (Featured Image, Categories). A form that already had one (saved before
  this lock) now renders nothing on the public front end instead of the
  raw, unevaluated expression string, which read as a broken form to a
  real visitor even though the builder always labeled it clearly as
  unevaluated to the form's author.
- Added `args` schemas (`includes/class-rest-api.php`) to the routes that
  previously validated nothing at the registration level: forms
  query/list, status toggle, duplicate, entries, and create-payment-
  intent — malformed requests now get a standard WP REST 400 before ever
  reaching the callback.

New test coverage:
- `tests/form-start-dedup.test.php` (5 checks): the tracking endpoint's
  dedup behavior described above.
- `tests/rest-api-form-save.test.php` and `tests/rule-migrator.test.php`
  from the prior round continue to pass unchanged; no new test was added
  for the payment-verification fix itself — it depends on `Stripe_
  Gateway::retrieve_intent()`'s live HTTP call to Stripe, which isn't
  mocked in this test harness. `tests/commerce-calculator.test.php`
  already covers the underlying `calculate_total()` math this fix reuses.

## Production Readiness Phase 1, closing this round's scope (v0.31.0)

Rounds out this session's Phase 1 work on Accessibility (#7) and Testing
(#8). Both issues are large, ongoing efforts per the roadmap's own
estimates (8-15 and 7-14 days respectively) — this closes the concrete,
verifiable gaps found by direct code audit, not the full scope of either
issue. Remaining accessibility work: a full screen-reader pass, contrast
audit, and reduced-motion support. Remaining testing work: payments,
uploads, entries, autosave/history, and browser-level (Playwright/
Playground) coverage — see `docs/research/production-readiness-
roadmap.md`.

Accessibility fixes, by surface:
- **Field Palette**: added `aria-label` to the search input and
  `aria-pressed` to the group-filter buttons (field insertion itself was
  already keyboard-operable — `PaletteItem` is a real `<button>`, no
  change needed there).
- **Field Settings Panel**: added `aria-label` to the unlabeled inputs
  and icon-only remove buttons in the three repeatable option editors
  (Custom Attributes, Image Choice options, Shipping methods), and
  `aria-expanded` to each collapsible section's toggle button.
- **Canvas**: added `aria-label` to the Floating Toolbar's icon-only
  Copy/Paste/Duplicate/Settings/Delete buttons, the column split/delete
  and accordion/tabs pane rename/delete controls.
- **Rename Form dialog**: added `aria-label` to its close/fullscreen
  buttons and an Escape-to-close handler — dialogs are expected to close
  on Escape per the WAI-ARIA dialog pattern; this one didn't.

New test coverage:
- `tests/rest-api-form-save.test.php` (7 checks): proves
  `RestApi::create_form()`/`update_form()` pass an invalid payload's
  `WP_Error` straight through to the REST client instead of accidentally
  `rest_ensure_response()`-wrapping it into something that reads as
  success, and that `toggle_status()`'s own status-value check runs
  before ever reaching `Form::update()`.
- `tests/rule-migrator.test.php` (17 checks): the legacy
  `field.conditional` → `field.logicRule` conversion end to end — AND/OR
  logic mapping, show/hide action preservation, a disabled conditional
  migrating to a disabled (not dropped) rule, an already-migrated field
  never being overwritten by a stray legacy key, and recursion into
  container children.

## Production Readiness Phase 1, continued (v0.30.0)

Closes the two Critical issues from `docs/research/production-readiness-
audit.md` left open by v0.29.0: Accessibility (#7, partial — the highest-
traffic gaps) and Testing Coverage (#8, partial — the gaps most directly
tied to this round's own changes). Both remain larger, ongoing efforts;
see `docs/research/production-readiness-roadmap.md` for what's still open.

Accessibility fixes, in order of user impact:
- **Public frontend Tabs field** (every visitor to any form using Tabs):
  `render_tabs()` (`public/class-shortcode.php`) now emits correct
  `role="tablist"`/`"tab"`/`"tabpanel"` semantics — `aria-selected`,
  `aria-controls`/`aria-labelledby` linking each tab to its panel, and
  roving `tabindex`. `initTabs()` (`assets/js/frontend.js`) keeps those
  attributes in sync and adds ArrowLeft/ArrowRight/Home/End navigation per
  the WAI-ARIA APG tabs pattern. Previously a screen reader announced
  nothing about which tab was selected or that tabs and panels were
  related at all. (The Accordion field was already fully accessible —
  native `<details>/<summary>` needs no JS or ARIA at all.)
- **Builder canvas field reordering**: Alt+ArrowUp/Alt+ArrowDown now moves
  the selected field up/down within its current container — a real
  keyboard alternative to drag-and-drop, not just a visual focus ring with
  no way to act on it. Previously reordering fields was mouse/touch-only.
- **Rule Builder condition/group reordering**: the tree's drag handles use
  only Pointer/Touch sensors (no dnd-kit KeyboardSensor), so dragging
  conditions and nested groups was keyboard-inoperable despite looking
  focusable. Added always-available Move up/down buttons next to every
  condition and group as the keyboard path.
- **Rule Builder inputs and icon buttons**: added `aria-label` to every
  unlabeled select/input (data source, field, operator, value, action
  type, target field, sample test values) and every icon-only button
  (duplicate, remove, collapse/expand, move up/down) across
  `ConditionRow.js`, `GroupEditor.js`, and `ActionsEditor.js` — previously
  most had only a `title` tooltip, which isn't a reliable accessible name.
  The Live Preview's result banner is now an `aria-live="polite"` region
  so screen reader users hear the evaluation result update as they edit
  sample values, without needing to move focus to it.
- **Vue admin forms list**: added `aria-label` to the status/sort filters,
  search input, and every per-row action (Edit/Duplicate/Delete/status
  toggle now identify which form they act on, not just "Delete" repeated
  once per row with no context).

New test coverage:
- `tests/form-create-validation.test.php` (9 checks): proves
  `Form::create()`/`Form::update()` reject invalid payloads *before*
  touching the database (not just that `Form_Validator` itself rejects
  them in isolation), and that valid payloads still write and hydrate
  correctly — the exact contract gap a v0.29.0 fix closed for two admin
  handlers.
- `tests/submission-handler.test.php` (12 checks): the field-tree
  flattening logic that decides what's actually submittable (sections,
  rows/columns, tabs, accordions, repeaters, hidden fields) and the
  repeater array-of-rows sanitizer.
- `tests/builder-keyboard-reorder.test.mjs` (11 checks): the Alt+Arrow
  shortcut matching (including that plain arrows and Alt+Shift+arrows
  correctly do NOT match the reorder handlers) and the same-parent
  index math the new keyboard reorder relies on, including inside nested
  containers.

## Production Readiness Phase 1, partial (v0.29.0)

Closes 6 of 8 Critical issues from a whole-plugin production-readiness
audit (`docs/research/production-readiness-audit.md`), scoped by the
accompanying roadmap (`docs/research/production-readiness-roadmap.md`).
Accessibility and deeper test coverage remain open.

- `Includes\Form_Validator` (new): the save-time trust boundary for form
  JSON. Rejects payloads exceeding 200 fields, 12 levels of field/logic
  nesting, 100 conditional-logic rules, 500 logic nodes, or 20 actions
  per rule, and rejects unsafe (catastrophic-backtracking-shaped) regex
  patterns in conditional-logic conditions. Wired into `Form::create()`
  and `Form::update()`, both of which can now return `WP_Error`.
- Runtime evaluator guards (`Includes\Logic\Evaluator`, `evaluate.js`):
  independent depth/node-count limits enforced during evaluation itself,
  failing safe (never matching) if exceeded — defense-in-depth alongside
  the save-time validator, not a replacement for it.
- Regex ReDoS fix (`Includes\Logic\Builtin_Operators`, `builtins.js`):
  pattern-shape heuristic on both engines, plus a real PHP
  `pcre.backtrack_limit` guard around the actual `preg_match()` call —
  catches any pathological regex, not just the specific shape the
  heuristic recognizes.
- Fixed a payment-correctness bug in `Includes\Stripe_Gateway`: the
  server-authoritative charge amount only totaled legacy
  `payment_item`/`custom_amount` fields. `calculate_total()` now walks
  the full field tree (including inside containers) and correctly
  includes `product`, `price`, `quantity`, `shipping`, `tax`, and
  `discount` fields — matching what the customer is actually shown.
- Builder: a recovered local draft no longer silently overwrites server
  state on load — an explicit "Restore draft" / "Discard" prompt is
  shown instead. Save and initial load failures now surface a visible
  error banner instead of failing silently.
- Fixed a gap the validator change introduced: `Form::create()`/`update()`
  returning `WP_Error` was not yet handled by every caller. The form
  import and create-from-template handlers in
  `Admin\New_Form_Page` would have hard-fataled
  (`Cannot use object of type WP_Error as array`) on any payload the
  validator rejected; they now `wp_die()` with the validator's message.
  The bulk-import loop in `Admin\Tools_Page` also silently overcounted
  rejected forms as imported; it now only counts real successes.
- New focused test coverage: `tests/form-validator.test.php` (7 checks)
  and `tests/commerce-calculator.test.php` (2 checks, including a
  hand-verified nested-container commerce total).

## Milestone 6 Phase A — Data Source Wiring (v0.28.1)

Closes the gap flagged honestly in the just-published user guide: 7 of 9
conditional-logic data sources (`urlParam`, `currentUser`, `userRole`,
`postMeta`, `userMeta`, `cookie`, `query`) were registered and evaluation-
ready but always resolved empty, since nothing populated `context.extra`.

- `Includes\Logic\Context_Builder` (new): builds real context data,
  scoped strictly to the specific (source, ref) pairs a form's own rules
  actually reference — collected by walking every rule's trigger tree
  once. Never a blanket dump of post meta / user meta / user fields.
  Keys starting with `_` (WordPress's private-meta convention) are never
  exposed for postMeta/userMeta, no opt-in, hard default.
- Wired into both real consumers: `SubmissionHandler` (server-side,
  authoritative) and `Shortcode::render_form_html()` (front-end, embeds
  only the subset that needs a server round-trip — currentUser/userRole/
  postMeta/userMeta — into a `data-fc-context` attribute; urlParam/query/
  cookie resolve live from the browser instead via a new `assets/src/
  frontend/context.js`, avoiding a stale embedded copy).
- Found and fixed a real, previously-silent bug while writing the
  end-to-end proof: the PHP data-source resolvers read snake_case keys
  (`url_params`, `current_user`, ...) while every actual populator of
  that context — the JS mirror, and now `Context_Builder` — always wrote
  camelCase. Invisible until this round, since nothing had ever populated
  the context before. 19/19 new tests passing, including a full
  integration check of the real `Shortcode` render path, not just the
  isolated builder logic.

## Milestone 6 Phase A — Schema Unification (v0.28.0)

A follow-up round: a production audit (v0.27.1's showcase page) exposed
that `field.logicRule` (the new engine schema) was written by the Builder
but read by nothing on the submission or front-end paths — those still
only understood the legacy `field.conditional` shape, meaning a rule
built entirely through the new Rule Builder had no real effect outside
its own Live Preview. This round closes that gap for good rather than
patching around it:

- `Rule_Migrator` (new): a one-time, write-boundary migration —
  `Form::create()`/`Form::update()` now convert every field's legacy
  `conditional` shape to `logicRule` before it ever reaches the database,
  and `Install::maybe_upgrade()` batch-converts everything saved before
  that guarantee existed. Not a permanent second reader — a genuine
  one-time backfill.
- Every consumer re-audited against the full list (frontend, submission,
  REST, preview, exports, entries, confirmations, notifications,
  calculations, payments, integrations, future AI) and confirmed to call
  exactly one evaluator through exactly one schema. Two now-dead code
  paths were deleted outright: the JS compat compiler
  (`logic/compat/compileFieldConditional.js`) and
  `Legacy_Conditional_Compiler` (PHP) — not deprecated, removed.
- Found and fixed a second, more serious bug while writing the required
  regression proof: the evaluator's `baseFieldState()` defaulted every
  field to visible, so a `show`-only rule could never actually hide
  anything when its condition didn't match — a bug in the *engine*, not
  just the migration, affecting brand-new rules too. Fixed in both
  engines; one existing shared test vector had encoded the old buggy
  expectation and was corrected.
- Proof: a dedicated regression script covering legacy-only, new-schema-
  only, and mixed forms; migration idempotency; disabled-rule
  preservation; nested-container recursion; front-end rendering; the
  exact submission-handler code path; and a grep sweep confirming zero
  remaining duplicate reads of `field.conditional` — 22/22 passing,
  alongside the shared vector suites (17/17 JS, 15/15 PHP).
- Honest gap, unchanged from Phase A: no real-browser-plus-real-database
  verification was possible in this environment. Full detail:
  `docs/research/milestone-06-conditional-logic.md` Part 7.

## Milestone 6 Phase A — Conditional Logic & Workflow Engine (v0.27.0)

A brand-new, industry-researched (not the reference product-Forms-copied) conditional
logic engine — see `docs/research/milestone-06-conditional-logic.md` for
the full research/architecture/implementation report. Full details there;
summary here:

- **One evaluation engine, four real consumers** — the Builder, the
  front-end, and the PHP submission handler all now call the exact same
  evaluator (`assets/src/builder/logic/` in JS, `includes/logic/` in PHP),
  verified identical via a shared JSON test-vector contract (17/17 JS,
  15/15 PHP passing). Two real duplicate implementations were deleted in
  the process: `SubmissionHandler::is_field_visible()` and
  `frontend.js`'s own `evalRule()`/`groupMatches()`/`applyConditions()`
  — a third, separately-hand-maintained copy of the same boolean logic
  that had existed alongside the other two.
- Unlimited-depth nested AND/OR/NOT groups, 15 operators, 8 field
  actions, 9 data sources — all registry-based (`registerOperator`/
  `registerActionType`/`registerDataSource`/`registerEvaluator`), so a
  future plugin can extend any of them without patching this codebase.
- Kahn's-algorithm dependency graph — one pass gives both evaluation
  order and real circular-dependency detection (fail-safe: a cycle
  excludes the affected rules rather than looping or failing open).
- A complete visual Rule Builder (`logic/ui/`) replacing the old flat
  two-level editor — unlimited nested groups, drag-and-drop reordering,
  duplicate/delete/collapse per group, live validation, a recursive
  plain-English rule summary, a Live Preview panel that doubles as a Rule
  Debugger (sample field values in, a pass/fail trace tree out — "Field
  Changed -> Rule PASSED/FAILED -> Action -> Reason"), and a Rule
  Inspector (ID, schema version, evaluation order, dependencies,
  referenced fields, circular-reference status, performance timing).
- Performance: a 500-field/250-rule/6-level-nesting stress test — see the
  research doc §6.3 for full numbers; realistic-case medians are ~7ms
  (JS, the perf-sensitive live-typing path) and ~41ms (PHP, a one-time
  per-submission cost), with no exponential blow-up observed.
- **Honest gap**: real-browser Playwright/WordPress-Playground
  verification was not possible in this environment (network-restricted
  sandbox) — verification performed instead (build compiles, shared
  contract tests, PHP boot/render smoke test) is real but is not the same
  standard prior milestones were held to. Flagged explicitly in the
  research doc §6.5 as follow-up work, not silently skipped.
- Phase A scope is field-level rules specifically; form-level workflow
  rules (page-skip, dynamic values/labels, cross-rule ordering UI) are
  Phase B, not attempted here.

## Milestone 5 — Advanced Field Engine (v0.26.0)

The field system's architectural foundation for everything that follows
(Conditional Logic, Calculations, AI, Payments, Integrations, Entries,
Templates, Automation). Delivered in five sub-phases:

- **5A — Field-module foundation.** A per-field-type module contract
  (`fields/shared/fieldModule.js`: `schemaVersion`, `defaults()`,
  `serialize()`/`deserialize()`, `validate()`, `migration()`,
  `previewRenderer`/`builderRenderer`, `settingsRenderer`,
  `frontendRenderer`) that new field types register through, with the
  ~48 pre-existing types kept on their original code path unchanged (an
  incremental migration, not a flag-day rewrite). Also closed a real,
  pre-existing gap: `class-shortcode.php` never rendered the
  Section/Row/Column/Container types the builder has supported since
  Milestone 2 — fixed with real recursive rendering plus matching
  submission handling. Added universal field capabilities (Locked,
  Hidden, Read Only, Disabled, Tooltip, Custom Attributes, HTML ID
  override, stored validation rules) and a new settings taxonomy
  (General/Appearance/Validation/Advanced/Accessibility/Developer).
- **5B — Toggle, Image Choice, DateTime, Rich Text.** Four new field
  types built on the 5A contract, each with real front-end rendering and
  native-control accessibility.
- **5C — Commerce & Calculation fields.** Product, Price, Quantity,
  Coupon, Shipping, Tax, Discount, Subtotal, Total, Formula, Math Field —
  11 new types, plus a real client-side running-total calculator
  (`assets/js/frontend.js`'s `computeCommerce()`) that replaced the
  pre-existing Payment Summary field's hardcoded `$0.00` with a live
  figure. Math Field is a real two-operand live calculation; Formula
  deliberately stores an unevaluated expression (real parsing is
  Milestone 8's job).
- **5D — Repeater, Tabs, Accordion.** The most architecturally demanding
  phase: Tabs/Accordion extend the existing row→column two-level
  container pattern (`tab_pane`/`accordion_panel` children), and
  Repeater needed no special builder-side handling at all — its
  children are just its template. Front end: Accordion is native
  `<details>/<summary>` (zero JS), Tabs adds click-to-switch on top of
  fully-server-rendered panes, and Repeater renders real array-indexed
  rows with an "Add Another" that clones a hidden `<template>`.
- **5E — Hardening.** A field-by-field/container-by-container
  Compatibility Matrix (`docs/FIELD_COMPATIBILITY_MATRIX.md`), a new
  automated Playwright suite for every Milestone 5 field type and
  container (`tests/milestone5-fields.playwright.js`), and a real
  performance measurement at 300 fields (initial render ~4-5.5s,
  interaction stays fast at ~70-170ms — confirms the canvas needs
  virtualization at scale, not yet built).

All phases verified end-to-end in WordPress Playground (real front-end
pages, real submissions, real REST-stored entries) plus all three
Playwright regression suites passing clean. Known, intentional gaps
(front-end enforcement of Read Only/Disabled/Tooltip, validation/formula
evaluation, repeater-row conditional logic) are documented in
`docs/FIELD_COMPATIBILITY_MATRIX.md`, not silently dropped.

Commits: `87217e8` `ad1f2fd` `a8c20f7` `d1550dc` `1331d41` and this
release's closing commit.

## Milestone 4 — Design System Foundation (v0.23.0)

Consolidated the plugin's scattered one-off colors/shadows/radii/spacing
values (23 distinct box-shadows, 13 ad-hoc font sizes, etc.) into one
real token scale in `assets/css/admin.css`'s `.chargeforms-wrap` root:
elevation (`--fc-shadow-xs` through `--fc-shadow-xl` plus a dedicated
selection-ring shadow), radius, spacing, type scale, and motion durations
— documented in `docs/DESIGN_SYSTEM.md`. Existing rules migrated onto the
new tokens opportunistically rather than in one unverified sweep.

Commit: `b03aa67`.

## Milestone 3 — Interaction Layer (v0.22.0)

Selection system with true multi-select (Ctrl/Cmd-click, Shift-click
range), a centered floating toolbar for the active selection
(Copy/Paste/Duplicate/Settings/Delete), keyboard navigation (arrow keys,
Escape to deselect, Delete/Backspace), and clipboard (copy/paste across
sections/columns). Verified via a dedicated Playwright suite
(`tests/milestone3-features.playwright.js`) covering multi-select,
grouped undo/redo for batch operations, and clipboard round-trips.

Commit: `b4844b5`.

## Milestone 2 — Layout Engine (v0.2x)

Section/Row/Column/Container nesting in the builder — unlimited depth
(`core/tree.js` never assumed a depth limit), Add Row/Add Column/Split
Column actions, drag-and-drop across nested drop zones via `@dnd-kit`'s
`pointerWithin` collision detection. This is the data-model and
builder-side foundation Milestone 5A's front-end-rendering fix and
Milestone 5D's Tabs/Accordion/Repeater both built directly on top of
without needing any reducer or tree-utility changes.

Commit: `1bb6d70`.

## Milestone 1 — Architecture Foundation (v0.1x)

The core builder architecture: a pure reducer over `{id, title, fields}`,
full-snapshot undo/redo history with `immediate`/`debounced`/`none`
strategies per action, autosave, keyboard shortcuts, `@dnd-kit`-based
drag-and-drop, and the original field-type registry
(`registerFieldType()`/`getFieldType()`/`getAllFieldTypes()`) every
later milestone's field work — including all of Milestone 5 — was built
to extend rather than replace. Established the reducer-purity rule (id/
seed generation happens in action creators, never in the reducer) after
finding and fixing a real bug where reducer-generated random ids broke
field-selection-after-add.

Commit: `a90ea4e`.

