# 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.10.3] - 2026-08-14

### Security
- **The enquirer's details no longer travel in a URL.** The generated
  cross-domain tag reported a submission by putting the person's name, email
  address and phone number in the confirm URL's query string. It worked — and it
  wrote all three, in plaintext, into the web server access log of every host the
  request passed through, on every submission. Those logs are rotated, backed up
  and shipped around by tooling that has no idea it is carrying personal data,
  and the exposure outlives the decision because nobody remembers the logs
  contain it.

  It could not be fixed on the receiving end: an access-log line is written
  before any of that server's code runs. The tag now sends the details in the
  request body instead. The URL carries the opaque token, which is what it was
  designed to carry.

  **Regenerate and re-paste the tag on the destination site to pick this up.**
  A tag already installed keeps working exactly as before — including sending
  details in the query string — until it is replaced. Nothing breaks if you
  wait; nothing improves either.

  Also fixed in the same place: on a browser without `sendBeacon`, the fallback
  now carries the details too. It would otherwise have recorded the enquiry with
  no name on it, silently, on exactly the browsers nobody tests.

## [1.10.2] - 2026-08-13

### Fixed
- **The weekly chart is back on a new site.** It was never removed — it withheld
  itself twice over: once because a site with under ten leads short-circuits the
  whole page, and again because a site whose leads all landed in one week has
  only a single bar to draw. The result was a dashboard with its top half
  missing, which reads as broken rather than as new. It now draws from the first
  lead, and on an all-time view it reaches back far enough to look like a chart
  with room to fill.

  The page still says, in words, that a handful of leads is too few to read a
  trend from. That sentence is what stops three bars being over-read; withholding
  the chart as well was belt and braces at the cost of the screen looking wrong on
  the day somebody first opens it.

### Changed
- **The breakdown table is four columns**: the dimension, Leads, Share and Pages
  read. **Came back** and **Assisted** are gone — both were answering questions
  better asked another way, and the table is meant to be the simple half of the
  dashboard.

- **The expander names the difference, and only shows one when there is a
  difference.** It now reads **"Originally came through X"** on a last-click view
  and **"Converted through X"** on a first-click one. Rows where a lead arrived
  and enquired through the same bucket are no longer listed — that was the
  commonest entry and it only restated the row above it, so the thing worth
  seeing was buried under it. Those leads are still counted in the row's own
  totals; nothing left the arithmetic.

  A row whose leads all came and went the same way no longer offers an expander
  at all, rather than opening onto a single line saying so.

## [1.10.1] - 2026-08-13

### Changed
- **A journey now reads newest-first.** The question in front of somebody opening
  one is almost always "what did they just do", and on a long journey the answer
  was at the bottom of the page. Visits are listed most recent first, and so are
  the events inside each visit.

### Fixed
- **The times column could run backwards.** An enquiry was positioned by the last
  thing the browser had told us about before it, while being *labelled* with the
  time it actually happened — and those are not the same instant. Anything the
  browser reported after the fact, such as a form-start that fired before the
  submission but arrived after it, then appeared below a row showing an earlier
  time. A journey read 10:38, 10:40, 10:38, 10:40 and the order of events was
  impossible to follow.

  It is now positioned by the same clock it is labelled with. Which visit an
  enquiry belongs to is unchanged — still decided without comparing the visitor's
  clock to the server's — and the position is bounded so an enquiry cannot be
  pushed out of its own visit by a device whose clock is wrong.

## [1.10.0] - 2026-08-13

### Added
- **Two capabilities, so reading enquiries no longer requires running the site.**
  Everything here used to need `manage_options` — which is not "can see lead
  reports", it is every Settings screen on the site and the capability most
  plugins treat as "is this an administrator". Somebody whose job is to read
  enquiries had to be handed the keys to everything to do it.

  - **`bw_lead_ai_view`** — journeys, reports, the attribution screens, Help.
  - **`bw_lead_ai_manage`** — settings, cross-domain configuration, retention,
    the setup wizard, imports, exports, resets, reprocessing and purging.

  The split is the point. Somebody reading enquiries should not be one wrong
  click from switching on data handoff, widening what is collected or shortening
  how long it is kept — those have privacy consequences and none of them is a
  decision that belongs to the person reading the reports.

  **Nobody loses a screen.** Anyone who can `manage_options` holds both
  capabilities automatically, so every existing site — including any role
  somebody built with `manage_options` — keeps working with nothing to do. Both
  are also added to the administrator role so they appear in whatever tool you
  use to assign capabilities.

  The read capability is what the top-level menu is gated on, so it is visible to
  the people it is for; the settings entries inside it are gated separately.
  Opening Settings as a read-only user now lands on Reports rather than a blank
  screen.

  Both names are filterable — `bw_lead_ai_view_capability` and
  `bw_lead_ai_manage_capability`. The older `bw_lead_ai_view_journey_capability`
  still works and now narrows journeys alone.

### Fixed
- **A read-only user could be offered a menu item that led to a blank page.**
  When WordPress refuses a submenu entry it adds a link back to the parent
  carrying the parent's capability — and the parent's screen is Settings, which
  renders nothing for somebody who cannot configure. Found by testing the menu as
  a read-only user rather than by reading the code.

## [1.9.0] - 2026-08-12

### Added
- **One person is one lead, however many times they enquire.** A second enquiry
  from the same browser now joins the journey they already have instead of
  starting a new one. Somebody using a contact form to ask three follow-up
  questions has not been won four times, and counting them that way was
  inflating both the lead total and the channels that happened to bring them
  back.

  **Credit does not move.** It stays with the first enquiry and what led to it —
  how they originally found the site, and the source of the visit they first
  enquired on. Everything after that is recorded and shown on their journey and
  takes no credit, because it did not win the lead. Without that rule a visitor
  who returned through an ad and sent a second message would hand that ad a lead
  it played no part in, which is the exact failure this plugin exists to prevent.

  Every enquiry appears on the journey in its place, each with its own form name
  and time, so the follow-ups are still there to read — they are simply not
  counted as acquisitions.

  **Links already handed out keep working.** A journey link is written into a
  CRM record the moment somebody converts, and those outlive everything else
  here, so the joined submission's link still opens the journey it became part
  of rather than breaking.

  This applies whatever the update setting says, including "Never — keep it
  exactly as it was saved". Whether a saved journey's *browsing history* keeps
  being rewritten and whether a second enquiry attaches to the person who made
  it are two different questions, and that setting only ever answered the first.
  A snapshot journey still keeps its frozen pageviews; it now also knows they
  came back.

### Changed
- **Sites set to "Never update a saved journey" now set the same first-party
  cookie the other two settings do.** It is what lets a second enquiry be
  recognised as the same person. Nothing else about that setting changed — the
  browser is still never asked to report a pageview, so it still costs nothing
  per page.

- Two pieces of copy on the Journey Storage tab were describing the old
  behaviour and are corrected: the "Never" option no longer says a resubmit
  gives you a second separate journey, and the section now states the credit
  rule up front rather than leaving it to be inferred from a report.

## [1.8.7] - 2026-08-12

### Changed
- **"How much was watched" is a checkbox you can see, not an empty text box.**
  A video can report one interaction — they played it — or that plus how far
  they got, and for most sites the first is the useful one: four thresholds turn
  a single watch into five records, which crowds genuinely interesting
  interactions out of a journey. That choice already existed, but only as
  "delete the contents of this field", mentioned in help text nobody opens. It
  is now a checkbox on the Interactions tab with the percentages behind it, and
  scroll depth got the same treatment for the same reason.

  Nothing stored changed and nothing migrates: the setting is still the list of
  percentages, still empty for off, and the checkbox is a reading of it rather
  than a second thing to keep in step. **Existing sites keep exactly what they
  have** — a site with the default four thresholds still has them, ticked.

### Fixed
- **A setting you deliberately emptied could have come back on.** When a save
  did not carry one of the interaction lists at all, it fell back to the shipped
  default rather than to what was stored — so an empty list, which is now a
  deliberate "record the play, not the percentages", would have been refilled.
  No screen does that today (every tab passes these through), so this was a trap
  waiting for the first one that forgot rather than a live bug; it is now
  impossible either way, and the round-trip test checks it on every run.

## [1.8.6] - 2026-08-12

### Fixed
- **YouTube embeds report again — they had stopped on any page whose theme also
  drives the player.** The IFrame API binds one player object per embed, and
  building a second takes the events away from the first. Silently: no error,
  no warning, the tracking simply goes quiet. Any theme, page builder or popup
  that builds its own player wins that race, and a click-to-load video hero —
  a poster and a play button, with the real embed built only when the visitor
  presses it — is exactly that.

  The plugin no longer builds a player at all. An embed reports its own state
  to the page, and that is read passively, so it cannot disturb whoever else is
  driving the video and it works whether that is a theme, another plugin, or
  nobody. Milestones now come off the clock the player sends rather than from
  polling, and **YouTube's API script is no longer loaded onto the page** — one
  less third-party request, and one less thing to slow a page down.

- **A video the visitor presses play on is counted again.** Two ways 1.8.5 got
  this wrong, both in the same direction — a real view going uncounted:

  The click that starts an embedded video happens *inside* the video, and never
  reaches the page around it. Requiring a page interaction first therefore threw
  away genuine plays. An embed is now judged on what can actually be known about
  it: a video hosted elsewhere cannot start itself without saying so in its
  address, and that is readable. A `<video>` on the page keeps the stricter test,
  because there a script can start playback at any moment and the visitor's
  interaction *is* visible.

  And "starts by itself" was read from the address alone, which gets a
  click-to-load video exactly backwards — it says "start playing" *because* the
  visitor just asked it to. What separates the two is when the video appeared:
  one is in the page as it loaded, the other did not exist until somebody
  pressed something.

