# Changelog

All notable changes to BW Lead Attribution Intelligence are documented here.

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

## [Unreleased]

## [1.4.1] - 2026-08-06

### Security
- **The handoff CORS allow-list was not actually restricting anything.** Any origin
  received `Access-Control-Allow-Origin` — plus `Access-Control-Allow-Credentials:
  true` — regardless of the configured allow-list.

  The cause was WordPress core, not the allow-list logic. Core's
  `rest_send_cors_headers()` echoes **any** `Origin` back with credentials enabled,
  and it is registered on the same `rest_pre_serve_request` hook the plugin uses. The
  plugin only ever *added* headers for permitted origins; it never removed core's
  permissive ones for an origin it rejected. So a rejected origin was declined by the
  plugin and then allowed by core anyway.

  This was invisible from an allow-listed origin — the response looked exactly right.
  It only shows up when probing with a rejected origin, where core's broader
  `Access-Control-Allow-Headers` value is the giveaway.

  Fixed by removing core's CORS handler for the handoff namespace only
  (`rest_pre_dispatch`), so the plugin is the sole source of CORS headers on these
  routes. Every other REST route keeps core's behaviour untouched. A rejected origin
  now receives no CORS headers at all. As a backstop, the handler also runs at a later
  priority and explicitly strips any permissive headers another plugin may have set.

- **`Access-Control-Allow-Credentials` is no longer advertised at all**, on any
  origin. Nothing in the design uses credentials — the destination script fetches with
  them omitted — and advertising them widened what a page on an allow-listed origin
  could do with these routes. Core sets it unconditionally, so it is now removed
  explicitly.

  **Impact was bounded and remains so.** These routes are useless without a token, and
  tokens are 128-bit from a CSPRNG, stored hashed, single-use for the claim, and
  short-lived. CORS was always defence in depth rather than the control — as the class
  docblock says. The problem was that the defence the code documented was not actually
  present.

### Added
- **`tests/cors-check.sh`** — 13 assertions covering the above: a rejected origin gets
  no grant, an allow-listed origin gets exactly its own origin and never a wildcard,
  credentials are never advertised, preflight behaves, and a request with no `Origin`
  gets nothing. Exits non-zero on failure. Validated by reproducing the 1.4.0 failure
  mode, where 7 of the 13 fail.

### Fixed
- **Documentation gave the wrong journey-viewer URL.** `docs/HANDOFF-PLAN.md` showed
  `admin.php?page=bw-lead-ai-journey`, but the page is a hidden submenu of Settings and
  `BW_Lead_AI_Handoff_Admin::viewer_url()` correctly builds
  `options-general.php?page=bw-lead-ai-journey`. Anyone wiring a destination from the
  document rather than the code would have written dead links into every stored record.
  The Handoff settings tab always printed the correct URL; only the plan document was
  wrong. It now says so, and points at the settings tab as the authoritative source.

## [1.4.0] - 2026-08-05

### Added
- **Cross-domain handoff** — carry a visitor's attribution across to a form on a
  different domain (an enrolment portal, CRM, booking tool, off-domain checkout).
  New **Handoff** settings tab, **off by default**.

  A short-lived random token rides on the outbound link. **The journey itself is
  never in the URL** — no browsing history in the address bar, in browser history,
  or in the destination's access logs.

- **Two modes**, chosen independently:
  - **Journey link** (recommended) — only the opaque token crosses domains. The
    destination stores a link; your team follows it and reads the journey here,
    signed in to WordPress. **No visitor data leaves the server.**
  - **Data handoff** — the destination exchanges the token for the datapoints you
    tick, and fills its own fields. Gated behind an explicit acknowledgment
    checkbox, because this sends visitor data to a third-party origin.

  Running both is the recommended shape: minimal attribution fields to the CRM so it
  can report, full journey kept at home behind login.

- **Confirm-to-persist.** A record is minted pending on click and deleted within the
  token lifetime unless the destination reports a submission. Only journeys a real
  person produced are kept, so a page with heavy click-through does not accumulate
  storage.

- **Retention is configurable, including Unlimited.** Lead cycles can run years and a
  link that dies before the lead converts is a broken feature. Confirmed journeys
  arrive at roughly the rate of form submissions, so even a decade is a few thousand
  small rows. The pending lifetime is separate and stays short — that one is an abuse
  control, not a business setting.

- Endpoints: `POST /wp-json/bw-lead-ai/v1/handoff` (create/refresh, same-origin),
  `GET …/handoff/<token>` (claim, single-use), `POST …/handoff/<token>/confirm`.
  Configurable link parameter (default `bwlai`), destination domains, allowed
  origins, token lifetime and datapoint set. The Handoff tab prints the exact
  endpoints and journey-link format to hand to whoever manages the destination's
  tag manager.