- **A journey page credits the visit the enquiry was made on.** The reports were
  corrected in 1.8.5 but the journey page itself was not, so it still said
  "came back and enquired through" whatever the visitor's most recent visit
  happened to be — naming a source they never enquired through, on the one
  screen that shows the enquiry in detail. The headline channel, the two
  "how they found us" cards, and the "first visit to enquiry" span all now read
  from the visit they actually enquired on.

  When there are visits after the enquiry the page says so, in place of leaving
  a reader to wonder why the newest source in the timeline below is not the one
  being credited above.

## [1.8.5] - 2026-08-12

### Fixed
- **Last-click credit no longer drifts onto a visit that happened after the
  enquiry.** A journey used to stop the moment someone enquired, so "the last
  thing that brought them here" and "the last thing that brought them here
  before they enquired" were the same sentence. Now that a journey can carry on
  recording afterwards, they are not: someone who enquired off a social post and
  came back a fortnight later through a paid ad handed that ad the credit for an
  enquiry it played no part in. **Paid channels are the ones that collect credit
  this way**, because a returning visitor tends to come back through an ad — so
  the effect is not random, it flatters exactly the channels this plugin exists
  to keep honest. And the better the journey beacon works, the worse it got.

  Every enquiry is now recorded with the channel, source, medium and campaign as
  they stood **at that moment**, and that is what the reports credit. Anything
  the visitor does afterwards is still recorded and still shown on their
  journey — it just cannot take the credit for something that had already
  happened. First-click is unaffected: the first visit never changes.

  Existing journeys keep their current answer until you press **Reprocess**,
  which works out when each stored enquiry happened and gives the credit back.
  It is deliberately cautious: where the moment cannot be established safely,
  the journey is left exactly as it is rather than guessed at.

- **Background videos no longer count as watched.** A muted, looping video
  behind a page heading plays itself, and each one was reporting a "Watched"
  interaction and then a full set of 25/50/75/100% milestones on every pass of
  the loop — so a visitor who watched nothing appeared engaged, on every page
  that had one. A play now counts only when the visitor caused it: they must
  have interacted with the page, and the player must not be one that starts
  itself. A video that starts itself becomes countable the moment it is paused
  and played again, because that second play is the visitor. Covers HTML5
  video, YouTube and Vimeo.

### Added
- **A journey shows when the enquiry happened, and which form.** Previously
  that a visitor had enquired at all was only implied by the journey having been
  saved — a cross-domain journey simply ended on "left for <destination>", with
  no moment of conversion anywhere on the page. There is now a row for it in
  the timeline, in sequence with the pages around it, marked "reported by the
  destination" when that is who told us.

  This is what makes the interesting case readable: someone who leaves for the
  form, does **not** send it, comes back later, sends it, and then keeps
  browsing. That used to read as two departures and nothing else.

  The enquiry is recorded on both paths — a form on this site and a form on a
  destination — so the two finally agree. It is held separately from the
  browsing history, which is what lets it survive: the update beacon replaces
  the stored journey with whatever the browser is holding, and the browser on
  this site never sees a form submitted on another domain.

## [1.8.4] - 2026-08-11

### Changed
- **A journey now hears about the pages a visitor reads after the first one.** The
  update beacon was throttled to one report per browsing session, so in "Every time
  they come back" mode a returning visitor reported their opening pageview and nothing
  else — everything they read afterwards waited for a next visit, and on the last
  visit before they went quiet that tail was never recorded at all. The browser now
  also reports as it leaves each page, using `navigator.sendBeacon`, which is the API
  built for it: the browser owns the request from that point, so it survives the page
  being torn down where an ordinary request would be cancelled with it.

  **Two brakes keep that from becoming a request per page.** A report whose payload is
  identical to the last one sent is dropped without a request at all — a reload, a back
  button and a tab switched away and back all produce one — and otherwise there is at
  most one report a minute. The single exception is the first teardown of a given page,
  which is allowed inside that window because it is the last chance to say what
  happened on it. Five pages read over a few minutes is about five small reports.

  Both teardown signals are listened for. `pagehide` is the correct one, but iOS
  Safari routinely tears a page down without firing it, and `visibilitychange` to
  hidden is what does fire there; on desktop they overlap harmlessly, since whichever
  arrives second finds an unchanged payload and is dropped.

  **The post-submission path is untouched.** After a form submission on a site that
  saves on submission, every pageview still reports however little has changed — that
  is what captures the thank-you page and the thank-you after it, and it is exempt from
  both brakes deliberately. Nothing changed server-side: which reports are accepted is
  still the update mode's decision, enforced on the server.

- **"When is a journey saved?" is one checkbox instead of two options.** They were
  never alternatives. A destination's confirmation saves a journey whichever way the
  setting was pointed, so the only real question — and now the only thing on screen —
  is whether a form submitted on this site saves one too. Nothing stored changed and
  nothing migrates; the automatic default still follows whether a cross-domain
  destination is configured, and an explicit choice still sticks.

- **The Journey Storage tab now says what actually happens, in order.** In place of the
  old options' prose: a journey is held for the real hold time (read from the
  cross-domain setting, not a number written into the sentence), it is saved when the
  form is submitted, and held journeys nobody submits expire on their own. A site with
  no cross-domain destination is no longer told about destinations. The one combination
  that stores nothing — no on-site saving and no destination — now says so on the page
  instead of being discovered by an empty journeys list a month later.

- **The three update options now say how often each one writes**, which is the question
  they were being read for. Never: nothing after the save, and the browser is not asked
  to report at all. Until their visit ends: every page after the submission, then
  nothing more, ever. Every time they come back: every visit, at the cadence above.

### Fixed
- **"Never — keep it exactly as it was saved" implied a resubmit might update the
  journey.** It cannot. Every submission carries its own token and saves its own
  record, so a second enquiry from the same person is a second, separate journey — in
  every mode. The description says that plainly now.

- **The cross-domain wizard forgot which of its two options you had chosen.** The
  answer to "what should happen when they get there" was reconstructed from whether any
  fields were mapped rather than from the answer itself, so a datapoint that stopped
  being shared silently reset the choice and the wizard asked again as though it had
  never been answered. The choice is now recorded when it is made — including when it
  is "just the journey link" — and read back as given.

## [1.8.3] - 2026-08-11

### Fixed
- **Hiding a tracking field left its label behind.** The code looked for the row to
  hide by class name, and form builders commonly wrap the input in an inner container
  whose class also contains "form-field" — so it hid the box around the input and left
  the label sitting beside an empty space. It now finds the field's label, climbs to the
  first ancestor holding both, and collapses any wrapper left holding nothing else. A
  field with no label behaves as before.

## [1.8.2] - 2026-08-11

### Fixed
- **The destination code left its tracking fields visible when there was no journey
  token.** The token check sat above the concealing, so a visitor who reached the form
  any other way — a bookmark, a search result, the back button — saw the raw tracking
  inputs sitting in the form. Hiding them now happens unconditionally, before anything
  that needs a token; the token still gates everything that fills them. Those fields
  are ours, never questions for the visitor, so they are hidden whether or not there
  is a journey to attach.

- **Nothing waited for a form that renders after the page.** The code hid and filled
  the fields in a single pass at load, so an embedded or multi-step builder that draws
  its form afterwards — and a tag manager that injects the code asynchronously — left
  it querying a page with no form in it yet, silently doing nothing. Both the hiding
  and the filling now retry as the page changes, until the fields appear (bounded, so
  it cannot spin on a busy page). The submit listener already handled late forms; the
  field half now does too.

## [1.8.1] - 2026-08-11