### Security
- Routes are **registered only when the feature is enabled** — with handoff off they
  do not exist, so a default install's attack surface is unchanged.
- Tokens are 128-bit from a CSPRNG and **stored only as a sha256 hash**, so a
  database dump or backup leak contains no usable tokens.
- The data claim is **single-use**: the first successful read burns it. That token
  ends up in the destination's URL bar, history, logs and readable by every other
  script on their page, so the exposure window is one round trip rather than the full
  lifetime. Retry-on-404 still works, because retries only matter when the first read
  failed.
- **CORS is an allow-list, never `*`**, with `Vary: Origin` and `no-store`. Stated
  plainly in the code: CORS is not authentication — it stops browser JavaScript on
  other origins and nothing else. Token secrecy is the actual control.
- The server **filters the payload to the enabled datapoints**, so a tampered front
  end cannot widen what gets sent. Size caps per field and overall.
- Rate limited per client without recording who they are: counters are keyed by a
  salted daily hash, never a stored IP, preserving the plugin's no-IP-logging rule.
- Expiry is enforced **at read time**, so a late or missing cron run is a housekeeping
  problem rather than a security one.
- Missing, expired and already-claimed all return an identical 404, so the endpoint
  cannot be used to probe which tokens existed.
- Everything rendered in the journey viewer is escaped on output — UTM values,
  referrers and page paths are attacker-controlled strings.

### Notes
- **First custom table**, created lazily when the feature is first enabled — never on
  activation. Sites that do not use handoff never get it. Dropped on uninstall, along
  with the prune cron.
- **No REST nonce on the create endpoint, deliberately.** It runs on public, cacheable
  pages; a page cache or CDN would serve a stale nonce and the feature would fail
  silently for a subset of visitors — the exact failure mode this design guards
  against. It is treated as public and defended with rate limits, payload caps,
  server-side filtering and a global pending ceiling instead.
- Debug mode logs every handoff step to the browser console, so a silent failure is
  diagnosable rather than invisible for weeks.
- 54 server-side verification checks covering gating, filtering, single-use claim,
  idempotent confirm, retention including unlimited, prune, read-time expiry, origin
  normalisation, cross-tab setting persistence and viewer escaping.

## [1.3.0] - 2026-08-04

### Added
- **Google Analytics client ID capture** — a new Settings → Google Analytics section
  with one checkbox, **off by default**. When enabled, the GA4 client ID (the
  anonymous per-browser identifier Google stores in the `_ga` cookie) is exposed as
  the merge tag `{bw:ga_client_id}`, appears as an "Analytics" group in the BW Lead
  Data field dropdown, and is listed in the Test tab.

  Sending the client ID with a lead lets that lead be joined back to its GA4 session
  in reporting or BigQuery — useful when a visitor converts on a different domain and
  Google's own cross-domain linker is misconfigured or has dropped the parameter.

### Notes
- **The value is never stored by this plugin.** It is read from the `_ga` cookie at
  the moment a merge tag resolves, so it only ever flows where the site owner points
  it — a form field, or a future handoff payload. Reading live also means a visitor
  who cleared cookies yields the identifier that will actually match downstream, not
  a stale one captured on their first visit.
- **This is not a fallback for Google Analytics being blocked.** If GA is blocked the
  `_ga` cookie does not exist either, and the tag resolves empty. Its value is the
  case where GA *is* running but cross-domain linking has failed. The plugin's own
  first-party attribution is unaffected by GA being blocked, and remains the answer
  in that scenario.
- Parsing handles the `GA1.<domainDepth>.<clientId>` format including domain-depth
  variants, ignores the unrelated `_ga_<MEASUREMENT_ID>` GA4 session cookies, and
  returns empty rather than erroring on a malformed value. Seven acceptance checks.

## [1.2.3] - 2026-07-27

### Fixed
- **Summary values displayed as unreadable HTML in Gravity Forms entries.** A
  submitted Summary showed up as one run-on block full of literal `<br />` text
  instead of a formatted, line-broken summary. Notification emails using
  `{all_fields}` had the same problem.

  Cause: the field never implemented `get_value_entry_detail()`, so it inherited
  `GF_Field`'s default, which does the escaping in the wrong order —

  ```php
  $value  = nl2br( (string) $value );   // \n       ->  <br />
  $return = esc_html( $value );         // <br />   ->  &lt;br /&gt;
  ```

  — inserting the tags and *then* escaping them. `GF_Field_Textarea` gets the order
  right, and every other core field type is single-line, so nothing in Gravity Forms
  itself trips over it. The field now implements the method and mirrors the textarea
  behaviour: escape first, then break.

  Affects only the two Summary data points; single-line points such as Channel and
  Source / Medium rendered correctly all along, since `nl2br` on a string with no
  newlines does nothing. This bug has been present since the field was introduced in
  1.1.0 and is unrelated to the 1.2.2 markup change.

  Plain-text notifications and CSV exports are unchanged — they receive the raw
  value, where real newlines are what's wanted. Entry values are still fully escaped,
  so there is no change to how untrusted content is handled.

## [1.2.2] - 2026-07-27

### Changed
- **Summary data points now render as `<input type="hidden">` instead of a hidden
  `<textarea>`**, making every BW Lead Data field the same shape on the front end.

  This is integration hardening rather than a bug fix — the old markup was hidden
  correctly and never leaked on its own. But third-party code that walks a form and
  skips `input[type=hidden]` *by type* — a common shortcut — would sail straight past
  a hidden `<textarea>` and treat Summary as a user-facing field. A multi-page form
  with a custom "review your answers" step hit exactly that and displayed the
  attribution summary back to the visitor. Channel was never affected, because it
  was already a hidden input; that asymmetry is what made the symptom confusing.

  Line breaks are unaffected. The HTML spec applies no value sanitization algorithm
  to `type=hidden`, so newlines survive both `.value` and form submission
  byte-identically to a textarea — verified in a browser, and covered by the
  acceptance suite. (`type=text` *does* strip them, which is where the "inputs can't
  hold multiline" assumption comes from.) Entry detail and debug mode still render a
  `<textarea>`, where the value is meant to be read and edited.

### Added
- **README now documents the integration contract** for theme and plugin developers.
  A BW Lead Data field is necessarily `gfield_visibility_visible` — a field the
  browser populates has to render — and its wrapper carries **`gform_hidden`**. Code
  deciding "is this field user-facing?" must key on `gform_hidden`, not on
  `gfield_visibility_hidden` and not on an inline `display:none` (which only appears
  for Gravity Forms conditional logic). Also documents the sharp edge that debug mode
  swaps `gform_hidden` for `bw-lead-ai-debug-visible`.
- README filled out generally — feature list, full merge-tag reference, and FAQ
  covering non-Gravity-Forms use and what is stored client-side. It was a stub.
- **`tests/acceptance.js`** — a dependency-free acceptance suite (`node
  tests/acceptance.js`, 70 checks) covering the resolution cascade, referrer
  classification, visit-history capping, custom dimensions, merge-tag substitution,
  legacy selector targets and interaction storage. Exits non-zero so it can gate a
  release; excluded from the release zip. Added in response to acceptance criterion
  R3 having sat unverified since 0.5.0 while hiding the 1.2.1 referrer bug.
- SPEC.md's acceptance table now records **how** each row was verified — `auto`,
  `wp-cli` or `browser` — so an unchecked box is never ambiguous about what is
  actually missing.

## [1.2.1] - 2026-07-26

### Fixed
- **Organic search and social referrers were never classified correctly.** This is
  the significant one. `hostMatches()` only tested whether a referring hostname
  *ended* with `.<configured value>` — true for the nonsense host `mail.google`,
  false for the real-world `www.google.com`. So no entry in Default Referrer
  Classification ever matched, and **every** untagged search or social visit fell
  through to the catch-all: source became the full hostname and medium became
  `referral`.

  In practice a Google organic visit was recorded as `www.google.com / referral`
  instead of `google / organic`, and its channel resolved to the bare hostname
  instead of "Google Organic" / "Organic Search". Facebook, Bing, LinkedIn and every
  other configured host behaved the same way. Only tagged traffic (UTMs, click IDs)
  and genuinely direct traffic were unaffected — which is why it went unnoticed:
  acceptance criterion R3 had never been run, and the `{bw:source} : */referral`
  channel rule was quietly absorbing all of it and looking plausible.

  Matching now handles both config styles properly: a value with a dot matches the
  host exactly or as a suffix (`openai.com` matches `chat.openai.com`), and a bare
  value matches any whole dot-separated label (`google` matches `www.google.com` and
  `google.co.uk`, but not `notgoogle.com`). As a side effect, Facebook's link shims
  (`l.facebook.com`, `m.facebook.com`) now classify as social too. Covered by 15 unit
  checks.

  **What this changes for existing sites:** organic and social leads captured from
  now on will show the correct source and medium. Leads already captured keep
  whatever was stored in that visitor's browser at the time.

### Added
- **Simulated test links** — a new accordion in the Test tab with a ready-made set
  of 20 incoming links covering every traffic type the plugin has to tell apart:
  fully tagged campaigns, one row per configured click ID, minimal and untagged
  URLs, referrer-driven arrivals, and edge cases (UTM + gclid conflict, this site's
  own non-UTM aliases, custom dimensions).
- Enter a landing path and every link regenerates live against the current domain.
  Each row shows what it **resolves to** — channel, source / medium, campaign, click
  ID, custom dimensions — computed against *this site's* actual settings, so the
  panel doubles as a configuration check. Copy and Open buttons per row.
- Scenarios are generated from live config, so a site with a customised click-ID
  table, non-UTM aliases or custom dimensions gets links matching its own setup.
- Referrer-driven scenarios are marked **preview only** and deliberately have no
  Open button: a link cannot forge a referrer, so opening one would record a Direct
  visit and quietly show the wrong result.
- `{bw:channel}`, `{bw:first_channel}` and the interaction tags are now listed in
  the Test tab's resolved-merge-tag table. `channel` had been missing since 0.8.x.

### Changed
- `BWLeadAI.simulateUrl( url, referrer )` takes an optional second argument to
  simulate arriving from an external site. `resolveChannel` and `hostMatches` are
  now exposed on the public API; `events.js` uses the shared `hostMatches` so social
  matching and referrer classification can't drift apart.

## [1.2.0] - 2026-07-26

### Added
- **Interaction tracking** — a new **Interactions** settings tab that records what
  visitors did on the way to converting, and attaches it to the lead alongside the
  traffic source. Nine interaction types, **every one off by default**:

  | Type | What it records |
  |---|---|
  | Video plays | First play plus progress milestones, for HTML5 `<video>`, YouTube and Vimeo |
  | File downloads | Clicks on links ending in a configured extension (pdf, doc, xls, zip…) |
  | Phone clicks | `tel:` links |
  | Email clicks | `mailto:` links |
  | Social clicks | Links to configured social hostnames |
  | Outbound clicks | Links to any other external site |
  | Custom actions | Elements you name yourself by CSS selector |
  | Scroll depth | How far down the page the visitor read |
  | Form starts | First interaction with any form field |

- **Interactions appear in `{bw:summary}`** — a count on the stats line, a
  breakdown beneath it, and an `== Interactions ==` section listing what happened.
  In `{bw:summary_detailed}` they are interleaved into the journey instead, so the
  visit reads as one chronological story.
- **New merge tags:** `{bw:events}` (total), `{bw:events_list}` (breakdown), and
  `{bw:event.<type>}` for a single type's count, e.g. `{bw:event.video}`. These are
  registered with Gravity Forms — and shown in the BW Lead Data field's dropdown
  under an "Interactions" group — only once at least one type is enabled.
- **Video milestones** (default 25/50/75/100%) make a real watch distinguishable
  from an accidental click. Configurable, or empty to record plays only.
- **Custom actions** let an admin define events without a plugin update, e.g.
  `Pricing CTA : .pricing-cta, #get-quote`. A custom match wins over the generic
  link classification, so clicking a selector that is also an outbound link records
  one event, not two.

### Fixed
- **Saving one settings tab no longer resets settings owned by another tab.**
  All tabs post to the same option and the sanitizer falls back to the *default*
  for any key missing from the POST, so saving the Settings tab silently wiped the
  Form Fields tab's legacy CSS-selector targets back to empty. Every tab now
  round-trips the settings it doesn't render through hidden inputs, including the
  nested `field_targets` values. This bug predates 1.2.0 and affected 1.0.x.

### Notes
- **Sites that enable nothing pay nothing.** With no interaction types ticked,
  `events.js` is never enqueued, no listeners are attached, no third-party video
  APIs are loaded, and the summary is byte-for-byte what it was before.
- **YouTube tracking rewrites embed URLs.** The IFrame API only talks to players
  that opted in with `enablejsapi=1`, so when video tracking is on, YouTube iframes
  get that parameter appended — which reloads the embed once, on page load, before
  the visitor can press play. Vimeo needs no such change (it uses postMessage).
- Interaction records are capped in browser storage (first 5 + last 25) the same way
  visits are, but the **counts are stored separately and never trimmed**, so totals
  stay accurate over a long multi-visit journey even after old records age out.
- Nothing is sent to the server and no new database tables are involved — same
  client-side-only model as the rest of the plugin.

## [1.1.0] - 2026-07-26

### Added
- **New Gravity Forms field type: "BW Lead Data."** Drop it onto a form from the
  Advanced Fields group and pick what it captures from a single dropdown —
  no more adding a Hidden field, opening the merge-tag picker, and hunting for
  the right `{bw:*}` tag. The field is hidden from visitors and submits with the
  entry like any other field.
- The data point dropdown is grouped by what it describes: **Latest visit**
  (source, medium, source / medium, channel, campaign, term, content, ad group,
  landing page, submit page), **First visit** (first source, first medium, first
  channel, first landing page), **Counts** (visits, pages viewed, tagged visits),
  and **Summary** (summary, detailed summary).
- **Custom dimensions appear in the dropdown automatically.** Any key declared in
  Settings → Parameter Aliases (e.g. `match_type`) shows up under a "Custom
  dimensions" group with no code change required.
- The field **picks its own input type**: a hidden `<input>` for single-value data
  points, and a hidden `<textarea>` for the two multi-line Summary points so the
  line breaks survive submission intact.
- The field uses the **analytics icon** in the form editor rather than the generic
  hidden-field icon, so it reads as a tracking field at a glance.
- The form editor **preview updates live** when the data point dropdown changes.
  Gravity Forms does not re-render a custom field's preview on a property change,
  so without this the preview kept showing the previously selected data point until
  the page was reloaded — the field saved correctly, but the editor looked wrong.
- **Debug mode now reveals BW Lead Data fields on the front end.** With Settings →
  Debug enabled, a user with `manage_options` sees each field rendered visibly as
  a read-only box showing the live captured value, which data point it holds, and
  its merge tag. Everyone else still sees nothing. The capability is filterable
  via `bw_lead_ai_debug_capability` for sites that want to widen it.

### Notes
- **No front-end JavaScript was added.** The field simply seeds its value with the
  matching `{bw:*}` merge tag; the existing capture script already scans every
  `input` and `textarea` on load, on DOM mutation, and at submit, so it resolves
  these fields with no changes.
- **Conditional logic is deliberately unsupported on this field.** Its value is
  written by the browser after page load, so any rule evaluated against it would
  run before the value exists. Gravity Forms therefore does not offer it as a
  conditional-logic source.
- Merge tags are unchanged and still work everywhere they did before — in
  notification bodies, confirmation text, Hidden field defaults, and the legacy
  CSS-selector targets for non-Gravity Forms setups. The new field is a
  convenience layer, not a replacement.

## [1.0.1] - 2026-04-13

### Changed
- **Default Click-ID Inference now includes `gbraid` and `wbraid`** on the
  `google/cpc` row. These are Google Ads' privacy-safe click identifiers used
  on iOS 14.5+ where Apple's App Tracking Transparency blocks the standard
  `gclid` mechanism. `gbraid` covers app-to-web clicks (ad tapped inside an
  iOS app) and `wbraid` covers web-to-app clicks. Without these in the
  Click-ID list, iOS-origin Google Ads traffic was getting misattributed as
  direct or falling through to referrer classification.
- Existing installs keep their current click-IDs — defaults only apply to
  fresh installs. Update the `google/cpc` row in Settings → Click-ID
  Inference manually if you're already running 1.0.0 and want the new params.

## [1.0.0] - 2026-04-13

First stable release. Graduates the plugin from pre-1.0 pilot status now that the
unified mapping format, channel resolution, custom dimensions, and full summary
output have all settled down and tested end-to-end on a live Gravity Forms site.

### Fixed
- **Source/medium separator no longer loses its spaces on save.** The default
  separator is `" / "` (with spaces), but WordPress's `sanitize_text_field()`
  trims whitespace, so the first time a user hit Save Changes the separator
  silently became `"/"` and `{bw:source_medium}` rendered as `google/cpc`
  instead of `google / cpc`. The separator now uses a dedicated sanitizer that
  strips tags and control characters but preserves leading and trailing
  whitespace. Users can keep `" / "`, `" • "`, `" — "`, or whatever they like.
- **Invalid Parameter Aliases rows are now stripped on save, not just hidden on
  render.** Previously, saving a row with a reserved merge-tag label (e.g.
  `summary : foo, bar`) would emit a validation error but still write the row
  to the database — the parser dropped it on render, so the UI stayed clean,
  but the stored option carried a zombie row forever. Validation now
  surfaces the error AND strips the offending row from the saved string, so
  the stored state matches what the UI shows.
- **Test tab custom-dimension display label.** The URL simulator result table
  showed custom dimensions as `custom.match_type` — a leftover from before the
  0.8.0 format change when tags were `{bw:custom.key}`. It now shows the
  unprefixed `{bw:match_type}` label so the Test tab matches what the Help tab
  lists and what you actually type into hidden form fields.

### Notes
- No schema or settings migrations required. Sites already running 0.8.x will
  keep their existing settings as-is; the separator fix only applies the next
  time the settings form is saved. Users with a mangled `"/"` separator can
  open Settings, retype `" / "` into the Formatting & Debug section, and save.

## [0.8.2] - 2026-04-13

### Fixed
- **Channel rule labels containing `{bw:source}` / `{bw:medium}` now parse correctly.**
  The labeled-list parser split lines at the first `:` it saw, which ate the colon
  inside the `{bw:source}` substitution token and broke the `{bw:source} : */referral`
  default rule. Any channel rule whose label contained a brace-wrapped token
  silently failed to match, falling back to the raw `source / medium` string.
  The parser now tracks `{...}` brace depth and splits at the first `:` that sits
  outside any group, so labels like `{bw:source}` round-trip cleanly. This bug was
  present in 0.7.0 but only surfaced now because referrer-medium visits rarely hit
  the default rule order during earlier testing.

## [0.8.1] - 2026-04-13

### Changed
- **Summary format polish.** The attribution and journey blocks now surface custom
  dimensions alongside the standard extras, so any dimension you've defined in
  Parameter Aliases flows into `{bw:summary}` / `{bw:summary_detailed}` automatically.
  - Source / medium line is lowercase (`source: google | medium: cpc`).
  - Extras line uses the real tag keys (`campaign: test | match_type: test match type`)
    instead of the previous human-labeled format (`Campaign: X | Keyword: Y`). This
    lets custom dimensions slot in naturally.
  - New `Channels: A, B, C` line under the stats block, listing distinct channels
    walked during the journey in most-recent-first order.
  - Stats label tweak: `Visits - 4` instead of `Visits: 4`.
  - Journey `Tag Info` line now shows source/medium with parentheses stripped
    (`source: direct`), omits `(none)` medium, and still collapses to
    `Tag Info - none` when a visit has nothing meaningful to report.
- **Parameter Aliases UI: Source and Medium promoted to dedicated inputs.** These
  two dimensions are required for attribution to work, so they no longer live in the
  shared textarea where they can be accidentally deleted or renamed. The "Other
  dimensions & custom parameters" textarea below handles `campaign`, `term`,
  `content`, `adgroup`, and custom dimensions. Internally everything still flows
  into the same `parameter_aliases` setting — the split is UI-only. Clearing the
  source or medium input falls back to the defaults on save.

## [0.8.0] - 2026-04-13

### Changed
- **Unified mapping format.** All mapping-style settings (Parameter Aliases, Referrer
  Classification, Click-ID Inference, Channel Mappings) now share one format:
  `label : value1, value2, ...`. One textarea per section, one rule per line, walked
  top-to-bottom. Easier to copy-paste between sites and easier to extend.
- **Parameter Aliases merged with Custom Dimensions.** The old Custom Dimensions section
  is gone — custom dimensions are now just rows in Parameter Aliases with a non-standard
  label (anything other than `source`, `medium`, `campaign`, `term`, `content`, `adgroup`).
- **Custom-dimension merge tags are now unprefixed.** `{bw:match_type}` replaces
  `{bw:custom.match_type}`. Validation on save rejects custom-dimension keys that collide
  with reserved merge tag names (e.g. `source_medium`, `channel`, `summary`).
- **Click-ID Inference flipped orientation.** Was `gclid|google|cpc` (one param per line).
  Now `google/cpc : gclid, gclsrc` (label is the resulting source/medium, values are the
  params). Consistent with every other section — label on the left is always the result.
- **"Referrer Classification" renamed to "Default Referrer Classification"** with a note
  that it is only a fallback — explicit UTMs and click-IDs always override it. The label
  on each row is the medium the visit will be assigned (e.g. `organic`, `social`, `ai`),
  making it trivial to add new categories without code changes.

### Added
- Help tab "All available merge tags" table now lists every custom dimension you've
  defined, showing its merge tag and which URL parameters populate it. No more generic
  `{bw:custom.key}` placeholder.
- Custom dimensions are now registered in the Gravity Forms merge-tag dropdown, so they
  appear alongside the built-ins when building forms.

### Removed
- Settings keys `source_parameters`, `medium_parameters`, `campaign_parameters`,
  `term_parameters`, `content_parameters`, `adgroup_parameters`, `organic_sources`,
  `social_sources`, `custom_dimensions`. Replaced by `parameter_aliases`,
  `referrer_classification`, and the updated `click_ids` format.

### Upgrade notes
- No migration: old settings keys are silently dropped on first save and defaults are
  used for any new keys that aren't present. Any custom aliases, referrer sources, or
  click-IDs must be re-entered in the new format. Acceptable because 0.7.x has not
  shipped outside the test site.

## [0.7.0] - 2026-04-13

### Added
- **Channel mappings.** New Settings section that maps raw `source / medium` pairs to
  friendly channel labels (e.g. `google / cpc` → "Google Ads", `* / referral` →
  "`{bw:source}`"). Rules are ordered, first match wins, wildcards (`*`) are supported
  on either side, and labels can substitute `{bw:source}` / `{bw:medium}` with the
  actual visit values. Ships with sensible defaults covering the common ad networks,
  organic search, email, social, direct, referral, and unknown buckets.
- New merge tag `{bw:channel}` — friendly channel label for the last-touch visit, with
  fallback to raw source / medium if no rule matches.
- New merge tag `{bw:first_channel}` — friendly channel label for the first-touch visit.

### Changed
- Summary attribution block uses the channel label on the `Converted via - ...` and
  `Originally found via - ...` header lines. Raw source/medium still appears on the
  second line (`Source: google | Medium: cpc`) for machine-readable consumption.
- The Source/Medium and Campaign/Keyword/Content lines now use `:` separators
  throughout (`Source: google | Medium: cpc`) instead of the mixed ` - ` / `:` format —
  these are data lines, not header labels.
- Detailed and default journey visit headers now show the channel label
  (`2026-04-13 08:13 - Google Ads`) instead of raw source / medium.

## [0.6.1] - 2026-04-13

### Added
- **Submission tracking.** The capture script now records every form submission against
  the visit it happened in. Visits that converted are annotated with "Submitted on: <url>"
  in the summary, and sessions that converted more than once list each conversion on its
  own visit.
- New merge tag `{bw:summary_detailed}` — same attribution block as `{bw:summary}` but
  the journey expands each touchpoint into the full page list captured inside that
  visit, with submitted pages flagged inline as `*submitted*`.
- Detailed journey now includes a `Tag Info - ...` line per touchpoint showing the raw
  source/medium/campaign/keyword/content attribution (or `Tag Info - none` for untagged
  visits).
- Summary `Days to Conversion:` stat, computed from the first-visit timestamp.

### Changed
- `{bw:summary}` attribution block uses ` - ` label separators
  (`Converted via - google / cpc`), adds an explicit `Source - X | Medium: Y` line to
  each touchpoint, renames `Tagged:` to `Tagged Visits:`, and drops the `Sources:` line
  (redundant with the per-visit journey rows).
- Default `{bw:summary}` journey is now a single line per touchpoint
  (`DATE - source / medium -> landing page`). Numeric index prefixes (`1.`, `2.`) and
  `--` separators were removed.
- Detailed journey drops the `Pages:` header and per-page indentation — pages list flush
  against the visit header, with submissions flagged inline as `... *submitted*`.
- `Campaign / Keyword / Content` line now uses the leading-label format
  (`Campaign - spring-sale | Keyword: shoes | Content: test-sd`).
- Field renamed `Submitted from` → `Submitted on` to match the per-visit annotation.
- View storage cap raised from 10 to 50 entries so the detailed journey can cover longer
  sessions.
- Visits and views now carry a millisecond `ts` so views can be grouped under the visit
  they belong to.

## [0.6.0] - 2026-04-12

### Added
- New merge tag `{bw:last_page}` — the most recent landing page URL (complements `{bw:first_page}`).
- New merge tag `{bw:submit_page}` — resolves live to the current page URL at form submission
  time, so you always know which page the lead converted on.
- **Summary redesign.** `{bw:summary}` now produces a structured, human-readable report
  optimized for CRM textarea fields: conversion source at top, first-touch comparison when
  different, stats line, and a numbered journey with only non-empty dimensions shown.
- **Test tab: inline URL simulator.** Type any URL and click "Simulate" to see how the plugin
  would classify it — without navigating or storing anything. Biggest UX improvement.
- Test tab: resolved values and summary counters now display as clean key/value tables instead
  of raw JSON.
- Test tab: "Clear all tracking storage" now asks for confirmation before wiping data.
- Test tab: visit history and raw storage are now in collapsible `<details>` sections.
- Settings tab: sections (Parameters, Referrers, Click-IDs, Custom Dims, Formatting) are now
  collapsible for better first-time comprehension.
- Form Fields tab: prominent info notice for Gravity Forms users to use merge tags instead.
- Help tab: "Which merge tag should I use?" decision tree with common CRM scenarios.
- Help tab: full merge tag reference table with descriptions.
- Help tab: "How attribution works" section explaining first-touch / last-touch / full-journey.
- UTM Builder: "Open tracked URL" button on each item for quick testing.
- `BWLeadAI.simulateUrl(url)` public API method for programmatic URL resolution.

### Changed
- Capture script moved from `<head>` to footer — no longer blocks page rendering.
- Summary counter updates batched into a single localStorage read-write cycle (was 2-4 cycles).
- MutationObserver debounced via `requestAnimationFrame` to avoid redundant work on rapid DOM changes.
- Visit/view storage keys include a monotonic counter to prevent collisions within the same ms.

### Fixed
- **Critical:** Mid-session re-tagging silently dropped. Tagged visits (UTMs/click IDs) with
  a different source or campaign now always update `last` and record a new visit entry, even
  within the same browser session. This ensures last-touch attribution is correct for
  retargeting, email campaigns, and multi-step funnels. `original` (first touch) is preserved.
- Referrer hostname matching used substring (`indexOf`), causing false positives (e.g.
  "mybloggoogles.com" matched "google"). Now uses exact hostname or dot-boundary suffix match.
- `getParams()` split URL values on every `=`, truncating values like `utm_campaign=a=b=c` to
  just `a`. Now uses `indexOf('=')` + `slice` to preserve the full value.
- Default click-ID table mapped `fbclid` to `cpc`, but Facebook appends `fbclid` to all
  outbound clicks (organic posts, shares, etc.), not just paid ads. Changed default to
  `facebook / social`. Paid Facebook ads should have explicit `utm_medium=cpc` which wins
  via the resolution cascade.
- MutationObserver for dynamically injected forms used a stale state snapshot from page load.
  Now calls `buildState()` on each mutation for fresh data.

### Removed
- Dead `URL_LC` variable in capture.js (defined but never used).

## [0.5.0] - 2026-04-11

### Added
- Phase 1 rebuild from the legacy `bw-user-analytics` plugin.
- Vanilla-JS capture engine (no jQuery / underscore / js-cookie dependencies).
- Click-ID inference table (gclid, fbclid, msclkid, dclid, ttclid, li_fat_id, twclid, yclid,
  gclsrc, gsrc) configurable from the Settings tab.
- Explicit source/medium resolution cascade: explicit UTM → click-ID → referrer classification
  → direct. Respects user-defined alias order so `utm_` wins over custom conventions.
- Custom dimensions: admin can declare arbitrary tracked parameters (e.g. `match_type`)
  available as `{bw:custom.<key>}` merge tags.
- Gravity Forms integration: `{bw:source}`, `{bw:medium}`, `{bw:source_medium}`,
  `{bw:campaign}`, `{bw:term}`, `{bw:content}`, `{bw:adgroup}`, `{bw:first_page}`,
  `{bw:first_source}`, `{bw:first_medium}`, `{bw:visits}`, `{bw:pages}`,
  `{bw:tagged_visits}`, `{bw:summary}`, `{bw:custom.<key>}`. Tags are replaced client-side
  in hidden-field default values before submission.
- Admin **Test** tab: live inspection of storage, resolved merge tags, summary counters,
  visit history, and a URL simulator that opens any test URL in a new tab.
- Legacy CSS-selector form-field targeting retained in the **Form Fields** tab for non-GF
  form plugins.
- UTM Tracking URL builder retained (migrated from legacy plugin), cleaned up and rewritten
  in vanilla JS.
- Configurable source/medium separator (default ` / `) for the combined `{bw:source_medium}`
  merge tag.

### Changed
- Replaced self-hosted updater with the shared `plugin-update-checker` framework pointing at
  `plugins.bowden.works`.
- Option names moved from `bw_uac_settings` / `bw_utm_tracking` to `bw_lead_ai_settings` /
  `bw_lead_ai_utm_tracking`. No migration — this is a clean rewrite for new client sites.
- Storage key prefix changed from `bw_` to `bw_lai_` to avoid collision with any legacy
  install still present on the same origin.
- REST endpoint `/bw-lead-ai/v1/links` now requires `manage_options`; the legacy endpoint
  was publicly readable.
- IP address no longer included in the summary dump by default (privacy; was unauthenticated
  header-trusting in the legacy plugin).
- Referrer classification uses hostname matching, not substring-in-URL.

### Fixed
- `getParams()` silently dropped values when a URL parameter appeared more than once (bug in
  legacy `app.js`: referenced `value` instead of `val`).
- Multiple output paths in the admin UI lacked escaping and sanitization.
- CSS-selector injection risk in the hide-tracking-fields style block is now mitigated by a
  permissive allow-list filter.

## [0.1.0] - 2026-04-11

### Added
- Initial scaffold.