### Added
- **The destination code now works on more than one address for this site.** It used to
  bake in a single API URL — the address of whatever site generated it — so a snippet
  written while a site was still on a staging hostname went on calling that hostname
  after launch. Nothing announced it; the hidden fields simply stayed blank. The code now
  carries a list of the addresses this site answers on, reads which one the visitor
  arrived from (`document.referrer`'s origin), and uses it for the claim, the confirm and
  the journey link alike. Absent, unreadable or unrecognised referrers fall back to the
  first address on the list, which is exactly the old behaviour — so one generated copy
  covers before and after a launch and never has to be re-pasted.

  **The list is a boundary, not a hint.** The code never calls an address that is not on
  it, whatever the referrer says, and the remembered choice is re-checked against the list
  on every page rather than trusted from session storage. Entries are reduced to
  `scheme://host[:port]` and anything that will not reduce is dropped — when it is saved
  and again when it is read.

- **New setting: "This site's addresses"** (Cross-domain tab, and a question on the
  wizard's Settings step). Empty by default, one per line, and the tab shows the exact
  list the code will carry so a rejected line is visible rather than silently missing.
  It is not part of the settings export — it names one site's own hostnames.

- **Tag Manager instructions on the wizard's code step.** A Custom HTML tag on the All
  Pages trigger, why it must not be narrowed to the form page — the token arrives on the
  landing page — and that the container has to be published.

### Changed
- **The Cross-domain tab is two named features instead of one pile.** It carried a form
  for cross-domain handoff and, filed under a "Reference" divider at the bottom, the code
  for a landing page on a property you own — a completely different feature that has
  nothing to do with handoff being switched on. Nothing on the page said so. The tab now
  opens with **Form Submissions on External Sites** (the handoff feature) and ends with
  **Cross-domain Source Passthrough from External Landing Pages** (the shared-property
  code, now the accordion "The code"). One visible line under each heading, the detail
  behind the `?`, and nothing loose above or between them.

- **The handoff settings appear only once the feature is switched on.** The Enable
  checkbox comes first, and the wizard prompt, Modes, Destinations, This site's addresses,
  The token and the destination code are hidden until it is ticked — then they appear in
  that order. The state is rendered by the server, so a site with the feature off never
  flashes settings it does not use, and a browser with JavaScript off still gets a page it
  can tick the box on and save. Hidden means `display: none` on fields that are still on
  the page and still submitted; nothing stops rendering, so nothing resets on save. The
  source passthrough at the bottom is a separate feature and is never hidden by it.

- **The wizard's step strip is a way back.** Steps already seen are now controls back to
  them, marked up as buttons on the four steps that have a form (so going back carries
  everything typed so far rather than discarding it) and as links on the final one. Steps
  not yet reached stay inert, the current step carries `aria-current="step"`, and pressing
  Enter in a text field still moves forward rather than back.

- **The generated code carries fewer comments.** The per-`catch` asides — private mode,
  very old browser, replaceState blocked — told the person maintaining the other site
  nothing they could act on, and there were a dozen of them. What is left is what that
  reader needs: the `?bwlaidebug=true` instructions, the rule that a value the visitor
  typed is never overwritten, why the mapped rows are hidden, why the submit report uses
  `sendBeacon`, and why the identity brackets are percent-encoded. The "replace any
  earlier Lead AI tag" warning is kept, as one line instead of three.

- **Code-step and Cross-domain wording.** Instructions written as though every reader were
  migrating from a two-tag setup now say "if that site already has an older Lead AI tag,
  remove it", which is true for the sites it applies to and quiet for the rest.

### Fixed
- **The wizard's Back button from Settings went to "What happens" in journey-link mode**,
  skipping "The fields" — which is where the journey link and the contact fields are named
  in that mode too. It goes to "The fields" now.

### Changed
- **The Reports tab is now just the chart and the table.** The date/credit/
  dimension switchers, then "Leads per week" with its chart, then the breakdown
  table under a heading that is simply the dimension name ("Channel groups",
  "Channels", "Sources", "Mediums"). Everything else is gone from this tab: the
  intro line, the four KPI cards, the per-block descriptions, the cross-bucket
  sentence, the "which form is pulling its weight" block, and the data-quality
  pointer (findings live on the Data Quality tab). 400 lines of the render code
  those pieces used were removed, not just hidden.

- **Every screen's copy rewritten to read in one pass.** The bar: a distracted
  reader takes it in without re-reading. Descriptions are deleted where the
  heading and its control already say it, cut to one short line where a
  first-timer needs orienting, and moved behind the "?" where they are detail for
  the curious. The Data Quality tab is the clearest case — each finding was a
  heading plus a description plus a "what this looks for" paragraph, three copies
  of one idea; now it is a heading, a count, and one short line behind the "?".
  The handoff wizard, the mapping-section intros, and the Cross-domain copy lost
  the most words. Data-honesty caveats and the warnings on reprocess, settings
  import and factory reset were shortened but kept in full.

### Fixed
- **The self-referral data-quality check now uses the real owned-host matcher.**
  It used to approximate the registrable domain and substring-match, which on any
  shared parent domain could report a neighbouring site as your own traffic. It
  now asks `is_owned_host()` — the same rule live capture and reprocess use — and
  its meaning tightened with it: it catches a host your "internal" row claims
  that is still stored as a referral channel, which is exactly the
  reprocess-needed case, and its copy says so. The unmatched-referrer check's
  owned-host exclusion moved to the same matcher, so a lead lands in exactly one
  of the two. Nothing in the plugin reduces a hostname to its registrable domain
  any more.

### Added
- **A new data-quality check: arrivals from your own properties carry no
  original source.** Fires wherever leads are credited to Own Property, because
  the statement is true in both states that produce it — the shared-property
  code is not installed on that property yet, or the visitor arrived there with
  nothing to carry — and the report alone cannot tell which. The detail points
  at the Cross-domain snippet; once it is in place, recovered visitors land in
  their true channel and the count falls on its own.
- **The Clear list on Data Quality is collapsed by default.** Clear checks are
  reference material — the register of what is being watched — not something
  competing with findings; the closed row carries the count.

- **Un-dismissing the last data-quality finding left an empty option row behind.**
  A site that never dismissed anything and a site that dismissed one thing and
  changed its mind should leave the same trace, which is none — the row is now
  deleted rather than written as an empty array.

- **A bookmarked URL for a moved screen 403'd instead of forwarding.** The
  redirect for retired page slugs ran on `admin_init`, but WordPress resolves
  page access in `wp-admin/includes/menu.php`, which `wp-admin/admin.php`
  requires *before* it fires `admin_init` — so an unregistered `page=` had
  already hit "Sorry, you are not allowed to access this page" by the time the
  redirect could run. It now also hooks `admin_page_access_denied`, which fires
  on the line immediately above that `wp_die` and is the only point early
  enough. The routing test now asserts the hook is wired, not merely that the
  handler behaves — it had been calling the method directly, which passed while
  the real page died.

### Changed
- **Editorial pass over every admin screen's copy.** Section intros and help
  expansions across Settings, Reports, Data Quality, AI Analysis, the journeys
  screens, the setup wizard and the Gravity Forms field descriptions are shorter
  and read correctly on a brand-new install — no sentence presupposes an earlier
  incident, an import, or a previous version, and per-section intros no longer
  repeat the page-level format/order rules. Data-quality "what this looks for"
  lines describe each check in the timeless present. The "Why?" help toggle is
  now a small circled "?" icon ("×" when open) with an accessible label. Two
  stale "Direct" references on the Test tab now say "Untracked", and two stale
  settings-path references were corrected. No behaviour changes.
- **Reports is a four-tab screen: Reports · Data Quality · AI Analysis · Journeys.**
  It is wp-admin's own `nav-tab` strip, the same control the settings page uses,
  replacing the Reports | Journeys pills — because these are four different
  screens, not four readings of one table, and that is the distinction the two
  controls now carry: a tab changes what you are looking at, a pill changes how
  the thing in front of you is counted. Two blocks that had grown into screens of
  their own moved off the bottom of the report: the data-quality checks and the AI
  Analysis package. `view=journeys` is unchanged — bookmarks, CRM-stored journey
  links and the moved-slug redirect all still resolve — and an unrecognised `view=`
  falls back to the report rather than rendering nothing. Every tab carries the
  window, the credit mode and the dimension, so a trip to Data Quality and back
  leaves the report exactly as it was set up.
- **The range / credit / dimension pills appear only where they change something.**
  Reports keeps all three. Data Quality and AI Analysis show only the window: no
  check on Data Quality buckets anything, and the AI package is always built per
  channel and by last click whatever the report is showing. An inert control is a
  worse explanation than a sentence, so both tabs say why instead.
- **The Reports tab keeps a one-line data-quality summary** — how many checks
  found something, how many findings are dismissed, and which blocks were withheld
  for thin coverage — so moving the section away did not leave the tab silently
  confident, or a hidden block unexplained.
- **The Lead AI menu is four entries: Reports, UTM Builder, Help, Settings.**
  Two screens that were settings tabs are not settings — the UTM builder writes
  campaign links and Help is a reference table — and as tabs both were only
  reachable by somebody already editing settings. They are their own pages now.
  Bookmarked `&tab=utm` and `&tab=help` links redirect to them.
- **Visitor Journeys is the Journeys subtab of Reports**, not its own menu entry.
  The two screens are one question — how many leads, and who were they — and the
  list is now reached by the same pill control the Reports page uses for its date
  window and its dimension. Every older address for a journey still opens it:
  `page=bw-lead-ai-journeys` redirects and carries the token, the record hash and
  the list's own search, status and page filters, so a journey link stored in a
  CRM years ago opens exactly the journey it always did. On a site with no stored
  journeys — where the Reports page is not registered at all — those links land on
  Settings rather than on "you are not allowed to access this page".
- **The Settings tab formerly called "Journeys" is "Journey Storage".** Two
  entries called Journeys in one menu, one of them settings and one a list of
  records, is one word doing two jobs. The `tab=journeys` slug is unchanged.

### Added
- **Data Quality tab — every check, including the ones that found nothing.**
  The old section only ever showed problems that applied, which meant an empty
  list could not be told apart from a set of checks that never ran, and there was
  no way to find out what the plugin watches for. Every check is now declared in
  one register with a plain-language description of what it looks for and why it
  would matter, shown whether or not it fired. Findings first, dismissed ones
  after, clear ones last. The register is the single source: the aggregate, the
  screen and the AI Analysis package all read it, and the routing test fails a
  register entry with no detector.
- **Dismissing a data-quality finding.** A dismissed finding is de-emphasised but
  never hidden: it stays in the list, marked dismissed, with the numbers it was
  dismissed at, an audit line saying when and by what share, and an un-dismiss
  button. The Reports tab still says how many are dismissed. **If a dismissed
  finding grows by five percentage points or more it lapses back to a finding on
  its own**, showing both numbers — a dismissal is an acceptance of a situation,
  not a permanent silence, and a screen that went on reassuring somebody about a
  problem that had trebled would be the exact failure this tab exists to refuse.
  Share rather than lead count, because a count means different things in
  different windows. Dismissals live in their own option, so they survive a
  factory reset and never travel in a settings export.
- **Export, import and factory reset, on Settings → Other Settings.** Your mapping
  rules as a JSON file you can keep or move to another site: the tracking
  parameter definitions, medium mappings, click-ID mappings, channel mappings,
  channel groupings, the interaction-tracking rules and the Google Client ID
  switch. `{this-domain}` is what makes that work — a rule written with it expands
  against whatever site it lands on, so `internal : *.{this-domain}` still means
  "my own subdomains" after the move.
  - **Nothing site-specific travels**: cross-domain destinations, form field
    mapping and custom interaction selectors describe one site's plumbing.
  - **Nothing that starts collecting or sharing personal data travels** —
    identity capture, submission capture and every cross-domain switch, including
    the owner's recorded acknowledgement. A file somebody pasted in must not be
    able to turn those on for them. `debug` stays behind for the same reason.
  - **An import is checked before it is applied**: it must carry this plugin's
    marker and a schema this version understands, every value goes through the
    same sanitizer the settings screens post through, unknown keys are named and
    dropped rather than silently ignored, and a two-step preview shows exactly
    which rules leave and which arrive before anything is written.
  - **Factory reset** puts every setting back to the shipped defaults and asks for
    the word RESET to be typed. It does not touch stored journeys, leads, saved
    UTM links or dismissed findings, and the screen says so.
- **Form Field Mapping — every merge tag is mappable, including your own custom
  dimensions.** The tab formerly called "Form Fields" offered six targets while
  the plugin exposed twenty-odd tags, and the six were a hand-kept list that could
  not have known about a site's custom dimensions in the first place. It now has
  one row per data point the site actually has: the built-ins, every custom
  dimension declared in Tracking Parameter Definitions, `{bw:ga_client_id}` once
  that capture is on, and the interaction tags once an event type is enabled.
  Each row names the field to fill by id, class, name or CSS selector, exactly as
  before, and every mapping already configured is preserved untouched.
- **One register behind every list of merge tags.** The Gravity Forms merge-tag
  dropdown, the BW field's data-point dropdown, the Help reference table and Form
  Field Mapping all read `BW_Lead_AI_Merge_Tags::available_tags()`. They cannot
  disagree any more — which they could, and did, on exactly the conditional tags
  hardest to notice: a site with interaction tracking on saw `{bw:events}` in one
  dropdown and not the other.
- **Help's merge-tag table now lists the conditional tags too.** Whatever the
  register has that the built-in table does not — custom dimensions, the GA client
  ID, the interaction counts — is appended from the register rather than
  hand-maintained, so the one screen that explains what a tag means can no longer
  omit one the site has.

### Fixed
- **A mapped form field can now hold any data point, not one of six.** The
  front-end resolved mapped fields from a hard-coded table of six values, so
  mapping anything else — a channel, a campaign, a custom dimension — wrote an
  empty string. Mapped fields now resolve through the same path as the matching
  merge tag, so a mapped field and a Gravity Forms hidden field always agree about
  the same lead. `sources` and `terms` keep their own handling: they are plural
  aggregations across every stored visit and have never had a merge tag.
- **A mapping that matches no field on the page no longer clears anything.**

### Removed
- **The "What last/first-click is hiding" block.** The row expanders under
  "Which channels are working" now show the same first-to-last pairs inline,
  per row, at whatever dimension the page is set to — two presentations of one
  fact, and the block was the redundant one. What it also carried, and the
  expanders cannot, is the whole-page total: expanding one row says where that
  row's leads went, and nobody sums twenty of those to learn how much of the
  page crosses buckets at all. That single sentence survives, under the table it
  describes. The pairs themselves are still in the AI package, whose prompt asks
  the assistant to name them, so nothing is lost to analysis.

### Fixed
- **`{this-domain}` now works in Channel Mappings, not only Medium Mappings.**
  A rule naming one of your own hosts to tell one owned property from the rest —
  `Own Property (Landing pages) : get.{this-domain}/internal` — was compared as
  the literal text `get.{this-domain}` and so matched nothing, silently: the rule
  looked right, saved fine, and simply never fired. The token is expanded in
  `parse_channels()`, which feeds both live capture and reprocess, so one fix
  covers both paths. Expansion happens *after* the empty-means-any default, never
  before: a site with no resolvable hostname gets a rule matching nothing rather
  than one matching everything.

### Added
- **"Which hostnames are mine" is now one visible row instead of a hidden rule
  plus a separate box.** Medium Mappings gains an `internal` row —
  `internal : *.{this-domain}` by default — and that row is the whole answer.
  Two new pieces of syntax make it possible to write: `{this-domain}` stands
  for this site's own hostname, so the rule survives being copied between
  staging, live and a client's own domain; and a `*.` prefix means **subdomains
  only**, so `*.example.com` covers `get.example.com` but not the bare
  `example.com`. Both work together. Everything else about hostname matching is
  unchanged — a bare `google` still matches `www.google.co.uk`.

  `{this-domain}` resolves to the site's **own hostname and never the domain
  above it**. That is a safety rule with teeth: sites are routinely siblings
  under one parent — a hosting platform's demo domain, an agency's staging
  domain — and a parent-expanded rule would silently declare every unrelated
  neighbour a property you own, filing their referrals as "Own Property"
  instead of counting them as real acquisitions. Nothing would error and the
  totals would stay plausible, so it is covered by tests on both sides
  (`tests/owned-hosts.php`, `tests/acceptance.js`) that fail if anyone ever
  changes it.

### Changed
- **"Other Owned Properties / Domains" is gone as a separate section.** What it
  held now lives in the `internal` row above, which is the only place the
  plugin reads: the capture script, the shared-property snippet on the
  Cross-domain tab (kept — it is the piece that recovers the ORIGINAL source
  from a property you own) and the reprocess pass all take their list from
  there. **Nothing is lost on upgrade.** Hostnames the old setting was holding
  are folded into the `internal` row at read time, and a site that never had
  the row gets one saying exactly what the old invisible rule said — so what
  matched yesterday matches today. The merge happens at *read* time rather than
  by rewriting your saved settings behind your back: you see the row on the
  Settings screen, and your next save is what makes it permanent. The retired
  value stays in the database untouched.
- **Reprocessing now also applies edits to that row.** Because the owned-host
  list is a setting the plugin re-reads when deriving attribution, adding a
  property you own and then running Reprocess corrects journeys already stored,
  the same way editing a channel rule does. Listed under "Reprocess" on the
  Journeys tab so nobody has to guess whether it is worth pressing.
- **Copy sweep after the section renames.** Every in-product reference to a
  renamed settings section was updated with it — the data-quality finding that
  points at referrer rules, the duplicate-label and reserved-key validation
  errors, the shared-property explainer, and the merge-tag reference table.
  Documentation that names a screen which no longer exists is worse than none,
  because it sends a reader looking for something they will not find. The same
  sweep caught two survivors of the Direct rename in copy outside the reports
  (the merge-tag example and the owned-property explainer).

- **Channel Groupings — a new setting that collects channels into buckets for
  reporting.** One row per group in the same `Group : member, member` shape as
  every other mapping setting, edited with the same row editor, walked in the
  same first-match-wins order. Ships grouping the shipped channels into
  **Paid**, **Organic Search** and **AI**, with a final `{bw:channel} : *`
  catch-all that leaves every channel you have not grouped standing on its own
  — which is what keeps a grouped table adding up to the same total as an
  ungrouped one. `{bw:channel}` is the documented form; a bare `{channel}` is
  accepted as an alias. Grouping is a **reporting** concern only: it is derived
  from the channel label when a report is drawn, never written to a journey, so
  changing it needs no reprocessing and no stored data moves.
- **The "Settings" sub-tab is now "Tracking Rules", and "Other Settings" is its
  own tab.** The label changed; the `tab=settings` slug did not, so bookmarks
  and the tab-save round-trip test are untouched. Tracking Rules now holds only
  the six mapping sections, which is what makes it scannable — a reader working
  down six sections of `label : value` rules no longer meets a checkbox and has
  to work out that it is not a seventh rule. Other Settings sits between UTM
  Builder and Test and holds Capture Google Client ID and Debug Mode.
- **A dimension selector that drives the whole Reports page.** "Break leads
  down by: **Channel Groups** | Channels | Source / Medium | Source | Medium",
  as a third pill row under the page heading with the window and the credit
  switch — where it belongs, because it changes the same amount they do. The
  KPI card, the weekly chart, the performance table and the transitions block
  all re-bucket and rename themselves; a heading, a column header or a help
  sentence never names a dimension the table below it is not showing. Three
  blocks deliberately do not follow it: the AI export (a self-contained
  artifact, always per-channel and last-click — and it now says so on the page),
  Data quality (about tag quality, not attribution) and the per-form table.
  **Channel Groups is the new default** — a channel list on a real site is
  twenty-odd rows, most of them one lead, and the question people arrive with is
  answered by five. It composes with the window and the credit switch: every
  link carries the other two axes, so switching any one preserves the other two.
  Every column is **recomputed over that bucket's own leads** rather than summed
  from the channel rows (a median cannot be summed), and the correctness
  property groups established generalises rather than staying a special case: a
  lead that moved between two channels *inside one bucket* has not changed
  bucket, so it is not a cross-bucket assist and not a move in the transitions
  table — both ends are mapped before they are compared. Row expanders work at
  every dimension and name the other end at the row's own granularity. It began
  as a Channels/Groups switch inside the performance table earlier in this
  release; the parameter name `grouping` is kept rather than adding a second one
  that would mean the same thing.
- **Journeys with no source or medium recorded are bucketed, not dropped.** On
  the three raw dimensions an unplaceable journey lands in a single **Unknown
  source** / **Unknown medium** / **Unknown source / medium** row — the same
  choice the per-form table makes for a journey saved with no form name, and the
  reason those tables add up to every lead in the window. A pair with one half
  missing names the half it has (`google / Unknown medium`) rather than
  rendering as a bare `" / "`. Those labels are invented by the report, so they
  are never linked to a journeys search that would find nothing. Channels and
  groups keep the older and equally deliberate convention — a journey that never
  said which channel brought it is excluded from the ranking and reported in
  Data quality — so "unknown" can never win "biggest channel".
- **Journeys are now saved server-side by a form submission on the site
  itself.** Until now the entire journey-storage pipeline was gated on
  cross-domain handoff being configured: a site whose forms live on its own
  domain — the common case — stamped every entry with a summary and stored no
  journey at all. Storage is now its own thing: on a site with no cross-domain
  destination, the capture script mints a journey token on pages holding a
  Gravity Forms form, stamps it into the form, and the submission promotes the
  record through the exact promotion the cross-domain confirmation uses — same
  `save()`, same retention, the form's title as the record's context, so the
  Reports forms block names it properly. Anonymous unless identity capture is
  on, exactly as before.
- **Two new settings on the Journeys tab govern the journey lifecycle.**
  *When is a journey saved?* — on a form submission on this site, or when the
  cross-domain destination confirms the lead (the only previous behaviour).
  *After a journey is saved, when does it update?* — never (**snapshot**),
  until the visit it was saved in ends (**visit**, the new default), or every
  time the same person returns (**growing** — the old "Keep growing" switch,
  folded in; its checkbox is gone from the Journeys tab and a ticked one reads
  as growing). Both settings resolve automatically until a choice is made:
  save-timing follows whether a destination is configured, and an explicit
  choice, once saved, is never silently rewritten by configuring one.
  **Visit mode is what catches the thank-you redirect chain**: the pages a
  form sends the visitor to arrive moments after the save, so the journey now
  ends where the visitor actually ended, not on the page before the form. The
  visit boundary is enforced server-side — thirty minutes without a beacon
  freezes the record permanently; the browser is never trusted to declare a
  visit still open.
- **The update beacon reports every pageview after a submission** (on sites
  that save at submission) instead of only once per browsing session — the
  once-per-session send has usually been spent before the thank-you page
  exists. The server accepts or refuses each report per the update mode.
- **A saved journey now wins over a newer provisional record when the update
  beacon looks a visitor up.** Right after an on-site save the browser mints a
  fresh provisional token, bound to the same visitor key; newest-first lookup
  handed the beacon that record — which it refuses to grow — and the
  post-submission pages went nowhere. Growing-mode sites get the same fix for
  the return-visit case.
- **First-click / last-click attribution toggle on Reports.** A second pill row
  under the date ranges — *Credit each lead to: Last click | First click* —
  re-answers the channel blocks (the biggest-channel KPI, Leads per week, Which
  channels are working) with the OTHER end of each journey: the channel that
  first found the lead instead of the one it enquired through. Same journeys,
  same totals, other end of the credit. The switched-journey table is one table
  reframed, not a second table: **"What last-click is hiding"** becomes
  **"What first-click is hiding"**, the headline names the channel that closed
  leads it was not credited for, and the *Got the credit* header moves to the
  column that mode actually pays. The channel table's cross column mirrors with
  it — **Assisted** (found leads someone else converted) under last-click,
  **Closed** (converted leads someone else found) under first-click — because
  each mode's blind spot is exactly the other end's work. Plain links carrying
  a whitelisted `attribution` parameter, so either view is bookmarkable and the
  default URL keeps its old shape.

- **Channel rows expand to show the other end of their journeys.** A **+** on a
  row in "Which channels are working" opens subrows splitting that row's leads
  by the opposite end of their journey — under first-click, the channels they
  actually enquired through; under last-click, the channels that first found
  them — plus a **came back through the same channel** line for returners who
  chose the same channel twice (loyalty, not triviality) and a line for
  journeys whose other end was never recorded. Single-visit journeys are the
  implied remainder, not a line of their own. Rows that are nothing but
  single visits get no button. Subrows are server-rendered and merely un-hidden by a
  small dependency-free script, so every number is in the page without
  JavaScript and nothing is fetched on click.

- **The "Part of Direct is arrivals via your own properties" data-quality
  finding is gone**, superseded inside this same version: Own Property is now a
  channel row of its own, so the share that finding existed to explain is
  printed in the table rather than footnoted under it.

- **Two honest names replace "Direct".** An arrival that carried no source
  information at all — an untagged email click, an app that strips the referrer,
  a bookmark, a typed-in address — is now labelled **Untracked**. "Direct"
  implied a person deliberately typing the address; the reality is usually an
  unattributable click, and the old name flattered it. And an arrival from a
  property the site owns is split out as its own channel, **Own Property**
  (`*/internal`), instead of being folded into that bucket — unlike Untracked
  it is *fixable*: the shared-property snippet on the Cross-domain tab carries
  the true source across. Reprocess applies both retroactively, resurrecting
  the own-property evidence preserved in `*_original` for journeys folded by
  an earlier pass. Both are ordinary channel-mapping rows, so a site can
  rename either freely.

- **AI assistants are classified by default.** `chatgpt.com`, `gemini.google.com`,
  `claude.ai`, `perplexity.ai` and Copilot now resolve to an `ai` medium and to
  per-assistant channel labels, with `AI Assistant : */ai` catching whatever comes
  next. This is real traffic — one site had 22 leads from ChatGPT sitting
  unrecognised in the referral bucket.

- **Webmail is classified by default.** A click from Gmail arrives as
  `mail.google.com`, and from the Gmail Android app as `com.google.android.gm`.
  Both contain the label `google`, so both were counted as **organic search**.
  Someone opening your newsletter is not someone finding you on Google.

- **Reprocess journeys.** A new panel under **Settings → Journeys → Stored data**
  that re-derives channel, source and medium on journeys already stored, using this
  site's settings and this build's processing as they are now. A stored journey's
  attribution is frozen at capture time, so fixing a channel mapping, adding a
  referrer rule or listing one of your own domains fixes the future and nothing
  else — the history keeps yesterday's answer forever.

  **It never runs on its own.** A report that quietly rewrites itself between two
  viewings is a report nobody trusts, so this happens only when someone presses the
  button. To make that an informed decision the panel names the processing
  improvements shipped since this site last reprocessed. That list lives in
  `BW_Lead_AI_Reprocess::CHANGES`, and **any future change to how attribution is
  derived must add an entry to it** — without one, a site owner has no way to know
  that reprocessing is now worth doing, and the fix reaches new leads only.

  The values captured on the day are preserved as `*_original`, written once so a
  second pass cannot overwrite them, and the correction is applied everywhere the
  same fact is stored: the flat payload keys the reports read, plus the journey
  blob's first touch, last touch and per-visit entries that the single-journey
  report draws from. Correcting only one of those is not a smaller version of the
  bug — it is the bug plus a headline that contradicts the six lines underneath it.
  On a real 435-journey dataset: 89 leads credited to the site's own `get.`
  subdomain collapsed to Direct and 11 stuck on a truncated `google / d` medium
  resolved to Google Ads. The second pass changed nothing.

- **`tools/import-gf-history.php`** — rebuilds journey history from Gravity Forms
  entries that carry a `{bw:summary}`, so a site that has been running the plugin
  arrives at its reports with real history instead of an empty table. Read-only
  against Gravity Forms, idempotent via a stored `gf_entry_id`, dry run by default.
  A `reclassify` mode re-runs the site's current referrer rules over already
  imported records, because the classification a summary captured is frozen at
  whatever the rules were that day.

- **Reports.** A new screen under **Lead AI → Reports** answering two questions a
  marketer actually has: is this working, and what should I do differently. Six
  blocks, chosen by profiling a real 434-journey dataset rather than by imagining
  what marketing data looks like — which killed four planned blocks before they
  were built (campaign and search-term tables had 1% and 3-distinct-value
  coverage; a landing-page table had 11%; a median-days KPI always read zero).

  It is its own menu page rather than a tab on the settings screen: it is the one
  screen here that is read rather than configured, and behind a settings tab strip
  it was only ever found by somebody already editing settings. The menu now reads
  **Reports, Visitor Journeys, Settings** — the two screens you read, then the one
  you configure — and Reports appears only once the site has stored a journey to
  report on. A bookmarked `&tab=reports` redirects to the new page, carrying its
  date range with it.

  The block that earns its place is **"What last-click is hiding"**: journeys
  where one channel found somebody and another closed them. A form notification
  names only the last touch, so search that finds a lead in March and sees them
  return as Direct in May earns none of the credit — and the budget decision that
  follows is made on a false premise.

  **Data quality is a first-class block, not a footnote.** It names what would
  quietly bend the numbers above it: a site counting its own subdomain as a
  traffic source, unresolved `{CampaignName}` placeholders arriving from an ad
  platform, journeys with no channel at all. Most tools present broken
  measurement as confident numbers.

- **AI Analysis.** A section at the foot of Reports that builds a self-contained
  investigation package — a written brief plus the whole window as data — to paste
  into whatever AI assistant you already use. Copy it, or download it as a markdown
  file. Nothing is sent anywhere by the site; there is no API key and no outbound
  request.

  The bar it is written against is that an assistant which has never seen this
  plugin can produce something **true and specific about your site**, rather than a
  fluent page of advice that would have read identically for anybody. Two things do
  that work. The dataset **leads with its own limitations** — field coverage, every
  data-quality finding verbatim, what Direct actually means here, which journeys
  came from an import and therefore carry no interaction events, and that engagement
  figures are medians — because a reader applies caveats they met first and ignores
  caveats they met last. And the brief bans the specific failures: ground every
  claim in a figure or a page you opened, go and *look* at the landing pages and
  enquiry pages before recommending anything about them, size effects honestly
  rather than inventing percentages, and finish with the questions this data cannot
  answer.

  **It states that it has no denominator, twice.** The plugin only ever sees people
  who converted, so a conversion rate computed from it would be a number divided by
  itself. The tables look exactly like the ones a full-traffic analytics tool
  prints, which is precisely why an assistant handed them without warning will
  produce a rate — so the absence is stated in the data and restated as a rule in
  the brief.

  Beyond the dashboard's six blocks it adds what the aggregate throws away: the
  landing pages journeys actually begin on, which pages recur across journeys and
  which of them the form was submitted from, and twelve journeys quoted in full —
  chosen for spread rather than at random, each saying which bucket it came from,
  because a selected sample presented as a neutral one is its own kind of lie. It
  finishes with real addresses to open.

  **The dataset counts its own measurement artifacts** so the assistant never has
  to deduce them: journeys that begin on a thank-you page (post-submit echoes
  recorded as journey starts — 93 of 435 on the proving dataset), and journeys
  containing WordPress admin pages (staff browsing captured as a lead, which
  otherwise tops every engagement ranking). Echo rows are marked in the landing
  table rather than removed — rewriting the table would hide the artifact the
  limitations section tells the reader to weigh — and staff journeys are labelled
  and kept out of the "highest engagement" samples. Both came out of running the
  v1 package for real and watching the analyst burn its wordcount discovering them.

  **Names and emails are off by default** and replaced with a record number. The
  toggle that includes them says plainly that the file then contains personal data.
  The download is a nonced, capability-checked request and the flag rides in the URL,
  so the file that arrives is the one the button described.

- **Reclassify mode for imported history** — re-runs the site's current referrer
  rules over already-imported journeys, since the classification a summary
  captured is frozen at whatever the rules were that day.

- **Your Own Domains.** Other hostnames you own — a landing-page platform on a
  subdomain, a booking tool, a second site. Arriving from one of these is not an
  acquisition, and crediting it means whatever actually found that visitor gets
  nothing: on a real site that was **a fifth of every lead** attributed to the
  site's own subdomain.

  Listed hostnames are treated exactly as the site's own: the visitor keeps
  whatever source found them earlier, and a visitor whose first contact came that
  way records as direct — an honest "we do not know" rather than a confident
  attribution to ourselves. The trip out and back becomes one journey, with the
  excursion recorded as the single waypoint we can honestly capture, since nothing
  of ours runs on the other property.

  **The site's own hostname is always included and matching covers subdomains**, so
  the common case — a landing platform at `get.example.com` — needs no
  configuration at all. Lookalikes are not caught by accident: matching requires a
  dot boundary, so `notexample.com` is never a subdomain of `example.com`.

- **Traffic through a property you own keeps its original source.** Listing a
  landing-page platform under Your Own Domains stops it being credited as a
  channel, which is right — nobody was acquired from your own landing page — but
  the visit then fell back to **Direct**, and Direct there is a tidier way of
  saying the answer was thrown away. Somebody found that page from somewhere.

  A new **"A property you own"** panel on the Cross-domain tab generates a script
  for that property. On the property it remembers what originally brought the
  visitor there — the page's own tracking parameters if it has them, otherwise the
  hostname that referred them — and appends it to any link pointing back at this
  site. The arrival is then classified as the ad or the search that really found
  them instead of as Direct.

  It works for **any** owned property on **any** platform: no cookies, no shared
  domain, no CORS, and it does not need handoff or continuity switched on. **No
  visitor data travels** — only the source, on your own links. Anything sent this
  way is stripped back out of the address bar once the visit is recorded, so it
  does not leak into browser history, shared links or another tool's page reports.

  **A carried source is only believed when the visitor genuinely came from one of
  your own hostnames.** A parameter naming a source is a claim made by a link, and
  links are written by anyone: without that check, a stranger could put one in an
  email or on their own site and have every click stored as organic search — paid
  traffic laundered into organic, a competitor's referral erased, and nothing in
  the data afterwards to show it happened. Real tracking parameters and click IDs
  always win over a carried source, and a carried hostname naming one of your own
  properties is still refused, so it cannot be used to launder a self-referral. One
  limit is accepted deliberately and recorded in the code: a visitor arriving with
  no referrer at all — common, because redirect chains and native apps strip it —
  cannot be checked, and refusing that case would throw away the ordinary arrivals
  this feature exists to rescue.

  **Not a Reprocess entry, and that is the point.** Reprocess re-derives stored
  attribution from what was captured. This changes what gets captured in the first
  place: a journey already recorded as Direct has no carried source in it to
  re-read, and none can be invented. It reaches new visits only.

### Changed
- **The Settings tab's sections are renamed, reordered and merged**, so the page
  reads in the order the data actually flows: parameters, then medium, then
  source/medium, then channels, then groups. Parameter Aliases → **Tracking
  Parameter Definitions**; Default Referrer Classification → **Medium
  Mappings**; Click-ID Inference → **Source / Medium Mappings**; Your Own
  Domains → **Other Owned Properties / Domains**. Google Analytics and
  Formatting & Debug merge into one **Other Settings** section holding Capture
  Google Client ID, Source / Medium Separator and Debug Mode. Presentation only:
  no option key, no form field name and no stored value changes, and saving the
  tab after the change writes back exactly what was there before.
- **The mapping settings are edited as rows, not as a block of text.** Five
  settings share exactly one shape — `label : value, value` — and all five now
  render as a table of rows with the label in one input and the comma-separated
  values in the other: **Parameter Aliases**, **Default Referrer
  Classification**, **Click-ID Inference** and **Channel Mappings** on the
  Settings tab, and **Custom actions** on the Interactions tab. The defaults
  arrive pre-made as rows; **+** adds one, **−** removes one. Nobody has to know
  that a colon separates the halves, or that a stray one breaks a rule.

  **Row order is editable, because for two of these it is the whole answer.**
  Referrer classification and channel mappings are walked top to bottom and the
  first match wins — `ai` above `organic`, catch-alls at the bottom — so each row
  carries **↑ / ↓** buttons and the order on screen is the order stored. The two
  settings whose order carries no meaning (parameter aliases, custom actions)
  get no arrows rather than a control that decides nothing.

  **Source and Medium cannot be deleted.** Without them nothing can be
  attributed at all, so those two rows are pinned to the top of Parameter
  Aliases with no remove button and an uneditable label. Clearing one falls back
  to the default parameter list, and the rule is enforced when the settings are
  saved rather than only drawn in the UI.

  **Each section keeps an "Edit as text" switch** — for pasting a whole ruleset
  in, and for anything the row parser cannot split. A line with no separator, or
  a comment, is never discarded: it comes back as a row with the whole line in
  the label and nothing in the values, so it is in front of you rather than
  quietly gone. Exactly one of the two editors is ever submitted, so they cannot
  race.

  **The stored format did not change.** Rows are a projection of the same
  newline-delimited text the textarea always held — parsed to draw, folded back
  to save — so attribution, reprocessing, the AI export and the reports are
  untouched, and a row nobody edits is written back byte-for-byte, spacing and
  all. Opening the settings screen and pressing Save changes nothing.

- **The visitor-continuity cookie spans your own subdomains.** It was scoped by
  WordPress's `COOKIE_DOMAIN`, normally an exact host — so a landing subdomain
  running the capture script started its own journey rather than joining the one
  already in progress. It is now scoped to this site's own host with a leading
  `www.` removed, so a site at `www.example.com` also covers `get.example.com`.

  **Deriving the registrable domain would have been the bug, not the fix.** It is
  what "share it across our subdomains" sounds like it means, and on a site that
  owns its whole apex the two agree — but a site that itself lives on a subdomain
  of a shared parent, which is how most sites start life, would scope its cookie to
  that parent and hand its visitor keys to every unrelated site sitting beside it.
  Two businesses' visitors would silently merge into one record. So the scope never
  goes higher than the site's own host, and a site that genuinely owns its apex can
  widen it deliberately through the new `bw_lead_ai_cookie_domain` filter.

  This carries identity across subdomains of one site. Carrying the *source* across
  a property is the separate link mechanism above — they are independent, and the
  cookie is deliberately not used for it: it is HttpOnly and the API never permits
  credentials cross-origin, by design.

- **A visit is now a session, not a browser tab.** A visit used to start whenever a
  browser session appeared that had not been counted yet, which is wrong in both
  directions: `sessionStorage` is per-tab, so opening a link in a second tab counted a
  fresh arrival, and it dies with the window, so closing the browser and coming back
  ten minutes later counted another. A visit is now bounded by **30 minutes of
  inactivity** — the conventional session boundary, chosen so these counts mean the
  same thing as the ones in the analytics tool sitting beside them. The exception is
  preserved exactly: an arrival carrying tracking parameters for a different source or
  campaign — a retargeting click, an email link, a second ad click — still starts its
  own visit mid-session, because that is a genuine new acquisition and whatever paid
  for it has to be able to claim it.

- **"Pages" counts distinct pages within a visit.** A refresh is not another page
  read, and neither is the back button or returning to the page a menu hangs off. On a
  real dataset a fifth of every view stored repeated a URL already in the journey. The
  view is still recorded either way — the timeline is a record of what happened and
  stays truthful; only the counter dedupes, because the counter answers "how much of
  the site did they read?", which is a question about distinct pages.

- **Reprocess brings stored history to the same two definitions**, so the reports are
  not half-measured on one rule and half on another. Consecutive stored visits less
  than half an hour apart merge into one, keeping the FIRST arrival's attribution;
  page counts are recomputed as distinct URLs per merged visit. The values captured on
  the day are preserved once as `visits_original` and `pages_original`, on the same
  write-once rule as the `*_original` attribution keys, and the raw `summary` text is
  untouched and remains the audit. Verified on a real 435-journey dataset: 117
  journeys changed, and a second and third pass changed nothing.

  **Expect engagement figures to step DOWN on the day this lands.** That is a
  correction to how they were measured, not a drop in performance, and the Reprocess
  panel now says so before anyone presses the button. On that dataset the journeys
  that stored more than one visit went from a mean of 3.99 visits to **1.81**, and
  "came back" fell from 30% to 16% on Direct, 19% to 12% on Google Organic and 14% to
  6% on Google Ads.

- **A site's own staff are no longer tracked.** The capture script is not loaded for
  anyone who can `edit_posts`. A marketer's own browsing is not lead behaviour, and it
  does not stay quietly in the background: on a real dataset the single most engaged
  "lead" in the whole table was the site's own staff account, with 111 visits and a
  wp-admin page sitting in the middle of its journey. `edit_posts` is the line rather
  than "logged in", so subscribers and customers — frequently the actual leads on a
  membership or shop site — are still tracked. This changes who is recorded from now
  on and rewrites nothing already stored. One consequence worth knowing: a staff
  member submitting one of the site's own forms will now see the literal `{bw:...}`
  text land in tracking fields, because nothing is loaded to substitute it.

- **Default referrer rules are ordered `ai · email · organic · social`.** The first
  match wins and a bare value matches any whole label, so `google` also matches
  `gemini.google.com` and `mail.google.com`. Ordering resolves that without
  narrowing the `google` rule — restricting it to `www.` subdomains would have
  stopped matching `google.com` and every country domain like `google.co.uk`.

### Fixed
- **A data-quality warning that was itself wrong.** The malformed-medium check
  flagged every ChatGPT and Copilot lead, because `ai` — a medium this plugin
  generates itself — was missing from the list of legitimately short mediums. A
  warning that is wrong teaches the reader to ignore the ones that are right, so
  any medium the plugin can produce is now on that list.

- **Journey storage is no longer gated behind cross-domain handoff.** The Visitor
  Journeys page appears whenever journeys exist, and `capture_identity()` no longer
  requires handoff. `handoff_enabled()` needs a destination domain *and* a mode, so
  a site whose forms live on its own domain could never create the journey table or
  reach the page — the same split-brain the Journeys/Cross-domain tabs fixed in the
  UI, still present in the gates.

- **Engagement figures used a mean and the mean was misleading.** One visitor who
  read 164 pages pulled a channel's "pages read" to 5.9 and made it look like the
  site's highest-intent source; its median was 2.0, the same as organic search.
  Long tails are the norm in web analytics, so this was wrong on principle rather
  than on one dataset. The engagement columns are medians — the typical lead, not
  the average one.

- **Two new data-quality detectors**, each generalising a fault that had to be
  found by hand: `unmatched_referrer` (a channel still shaped like a bare hostname
  means no classification rule claimed it — this would have caught the broken
  hostname matcher automatically) and `malformed_medium` (a one or two letter
  medium is an ad platform firing its template before substituting the value).

- **An en dash silently discarded whole journeys on import.** Some stored
  summaries write "Converted via – Google Ads" with an en dash where the format
  uses a hyphen — most likely `wptexturize` between the form and the database.
  Every prefix test in the parser matches a literal hyphen, so those entries
  parsed to *nothing*: no channel, no landing page, no visits, no journey. They
  did not look broken afterwards, they were simply absent. On one site that was
  22 entries, 9 of them paid ad clicks that then appeared to have no source at
  all — which is exactly the shape of an error that makes someone distrust a
  report they should trust. The separator is normalised before any line is read;
  hyphens inside URLs and campaign names are untouched because they are never
  space-surrounded.

- **Imported journeys duplicated their last visit.** The summary parser appended a
  *reference* to the visit being built, so each new visit wrote through it into
  the element already stored: a three-visit journey became three copies of the
  last one, sharing a timestamp. It presented as a tracking fault and was a parser
  fault. Repaired: unique page views across the dataset went from 179 to 336, and
  the share of page views that were repeats fell from 66% to 39%.

- **First-touch and last-touch could disagree about the same visit.** The importer
  gave `first_channel` a fallback to `channel` but gave `first_source` and
  `first_medium` none, so a later reclassification corrected one end of a journey
  and not the other — inventing 100+ channel switches that never happened. The
  measured rate of genuine first-to-last changes fell from a fabricated 44% to a
  real 10%.

- **Growth percentages are withheld when there is nothing to compare against.**
  A 90-day window on a site with 90 days of history was reporting "+106%" against
  a period that predates the data. A number on a card gets believed; a caveat
  underneath it does not.

### Removed
- **The Source / Medium Separator setting.** `{bw:source_medium}` is always
  `source / medium` with a slash now. It was configurable, and the only thing
  that bought was a way for two sites to disagree about what `google / cpc`
  looks like — including one that saved it as `google/cpc` because an early
  sanitizer trimmed the spaces off and nobody could see why. The literal lives
  in `BW_Lead_AI_Settings::SOURCE_MEDIUM_SEPARATOR`, mirrored by a matching
  constant in `capture.js`. Consumers updated: the merge-tag resolver, the
  summary builder and the channel-label fallback in `capture.js`, the front-end
  config it was passed through, the reprocess pass's channel-label fallback, the
  settings default, its sanitizer and the settings field itself. **A site that
  had customised the separator will see `{bw:source_medium}` render with a slash
  from now on.** The stored value is left alone rather than deleted — it is
  harmless, and deleting a site's saved data for a cosmetic change is not
  warranted — but nothing reads it.

## [1.7.0] - 2026-08-07

### Added
- **The Handoff tab is now two tabs: Journeys and Cross-domain.** Retention, whether
  a journey keeps growing, what a journey stores, and whether a name is kept against
  it apply to every journey — a site that never hands anything to another domain
  still has to answer all four. Filing them under "Handoff" put general settings
  behind a feature most sites never switch on. Cross-domain now holds only what is
  genuinely cross-domain: the modes, the destinations, the token, and the code.

- **A setup wizard for cross-domain handoff, and the code it was never generating.**
  The Handoff tab presented modes, origins, two datapoint lists, a token parameter
  and a lifetime all at once, then offered four bare API URLs under labels like
  "Claim the data" — and never produced the one thing the job actually needs: the
  script to put on the destination site. Users were expected to write that
  themselves from the endpoints.

  The wizard asks questions with answers a marketer already knows — where the form
  lives, what should happen, what the form's fields are called, how long to keep a
  journey — writes every setting, and generates a ready-to-paste snippet. It covers
  the whole feature, not just the cross-domain half, because sending somebody to
  another screen for retention mid-flow is how a setup ends up half-finished. That snippet also appears on the
  settings screen, regenerated from the saved field mapping, so it is always current.

  The generated code avoids CORS preflight entirely: claim is a plain `fetch` with
  no custom headers, and confirm is a `sendBeacon` with its parameters in the query
  string. A JSON body would work — the allow-list permits `Content-Type` and
  `OPTIONS` — but it would make the destination depend on preflight surviving
  whatever CDN or tag manager sits in front of it. `sendBeacon` is also the only
  mechanism that reliably survives the page being torn down by a form submit.

- **Its own place in the admin menu.** "Lead AI" is now a top-level menu with
  Settings and Visitor Journeys under it, rather than an entry buried in Settings.
  It had grown past what a settings submenu can carry.

- **Longer-lived visitor continuity.** A server-set `HttpOnly` first-party cookie
  (`bwlai_vk`, 400-day cap, re-issued on each visit) anchors a returning visitor,
  and a `/journey/beacon` endpoint refreshes it on any page view rather than only
  when a form is filled in. The 400-day cap is a browser ceiling in every major
  engine, so the cookie must be re-issued to survive — it is.

- **Devices and people are separate.** A new leads table resolves a person from the
  email on a submission via `HMAC-SHA256(normalized_email, site_pepper)`, and links
  the device to them. Where a device has already produced a *different* person, it
  is flagged as shared and attribution stops rather than guessing. Link, never
  merge; never rewrite past ownership.

- **"Keep growing"** (Lead AI → Journeys → How long journeys live). Off, a saved
  journey is a snapshot of how someone converted. On, it keeps growing as they come
  back. Retention is counted from the last contact rather than the conversion, so
  an active lead is not deleted mid-relationship.

### Changed
- **Statuses are now Held and Saved** (was pending/confirmed), migrated in place.

- **The admin screens were reviewed in a browser and reworked for scanning.**
  Always-on descriptions became a revealable "Why?" toggle (pure CSS, no
  JavaScript); every tab intro was cut to one sentence; the nine-row Interactions
  list became a compact table; sections start collapsed and each shows *what it is
  currently set to* on its closed row, so the page answers "what is turned on
  here?" without opening anything.

- **Journey report timestamps are the site's timezone and date format.** They were
  rendered with `gmdate()` in the timeline while the header used the site's
  settings, so the same record showed two different clocks.

### Fixed
- **A visitor's second trip to the destination silently filled nothing.** Found live
  on a real deployment: the claim is single-use, and the WordPress-side script keeps
  one token per browsing session — so the first click-through burned it and every
  later trip carried a token that could only 404, with no error anywhere. Two-sided
  fix: the server now refuses to refresh a claimed token (`data_claimed_at IS NULL`)
  and mints a fresh one instead, and the front end reads the answer it was already
  getting — same request, it just stopped discarding the response — swapping in the
  new token and re-decorating.

- **The destination snippet learned everything a live deployment proved necessary.**
  It hides the rows it fills (they are plumbing, not questions); `?bwlaidebug=true`
  keeps them visible with a red outline and narrates progress in the console; it
  dispatches `input`/`change` events so framework-bound forms notice the values; it
  retries a 404 claim twice to cover the click-vs-page-load race; and it strips the
  token from the address bar — only once safely stored, so a refresh keeps working.
  It also now says, in the code and the UI, that it **replaces** earlier Lead AI
  tags: a leftover older tag claims the details first and leaves the new one blank.

- **Reloading the wizard's final step wiped the field mapping.** A bare GET of the
  last step ran the save with nothing collected, writing constructor defaults over
  the real configuration. It now saves only on a posted walk-through; a bookmark or
  reload of that step just re-renders your code from what is saved.

- **The wizard's three settings could be erased by any other form save.** They have
  no inputs on any settings tab — the wizard is their only writer — so the
  sanitizer's default fallback meant a form that failed to pass them through would
  silently reset them. They now fall back to the stored value.

- **"Delete all stored journeys" returned you to the wrong tab** after the split.

- **The journey report was unreachable** under the new top-level menu — following a
  link from the list returned "Sorry, you are not allowed to access this page".
  `admin.php` resolves a page's capability through its `$submenu` entry, and
  `remove_submenu_page()` deletes exactly that, so a hidden page under a top-level
  menu cannot be authorised at all. The report is now a mode of the Visitor
  Journeys page. Every server-side test called `render_viewer()` directly and so
  passed throughout; only loading the real URL caught it.

- **All admin CSS and JavaScript silently stopped loading** on the settings page:
  `enqueue()` compared the hook against a hardcoded `settings_page_…` prefix that
  became `toplevel_page_…` when the menu moved. It now compares against the hook
  `add_menu_page()` returns, so it cannot drift again.

- **Admin assets are versioned by file modification time**, not the plugin version.
  A CSS fix between releases used to sit behind a browser cache keyed to a version
  that had not moved, so the fix appeared not to have worked.

- **Ragged page edges throughout.** WordPress admin leaves `box-sizing` at
  `content-box`, so a `max-width` cap landed a padded card ~50px wider than the
  unpadded block beside it. The report's blocks now share one border-box measure
  and one right edge.

- **One script now replaces the two tags the old instructions implied.** It claims
  the data, fills the mapped fields, and reports the submission itself. The submit
  detection is a capture-phase listener on the document, so it also covers forms
  added to the page later; a form that submits purely in JavaScript without firing a
  submit event needs the confirm call from its own success callback, and the wizard
  says so.

- **Journey-link mode never produced a journey link.** The mode is defined as "let
  the destination store a link back to this site", but nothing asked where that link
  should go and the generated snippet never wrote one — it only confirmed the token,
  so the link existed nowhere the destination's own system could see. The wizard now
  asks for that field first, in both modes, and the snippet writes
  `…/admin.php?page=bw-lead-ai-journeys&token=…` into it. No API call is involved:
  it is this site's own address plus the token the browser already holds.

- **The wizard skipped a step that was still needed.** Choosing "just let me see the
  journey" jumped past field mapping entirely — but that mode still needs one field,
  for the link. Worse, the step list was fixed at four items, so "The fields"
  rendered with a green *completed* tick against a screen that was never shown, while
  the reader sat on the last step looking for a way forward. The step now always
  appears and scales: one field in link mode, the full mapping when datapoints are
  being sent.

- **Settings rows sat flush against the section border** while the prose between
  them was indented. A collapsible section applied its inset with `padding`, which
  a table under `border-collapse: collapse` ignores completely and a form control
  applies to its own text rather than its position. Each element type now gets the
  inset in the form it actually honours.

- CORS: WordPress core's `rest_send_cors_headers()` echoes any origin, which made
  the handoff allow-list decorative. It is now removed on `rest_pre_dispatch`.

- `confirm()` is idempotent — it checked `$wpdb->query()`'s return value, which
  counts *changed* rows, not matched ones.

## [1.6.0] - 2026-08-06

### Added
- **Who the visitor was.** Two new options under Settings → Handoff, **both off by
  default**: record the name and email of whoever submitted, and optionally keep a
  copy of the whole submitted form. The journey report then opens with the person
  rather than a channel, and the browse list gains a "Who" column.

- **Captured server-side, never in the browser.** The obvious implementation — read
  the name and email in JavaScript and keep them beside the journey in
  `localStorage` — was rejected. This plugin otherwise stores no personal data at
  all, which is what lets it say nothing identifying is retained, and why a
  cross-site-scripting flaw on the host site cannot leak anything about a person.
  Identity is instead read from the submission the server already receives
  (`gform_after_submission`), and the only thing the browser contributes is the
  handoff token it already holds.

- **A destination can also supply it.** When the form lives on the third party's own
  domain we never see it, so the confirm endpoint now accepts optional identity
  fields. Keys are a fixed allow-list, the email must actually be one, and everything
  is length-capped.

- Two filters: `bw_lead_ai_extract_identity` and `bw_lead_ai_capture_submission` —
  the latter for dropping a sensitive question before it is stored.

### Security
- **Identity and submissions live in dedicated columns, outside the datapoint
  system.** The claim endpoint only ever reads the datapoint payload, so there is no
  code path by which a name, an email or a form answer can reach a third-party
  origin. That is a structural guarantee rather than a setting anyone could mis-tick,
  and it is covered by a test asserting no trace of either appears in a claim.
- Both options are gated on handoff being enabled, and both default off. The settings
  copy states plainly that these are the only options in the plugin that cause
  personal data to be retained, and points at the retention setting.

### Notes
- Schema version 2 adds two columns; `dbDelta` applies it in place on an existing
  install without touching stored rows.
- Identity is not currently captured for a visitor who never clicks through to a
  destination — there is no journey record to attach it to. That is a natural
  consequence of records being minted by the handoff flow, and is the same limitation
  as journeys generally.

## [1.5.0] - 2026-08-06

### Added
- **Visitor Journeys browse page** (Settings → Visitor Journeys) — every captured
  journey in one list, so you no longer need a link from the destination to find one.
  Filter by confirmed enquiries vs awaiting confirmation, search by channel, campaign
  or page, and click through to the full report. Appears only when handoff is on.

- **The journey report is now a proper report.** Previously it listed raw datapoint
  names and values; the browsing history — the actual reason for the feature — wasn't
  shown at all. It now leads with what matters:
  - **Hero**: the channel they enquired through, when, and how long from first visit
    to enquiry.
  - **How they found us**: first touch and last touch side by side — shown as two
    cards only when they differ, with a note explaining why both deserve credit.
  - **At a glance**: visits, pages viewed, interactions, days to enquiry.
  - **What they did**: a chronological timeline grouped by visit, with pages,
    interactions and form submissions interleaved. "Watched Campus tour (50%)",
    "Downloaded viewbook.pdf", "Submitted a form" — not merge-tag names.
  - Everything else — all captured values, the written summaries, and record
    plumbing — folded into collapsed sections so nothing is hidden but nothing
    clutters.

- **Structured browsing history.** A new **Full browsing history** datapoint captures
  the journey as structured data rather than only as pre-formatted text, which is what
  lets the report render a real timeline instead of parsing a blob back apart. Oversized
  journeys are dropped rather than truncated — half a JSON document is unparseable.

### Changed
- **Stored and shared datapoints are now two separate lists**, and this is the
  important one.

  Previously a single list governed both what a record held *and* what the third-party
  destination could claim. That meant the only way to see a visitor's browsing history
  in the report was to also hand it to the destination — the exact opposite of the
  Journey-link mode's purpose, and directly at odds with the recommended setup
  (minimal fields to the CRM, full history kept behind login).

  The Handoff tab now has a **Store** column and a **Send** column. Store feeds the
  report and never leaves the server; Send is the subset the destination may claim.
  A datapoint can only be sent if it is also stored, enforced on save and again when
  a claim is served. Existing installs keep their current list as the stored set, with
  the same values carried over as the shared set — no change in what any destination
  currently receives.

- Datapoints are labelled in plain language throughout the admin ("Search term",
  "First page visited", "Full browsing history") rather than by merge-tag name.

## [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.
