---
title: "Ändringslogg — Egenverk"
description: "Ändringar i Egenverks tillägg, senaste versionen först."
canonical: https://egenverk.se/andringslogg
lang: sv
updated: 2026-09-29
alternate: https://egenverk.se/en/changelog.md
---
# Ändringslogg

Vad som ändrats i varje tillägg, senaste först.

Ändringsloggens text är på engelska, som i tillägget.

[Atom-flöde](https://egenverk.se/feeds/changelog.xml)

## [Egenverk Conversion Relay](https://egenverk.se/plugins/conversion-relay/changelog)

### 1.4.0 — 2026-09-24

**Ändrat**

- The server reads marketing consent and the ad click from first-party cookies on requests WooCommerce makes anyway, instead of waiting for the browser to send them to `?wc-ajax=egenverk_conversion_relay`: every page view and `wc-ajax` request of a visitor with a WooCommerce session or a login (`init` priority 20, before Kustom's confirmation completes a payment at 999), a cart addition, the order review, the checkout and the moment before a payment completes. Cookiebot's `CookieConsent` is parsed by the plugin itself: `-1` (a visitor outside a consent region, which Cookiebot's script also reports as a grant) is a grant, `0` a refusal, and otherwise only the `marketing` flag of the object Cookiebot writes is read, up to 4 kB; anything else is unknown. WP Consent API's `wp_consent_marketing` cookie is read directly, because `wp_has_consent()` on the server grants when no consent type is set or, in an opt-out region, when there is no cookie: `deny` is a refusal, `allow` a grant only when `wp_has_consent()` agrees, anything else unknown. The click comes from the pixel SDK's `__oppref`, which the SDK replaces on every ad landing, then from the plugin's own cookie `egenverk_conversion_relay_click`, and the browser reference from `__obref`; each is checked like the endpoint checks them, and must be valid UTF-8.
- The consent record is written through the same path as the endpoint, so a refusal still withdraws queued purchases at once, but only when consent or the click changed, or when the record has less than two days left; a copy in the WooCommerce session answers every other request without reading the record (a logged-in customer without a session cookie gets no copy, so no session is stored for it). No session is ever opened for this, unknown consent never changes a record, a refusal waits up to five seconds for a record another request holds, and a click the granted record holds is kept when the cookies lack it, as the browser keeps it until a refusal.
- A cart addition queues its `items_added` event when the consent cookie in that request grants, instead of requiring a record an earlier request had stored.
- The response to a granted cart addition hands the session's additions of the last two minutes to the browser in a two-minute cookie, `egenverk_conversion_relay_added` (host-only, not HttpOnly, `SameSite=Lax`, `Secure` on https, path `/`), each with the event id the server generated, the product and variation, quantity, amount and currency. Carrying every recent addition, rather than the ones the request brought back, keeps an addition whose answer the browser had not read when the next one was posted, and never sets again what the browser sent. The value is at most 1 kB encoded: product names are dropped first, then an addition too large on its own, then the oldest; when nothing fits the cookie is removed. The script reads it on `added_to_cart`, `wc-blocks_added_to_cart` and when a page loads, which covers an addition that reloads the page, measures `items_added` with each event id it has not measured in the last five minutes, and removes the cookie. With refused consent, the pixel off or the event not selected it removes it unmeasured; while consent is unknown, or another script owns the pixel SDK, it leaves it. A refusal the server sees removes it. A refusal also forgets the ids measured, and empties the session's additions, so a later grant does not hand earlier ones over.
- An order is stamped with the consent in the checkout request's own cookie; an order placed without a readable consent cookie is `unknown`, whatever an older record says. The session's unpaid order is re-stamped on every order review and consent change, never above what the request's cookie says. Every capture, through the payment or a paid status, now needs that stamp to be a grant as well as the record, and none happens in a request whose consent cookie refuses. A grant that reaches the server only after the payment completed no longer makes the purchase count, as it could through the payment until now: consent is taken as it was when the order was placed and paid.
- The ad click from the landing URL is kept in the first-party cookie `egenverk_conversion_relay_click` as `<expiry>.<click>` (host-only, path `/`, `SameSite=Lax`, `Secure` on https, not HttpOnly), 30 days from the click, instead of in `localStorage`, which the server cannot read. The script writes it only with a grant and never writes the same click again, so a reload does not extend it; a click 1.3 kept in `localStorage` moves to the cookie with its expiry. With a grant and a click the server sets the same cookie, since Safari keeps a cookie a script writes for seven days only. The expiry it carries is kept in a new session or for a new account, a click only the pixel SDK's `__oppref` carries keeps the expiry the session saw before, and a cookie the browser already holds is not sent again. A refusal removes it on both sides.
- While the server keeps a grant for the browser it sets two marker cookies until the record expires, host-only, `SameSite=Lax`, `Secure` on https: `egenverk_conversion_relay_record`, HttpOnly, which names the record with a signature, and `egenverk_conversion_relay_marker`, which the script reads and which holds only the expiry, so no script sees a stable identifier. A refusal made on a page, which the next request WooCommerce makes would carry anyway, is sent at once as one small request (`client: 4`, `action: refuse`, with `keepalive`, so it survives the tab being closed) when the server may hold a grant: with a cart, a login or that marker. It is sent once per change to a refusal on that page, and on start-up for a refusal made before the script ran while the marker is there; never for a refusal the page request itself carried, and never in answer to another tab. The HttpOnly marker's signed reference to the record lets the refusal reach it after the WooCommerce session has expired, as it does two days after a guest's last visit; it is signed with a key derived from the auth salt, so rotating the salts invalidates every marker, which is then ignored and removed, and such a late refusal is dropped while the record expires by itself. The endpoint answers the refusal with an empty result, never throttles it, removes both markers, and ignores it when the request's consent cookie grants again; the script then sends a refusal made before it started at most once per marker value in a tab session. The order received, pay-for-order and account pages, where nothing is measured, carry the script in a refusal-only mode: no pixel, no page events and no cart additions, only the refusal and its cleanup.
- The script no longer sends grants, clicks, cart and login changes, the preparation before the first cart addition, the daily and 30-minute repeats or the forced sync before checkout, and it no longer listens to other tabs. The synchronization state older versions kept in `localStorage` (`egenverk_conversion_relay_synced`, `egenverk_conversion_relay_granted` and their first names) is removed on the first page view. The pixel, the page events, the configuration in the page and the diagnostics are unchanged. A page cached with HTML from before 1.2.1 measures nothing until the page cache is purged.
- The endpoint still answers scripts from 1.2.x and 1.3.x (`client` 2 and 3) as before for one version, since pages and browser caches keep them for a while: grants, clicks, refusals, the cart preparation and the throttling. That code goes in 1.5.0.
- A refusal the record lock kept from being stored is logged as a warning (`consent_lock_busy`).
- A guest who logs in or creates an account at checkout takes the consent record and click to the account (WooCommerce 8.8 and later), unless the account holds a newer record.

**Åtgärdat**

- The changelog of 1.2.1 and 1.3.2 promised one synchronization before an order is placed, so that a refusal made on the checkout page reaches the server first. The script hooked WooCommerce's `checkout_place_order` event on the checkout form, but Kustom Checkout posts the order straight to `?wc-ajax=checkout` from its own script, so that event never fires and the synchronization never ran for an order placed through Kustom: a refusal made on its checkout page after an earlier grant could leave the order stamped with the grant. The checkout request itself now carries the consent cookie, and the order and the record are both written from it in that request, whichever script posts the order; the hook is gone. A refusal made after the order was placed is read on Kustom's confirmation page before that request captures the payment; if Kustom's server push captured it first, the refusal withdraws the queued purchase if it reaches the server before the purchase is sent, a few seconds after payment.
- Since 1.0.0 a purchase captured through `woocommerce_payment_complete` or Kustom's payment callback was decided by the customer's consent record alone, never by the consent recorded on the order when it was placed, so an order placed with consent unknown or refused was still exported when an older grant was on record. Only the paid-status path (1.2.0) checked the order.

### 1.3.3 — 2026-09-23

**Åtgärdat**

- With the pixel on, the request budget of 1.3.2 (1.2.1 on the old slug) still cost about 0.3 requests per page view on a live store instead of the few its tests measured: a visitor with consent sent one or two on almost every page, a few seconds after it loaded. The script queued a refusal for the pixel SDK before starting it, and the SDK deletes its browser reference (the `__obref` cookie) on a refusal and creates a new one when consent follows. Every page view therefore got a new reference, which the script counts as something the server has to hear. The reference sent to OpenAI also changed on every page, so a visitor's page views and purchase could not be tied together through it. The same refusal deleted the SDK's stored click (`__oppref`) on every page, so pixel events after the landing page lost it, and stored the refusal where the SDK in the visitor's other open tabs reads it, switching their pixel off. The SDK is now started with the grant it is only ever created with, and the reference lasts; a new test replays the SDK's cookie handling over ten page views.

### 1.3.2 — 2026-09-23

**Åtgärdat**

- A store running 1.1.2 received 248,000–286,000 requests a day to `?wc-ajax=convert_relay`, 25–47 % of all PHP requests, and 1.2.0 still about 1.1 per page view, about 3 per page for an active visitor with consent, each answered with 5.5 kB. Every one is an uncached POST that boots WordPress and WooCommerce, and with a small PHP-FPM pool the site and wp-admin slowed down. The cause: the script sent a bootstrap and a sync on every page view of a visitor with consent, a cart, a login or a stored grant, remembered what it had sent only for the page it was on, fetched the configuration, which is the same for every visitor, from the server each time, and received all of it in every answer.
- What the server last received is now kept in the browser (`localStorage` key `egenverk_conversion_relay_synced`; the state 1.2.1 kept under `convert_relay_synced` moves there, so updating from 1.2.1 costs no request): the consent state, the click, whether there was a cart and a login, and an opaque marker of the session it was stored under. A request goes out only when one of those changed: a grant or a refusal (unknown consent is never sent on its own), a new or changed click, a cart or login appearing or going, a cart addition and the forced sync before a classic checkout. An unchanged state is repeated once a day, before the server forgets the record after 30 days, and every 30 minutes for a logged-in customer, whose record every device of the account shares. A visitor without a server session sends nothing further until a cart or a login opens one. Measured by the new tests: 0 requests for 10 page views with unknown or refused consent, 1 for 10 page views after a grant, at most 2 for the first cart addition, 1 before placing an order.
- The configuration every visitor shares (active, pixel, Pixel ID, adapter, events, log level) is printed into the page with a version; the page stays identical for every guest, so it can be cached. An answer carries the configuration only when the page was rendered with other settings, otherwise just the session marker and the cart events.
- The bootstrap and the sync are one request. The session token it needed was handed out to any request that passed the origin and header checks, so it protected nothing further; writes are bound to the WooCommerce session cookie as before, and the diagnostics endpoint no longer asks for a nonce either. The server answers a state it already holds without taking the lock or writing, and touches the session only when there are cart events to collect.
- A browser whose ad blocker stops the pixel SDK reported `pixel_load_failed` on every page view, each report another request that boots WordPress; it now reports each error once a day.
- A tab that sees another tab remove the stored click no longer answers that tab's refusal with its own older grant.
- Pages cached with an older script keep working for one version: 1.2.0 and 1.3.0 scripts still get a token, a log nonce and the answers they read, and 1.2.1 scripts calling the endpoint by the first name are answered as current ones. A page cached with 1.3.0's HTML asks for its configuration once per view until the page cache is purged.

### 1.3.1 — 2026-09-23

**Ändrat**

- The Egenverk symbol is redrawn with the three bars centred in their square, in the admin header signature, the brand files of the UI kit (`assets/egenverk-ui/brand/`), the product icon and the wordpress.org banner and icons.

### 1.3.0 — 2026-09-23

**Ändrat**

- Slug, folder, main file (`egenverk-conversion-relay.php`), text domain and translation files are `egenverk-conversion-relay`; namespace `Egenverk\ConversionRelay`; constants `EGENVERK_CONVERSION_RELAY_*`; options, transients, nonces, Action Scheduler hooks and group, script handles and JavaScript globals use the `egenverk_conversion_relay` / `egenverk-conversion-relay` prefix. The WooCommerce log source is `egenverk-conversion-relay`. Author Egenverk, https://egenverk.se.
- Activation deactivates Convert Relay (any folder whose main file is `convert-relay.php`), which would otherwise measure every event a second time; while both are loaded, this plugin stays idle and says so. Settings, the encrypted API key, the validation result and history and the snapshot warning move to the new option names once per site (activation, and first load on sites a network activation did not reach); values already under the new names win. Jobs queued under the old Action Scheduler hooks are removed; the maintenance job reschedules pending deliveries.
- The tables `{prefix}convert_relay_*`, the order meta `_convert_relay_*` and the encryption context keep their names, so queued purchases and orders need no migration.
- Kept for one version: the `wc-ajax` endpoints `convert_relay` and `convert_relay_log` and the `X-Convert-Relay` header for pages cached with the old script; the filters `convert_relay_*` (through `apply_filters_deprecated`); the `CONVERT_RELAY_API_KEY` constant and environment variable. The browser moves the stored click and grant marker from the old `localStorage` keys.
- The admin page moved to WooCommerce → Settings → Conversion Relay, with the sections Settings, Validation & log and Delivery status, and uses the Egenverk UI kit (`assets/egenverk-ui/`, 1.1.0) with the product icon and the "by egenverk" signature. Settings are saved through WooCommerce's settings form; validation and retry post to their own actions with their own nonce. Old admin URLs redirect to the tab.
- readme.txt describes the plugin as the connection between WooCommerce and ChatGPT Ads; new tags; screenshots, banner and icon in `.wordpress-org/`.
- Uninstall with erase also removes the options of the old name.

### 1.2.0 — 2026-09-23

**Ändrat**

- Purchases are captured on `woocommerce_payment_complete` and on the order statuses `processing` and `completed`, whichever comes first, so cash on delivery, bank transfer and orders marked paid by hand are measured. A capture through a status also requires the consent recorded on the order while it was unpaid to be a grant. WooCommerce Subscriptions renewals are skipped; the filter `convert_relay_capture_order` can exclude further orders.
- Diagnostics are written to the WooCommerce logger (source `convert-relay`) instead of the plugin's own `.php` files in uploads, which security scanners flag. The Validation & log tab links to WooCommerce → Status → Logs. The files, directories and options of the file logger (`convert_relay_log_suffix`, `convert_relay_log_error`, `convert_relay_log_truncated`) are deleted on upgrade; the size budgets, their filters and constants are gone.
- A page contacts the server only when the visitor grants consent, has a cart, is logged in, or the browser holds a grant the server stored (`localStorage` marker `convert_relay_granted`, removed by a refusal or when the server has no session). Before, every page view sent an uncached bootstrap request, even for visitors with unknown or denied consent. Consent given later on the page bootstraps and sends the page events at once.
- The admin notice about a missing consent integration appears only when measurement is enabled, and only on the Plugins screen and the plugin's page.
- `convert_relay_settings` and `convert_relay_version` are autoloaded, and the maintenance schedule is checked once an hour instead of on every request.
- With a persistent object cache the synchronization throttle counts there instead of in transients.
- Admin input is unslashed and sanitized before validation; the delivery states, the header label and a Settings link on the Plugins screen are translatable.
- `phpcs.xml` uses `WordPress-Extra` and `PHPCompatibilityWP` for PHP 7.4+. Header `WC requires at least: 8.2`.

**Åtgärdat**

- An invalid Pixel ID or API key, or unavailable encryption, stopped the save with an error page and discarded the rest of the form. The rest is saved now and a notice names the rejected field. Saving shows a confirmation.
- A key saved before the site's security keys changed silently read as empty; a notice now asks for it again.
- Tables were only created on activation, so a network activation left subsites without tables and a future schema change would not reach sites that update automatically. The upgrade routine runs `dbDelta` on every version change, per site.
- A database without `GET_LOCK` skipped every purchase without a trace. It is logged as an error and shown under Configuration status as Database locks.
- The snapshot failure warning stayed forever; it clears when the next purchase is queued.
- The consent endpoint accepted only the `home_url` origin, so a site whose `site_url` differs got a silent 403. Both are accepted, the filter `convert_relay_allowed_origins` adds more, and a rejected origin is logged at most once an hour.

### 1.1.2 — 2026-09-22

**Åtgärdat**

- A refusal of marketing consent could fail to reach the server. When a session had exceeded the synchronization limit, every further request from the page was answered 429, and a page whose very first request was refused never became ready: it could not even read the consent banner, because the adapter came from the server's answer. A visitor who then withdrew consent on the checkout page placed the order with the earlier grant still stored, so the purchase could be exported, and the pixel was not switched off either. A refusal from a current script is no longer held to the synchronization limit, since it can only stop measurement and is sent only when the state changed; its own limit of 300 a minute per session (`convert_relay_refusal_limit`) only stops a script that repeats refusals to write to the database. A refusal that fails after a 429 is retried, and it removes the stored click even when the server configuration never loaded. A throttled page still becomes ready with measurement off, switches the pixel off on a refusal and sends that refusal, but no grants. The page configuration names the adapter, so consent can be read before the server answers.
- Log files could be downloaded on servers that do not read `.htaccess`, such as nginx, where `.php` in uploads is served as text: the directory name was fixed, so the daily file names were predictable. The directory is now `convert-relay-logs-` followed by 24 random hexadecimal characters kept in the option `convert_relay_log_suffix`. Files in the directories used before, `wp-content/convert-relay-logs/` and `wp-content/uploads/convert-relay-logs/`, are deleted on upgrade together with their guards, and so are directories under another random name. The name is created by the upgrade routine; uninstall removes every directory of this form.
- On a multisite network, uninstalling with erase selected removed only the main site's tables, settings and logs, although every site that activated the plugin has its own. Each site is now handled on its own erase setting.

### 1.1.1 — 2026-09-22

**Ändrat**

- The browser sends a synchronization request only when consent or attribution changed since the last successful one, and at most ten per page view. Cart additions and checkout submission still always synchronize, so their events are collected. After a 429 response the page stops synchronizing.
- A page without a WooCommerce session asks the server once and not again until a cart action, since the answer cannot change before then.
- The endpoint limits synchronization requests to 60 a minute per WooCommerce session and, for scripts older than 1.1.1 without a session, 120 a minute per address, adjustable with the `convert_relay_sync_limit` and `convert_relay_sync_address_limit` filters; the address is only kept as a keyed hash. The first refusal in a window is logged as a warning. The current script identifies itself and receives a 429. Tabs still running an earlier script, which retries on every storage event whatever the status, receive a successful answer with measurement switched off instead: that script then removes the stored click in the granted tab too, which ends the loop without the tab having to be reloaded. Current scripts without a session are not counted per address, because visitors behind one proxy or carrier NAT share it. The counter is a transient, so without a persistent object cache each counted request writes to `wp_options`.
- No script is loaded while measurement is inactive, which is the state after installation.
- The script no longer depends on jQuery. Its cart and checkout hooks attach when jQuery is present, also when a script optimizer runs this script or delivers jQuery after the page has loaded, so block themes do not load jQuery for this plugin.

**Åtgärdat**

- The consent endpoint `?wc-ajax=convert_relay` received a request loop in production: over half of all requests on the store, about 300 a minute from single visitors roughly once a second, each one booting WordPress and WooCommerce. Two open tabs with different consent states triggered each other through `localStorage`. A tab whose consent was still unknown, which is every tab opened before the visitor answered the banner since the banner does not update tabs already open, removed the stored ad click; the granted tab received the `storage` event, wrote the click back with a fresh expiry, and that write fired the event in the first tab again. Every round ran a synchronization request. The stored click is now removed only on a refusal, never on unknown consent. A tab reacts to another tab only when the click actually changed or was removed, and after a removal it no longer re-imports the click from the pixel cookie, so a refusal in one tab is not undone by a tab that still shows the old grant.
- Reloading a landing URL that carried `oppref` restarted the click's 30-day lifetime, although the documentation said a refresh does not extend it. The stored click is only replaced by a different click.

### 1.1.0 — 2026-09-22

**Tillagt**

- WP Consent API as a second consent integration, next to Cookiebot, so the plugin works with the consent banners that support that API. It reads the `marketing` category with `wp_has_consent()` on the server and in the browser, and listens for `wp_listen_for_consent_change` and `wp_consent_type_defined` so page events are sent the moment consent is given, as with Cookiebot. When the banner signals that it sets the consent type in the browser (`waitfor_consent_hook`), consent stays unknown until it has, so a server-side opt-out default cannot grant consent to a visitor in an opt-in region. `wp_has_consent()` reports consent for every category while no banner has declared a consent type, so that case counts as unknown and nothing is measured; once a banner declares opt-in or opt-out, its rule applies. A server-side grant also requires the browser to report one, so either side can only hold measurement back. The plugin registers itself with the API. The option is disabled in the settings while the WP Consent API plugin is inactive, unless it is already the saved choice, and an admin notice says why measurement stops if that plugin is deactivated later.
- `Consent::adapter()` returns the configured integration. The consent endpoint, the browser report endpoint and `Settings::active()` now go through it instead of constructing the Cookiebot adapter directly.

**Ändrat**

- Event log files moved from `wp-content/convert-relay-logs/` to `convert-relay-logs/` in the uploads directory, where WordPress.org expects plugins to write. The directory now carries an `index.php` and an `.htaccess` deny rule in addition to the exit header in every file. Log files in the old directory are deleted on the first request after the upgrade, using the same file name pattern as uninstall, and the old directory is removed if nothing else is left in it. Uninstall with erase selected cleans both locations, and the admin error for a failed write names the directory in use.
- The admin notice asks for a consent integration instead of naming Cookiebot, and the settings text describes any consent tool.
- The Before production card describes payment confirmation for every gateway instead of Kustom's, and the Kustom version appears under Configuration status only when Kustom is installed. Kustom's accepted-authorization handling is unchanged.
- `tools/package.py` refuses to build an installer in which any file matches a local deny list of terms, and no longer packages `README.md`, `docs/`, `.DS_Store` or `__pycache__`; `readme.txt` is the user documentation.
- `readme.txt` rewritten for WordPress.org: it stands on its own, lists every external endpoint with what is sent and when, links to OpenAI's Ad Tools Terms, Conversion Terms and privacy policy, and adds a FAQ. The plugin header gains `Tested up to` and `License URI`.

### 1.0.12 — 2026-09-22

**Åtgärdat**

- The page events waited for the next page load when the visitor accepted marketing cookies on the page they arrived on. `page_viewed`, `contents_viewed` and `checkout_started` were only measured while the script started up, so a visitor who was still looking at the consent banner at that moment was measured from the following page view at the earliest, and an ad click identifier that lived only in the landing URL was gone by then. They are now sent the moment consent is given, in the same visit and with the attribution the landing page carries. Each event still has its own identifier, so the repeated calls that follow a consent change send nothing twice.

### 1.0.11 — 2026-09-21

**Tillagt**

- Optional server matching signals, each behind its own setting and off until an administrator enables it: the customer's IP address with the user agent, and the billing country, city, region and postal code. They raise how often a purchase can be matched to an ad click when no attribution identifier survived the journey. The advertising API documents these as raw values rather than hashed, which the settings screen states, and a value that does not match the documented shape is left out instead of sent, including a two-letter country code that is not part of ISO 3166-1 alpha-2. Both are read from the order when the event is queued and stored encrypted with the rest of the payload, so enabling them adds nothing to what the site already keeps.

### 1.0.10 — 2026-09-21

**Åtgärdat**

- The installer name was hardcoded in `tools/package.py`, so building a release produced a ZIP named after version 1.0.9 whatever the plugin header said. The name is derived from the header now, and the build refuses to run when `readme.txt` disagrees with it.
- Browser events did not match the payloads the measurement pixel documents, and the advertising account reported rejected pixel events with invalid properties. `page_viewed` sent no `contents` at all, where the documentation sends the page itself as a content item; it now carries the page slug, the document title and `content_type: page`. The slug is never a search term or any other visitor input, so a page view cannot carry what somebody typed. `items_added` sent no `amount` or `currency`, neither on the event nor on the item, although both are documented for it; a cart addition is now priced like the checkout and purchase events, from the cart line that was added rather than the catalogue entry, so add-ons, name your price and dynamic pricing report what the customer actually added; an unreadable price drops the amount rather than sending a partial one. A search page reports a fixed id and a fixed name, because the generated document title embeds the query.

### 1.0.9 — 2026-09-21

**Åtgärdat**

- Log retention was enforced by age only, so on a busy store with Info or Debug selected a single half-day file could grow until the disk did: browser reports alone are allowed 1,200 a minute. A file now stops accepting entries at 8 MB and surfaces a notice in the viewer, and maintenance deletes the oldest files once the directory passes 64 MB. Both budgets are filterable, a constant overrides the filter, neither can be set below 64 KB, and a per-file cap above the directory budget is clamped to it, so a limit can neither silently disable logging nor let one file fill the directory.
- The Event files card named 8 MB and 64 MB even when a filter or constant had changed them, so the text was wrong exactly when an administrator read it to make sense of the truncation notice. It now names the limits in force.
- The compiled Swedish catalogue was maintained by hand, so a translated string whose source text changed fell back to English. It is built from the source catalogue now, and the check script fails when either is out of date.

### 1.0.8 — 2026-09-21

**Tillagt**

- Contents items now carry the product name, which the measurement pixel documents as a required field of a contents item and which neither the browser nor the server events populated.
- Purchase items carry their unit price including tax and the currency. `amount` is the unit price because `quantity` is sent alongside it, so the two multiply to the line total. A line total that does not divide evenly by its quantity sends no unit price at all, rather than a truncated one whose multiple misses the line.
- Variation purchases carry a `variant_dict` of at most ten attribute pairs, each name and value bounded to 200 characters. The values are read from the order item, so they are the ones the customer bought: a later catalogue edit, a deleted variation or an "Any …" wildcard cannot change or empty them. Only meta matching an attribute of the parent product is used, so unrelated line item meta such as a gift message is never sent.
- `checkout_started` carries the cart contents and the cart total instead of an empty payload; the first fifty lines are sent.

**Åtgärdat**

- Optional item enrichment is dropped instead of failing the event when a price, attribute or name cannot be read, so a single unreadable line cannot stop a purchase from being measured.

### 1.0.7 — 2026-09-21

**Tillagt**

- Verified the acknowledgement contract against the live API. A response counts as an acknowledgement only when it parses, reports no rejection and carries an integral `accepted_events` of at least one, so a fractional or exponent-notation count is treated as schema drift; the captured response for an accepted event is `{"accepted_events":1}` under HTTP 200. Validation and purchase deliveries therefore report `accepted` instead of marking every HTTP 2xx as unverified, while anything unparseable or rejected still fails closed.
- `tools/ack-probe.php`, a development-only probe that records one API response body so the contract can be re-checked if the API changes. It is not part of the installer.

### 1.0.6 — 2026-09-21

**Åtgärdat**

- The release section of the README claimed that the current version was a local build not yet published to GitHub. That sentence went stale the moment a version was released, so it no longer names a single version and points at the Releases page instead. No runtime changes.

### 1.0.5 — 2026-09-18

**Tillagt**

- Protected daily FM/EM event files in the store timezone, with errors always enabled and selectable warning/info/debug levels.
- Structured purchase state, API response, consent and consent-gated browser SDK diagnostics without credentials or event payloads.
- Admin file selection, bounded viewing, 14-day retention, concurrent-write locking and browser report rate limits. Each rate window keeps its original expiry, so steady traffic below the documented per-minute limits never accumulates into a lasting 429 period.

### 1.0.4 — 2026-09-18

**Ändrat**

- Split the admin page into Settings, Validation & log, and Delivery status tabs, retaining the relevant tab after actions.
- Explain HTTP success without verified acknowledgement in a distinct warning result.
- Keep the latest 100 validation results without credentials or payloads. On the first validation after upgrading, the result stored by an earlier version seeds the new log instead of being overwritten. History is removed on opted-in uninstall.

### 1.0.3 — 2026-09-18

**Ändrat**

- Show a masked placeholder for configured API keys and an accessible show/hide button inside the field.
- Retrieve the key only on explicit request through a nonce-protected, uncached endpoint restricted to administrators, and only for a key saved through this plugin. A key supplied by `wp-config.php` or the environment is never returned, so WooCommerce managers cannot read server configuration. Viewing a key does not change stored credentials.

### 1.0.2 — 2026-09-18

**Ändrat**

- Redesigned settings with responsive cards, aligned fields, configuration status, section navigation and a delivery empty state.
- Added readable browser event labels and Swedish translations; grouped destructive options under advanced settings.
- Validation controls explain missing credentials before a test can be sent.

### 1.0.1 — 2026-09-18

**Åtgärdat**

- Lower the WordPress requirement from 6.6 to 6.5: the previous metadata blocked installation on compatible 6.5 sites despite no runtime dependency on 6.6.

### 1.0.0 — 2026-09-18

**Tillagt**

- Consent-gated browser measurement and queued purchase snapshots for WooCommerce.
- Cookiebot adapter, Kustom authorization integration, HPOS order access, encrypted secrets and payloads.
- Disabled-by-default setup, synthetic validation, delivery diagnostics and bounded retries.
- GitHub pre-release distribution with the verified WordPress installer attached as a release asset.

## [Egenverk Lagom](https://egenverk.se/plugins/lagom/changelog)

### 0.20.0 — 2026-09-28

**Tillagt**

- **Database** (M5): while switched on, shows (in a section under the settings) the 25 largest site tables (plus any smaller one worth optimizing) and the 20 largest autoloaded options with owner guesses. One table can be optimized per confirmed click only when it is at most 500 MB, has at least 20 MB and 20 % free inside (InnoDB shows a few MB free in almost every small table, which a rebuild does not return), and the database data directory has at least twice its size free. Larger tables get a maintenance-window explanation. Autoload can be stopped per option, never for protected WordPress options, and each change can be undone from the log. Nothing is deleted or changed automatically; the limits keep rebuilds away from large production tables and protect available disk space.
- `tests/database-test.php` (`wp eval-file`).

### 0.19.0 — 2026-09-28

**Tillagt**

- **Heartbeat, transients and sessions** (M4): slows the admin heartbeat and, each night at 03:00 site time (kept across daylight-saving changes), asks WordPress to delete expired transients and removes expired WooCommerce sessions in batches. Core leaves expired transient rows behind when a persistent object cache is active; Lagom forces the database cleanup after showing the counts first. The run keeps 60 measured rows and the card shows the last 10. On dev, 11 363 expired rows took 1 969 ms to remove.
- `tests/housekeeping-test.php` (`wp eval-file`).

### 0.18.0 — 2026-09-28

**Tillagt**

- **Request watch** (M3): with the module on, every `wc-ajax` call (per endpoint), `admin-ajax.php` call (per action, Heartbeat labelled), REST call (per route pattern, so `/wp/v2/pages/3` and `/4` are one row; unknown routes as `no-route`, and calls refused before they run, such as by a permission check, by their path with numbers as `{id}`) and `wp-cron.php` hit adds one short line to a file for that minute under `uploads/egenverk-lagom/watch/`. No database write and no cache on the request path: about 18 µs per call measured on dev, and files stop growing at 2 MB a minute. A WP-Cron job sums finished minutes every minute into the last hour's counts and deletes the files; the card shows the top 25 endpoints (total, peak per minute, last minute) and warns when WP-Cron has not run, the folder is not writable or a minute hit the cap.
- When an endpoint passes the threshold in one minute, admins see a notice on every admin screen until they dismiss it, and one email per endpoint per hour goes to the address on the card, or the site's admin email.
- Nothing about the visitor is stored (no IP, user, cookie, query string or body). Switching the module off stops counting and deletes the files, once at once and once ten minutes later, since a persistent object cache can serve the old setting to the front end for a while. Uninstall removes the counts, alerts, scheduled jobs and the folder.
- `tests/watch-test.php` (`wp eval-file`; run it as the web server user, or chown the folder afterwards).

**Åtgärdat**

- `tests/scheduler-test.php` passes phpcs (multi-item arrays on one line).

### 0.17.0 — 2026-09-28

**Tillagt**

- **Scheduler cleanup** (M2): with the module on, Action Scheduler's own cleaner removes finished and canceled jobs older than "Keep finished jobs for" (default 14 days, Action Scheduler's own is 30) and failed jobs older than "Keep failed jobs for" (default 90 days), with their log rows. "Finished jobs per cleanup run" sets how many finished and canceled jobs go per status each queue run; Action Scheduler 4.0 removes failed ones 20 at a time on its own, by design, so they never crowd out the rest. Lagom sets Action Scheduler's filters and never runs its own delete on its tables. Action Scheduler below 4.0 never removes failed jobs; there Lagom adds one daily job that asks Action Scheduler's cleaner to remove them.
- The switch saves on only after **Show what would be removed** has counted, for the values in the form, the finished, canceled and failed jobs and log rows that would go, within the last 10 minutes; lowering a retention while the module is on needs a new count too. The count also shows which Action Scheduler version runs and any log rows that belong to no job (left alone).
- A log keeps removals per day and source (200 rows); the card shows the last 20 and the total for the last 30 days. Uninstall removes the log, the counts and the daily job.
- `tests/scheduler-test.php` (`wp eval-file`).

**Ändrat**

- "Rows per batch" became "Finished jobs per cleanup run", 20–500, default 100 (was 50–5000 rows, default 500), since it now sets Action Scheduler's batch size and Action Scheduler cleans inside each queue run: on dev one run of 500 per status removed 520 jobs in 9.5 s, about 18 ms each, which would hold up the jobs waiting behind it.

### 0.16.1 — 2026-09-28

**Åtgärdat**

- Old bookmarks to `tools.php?page=lagom` now reach Tools → Lagom instead of "Sorry, you are not allowed to access this page": WordPress refuses the unknown page before `admin_init`, where the redirect used to run.
- The zip from `bin/package.sh` gives every file and folder readable modes, so fonts copied with private modes no longer answer 403 on servers where the web server runs as another user.

### 0.16.0 — 2026-09-25

**Tillagt**

- **Dashboard widgets** (M1): with the module on, the widgets ticked on Tools → Lagom are removed from the dashboard before it is drawn, in every column, so their render code never runs (the Kustom widget calls its API twice per load there). Hiding a widget in Screen Options only hides it. Lagom still records every widget first, so a removed one stays in the list and can be unticked. Work a plugin does while registering its widget, or scripts it enqueues for the dashboard, are not affected; the asset rules handle the latter.
- `tests/dashboard-test.php` (`wp eval-file`).

### 0.15.0 — 2026-09-24

**Tillagt**

- **PageSpeed Insights as a second opinion** (M6f): Tools → Lagom lists every scanned public page type (not wp-admin, not the order endpoints) with a **Run PageSpeed** button, mobile or desktop. Google opens the page once, logged out, with an empty cart, and reports the score, LCP, TBT and CLS, and how much of each CSS and JS file went unused, matched to the scan's handles where the address is the same. The section is labelled "estimate, public pages only" and only shows results: rules still come from coverage runs and the verification run. A result where Google was redirected to another page (checkout with an empty cart goes to the cart) is refused, not stored.
- The API key is optional (without one Google allows only a few runs a day). It is stored on the server in a non-autoloaded option, or set as `EGENVERK_LAGOM_PSI_KEY` in `wp-config.php`; the page says only whether a key is set, the key is sent only in the server's request to Google, and it is scrubbed from any error message before that is stored or shown. The address sent is always a scanned page of this site, never one from the form. Uninstall removes the key and the results.
- `tests/pagespeed-test.php` (`wp eval-file`; Google is never called, the request is answered by a filter).

### 0.14.0 — 2026-09-24

**Tillagt**

- **Whole plugins per address** (M6e): a plugin rule stops an active plugin from loading at all — its PHP, CSS and JS — on addresses starting with a path (or exactly one address). It needs a small must-use plugin, which Lagom writes only when you click **Install the mu-plugin** on Tools → Lagom, next to an explanation of what it does and why: a normal plugin loads too late to stop others. Update and remove buttons sit beside it; switching the module off or uninstalling Lagom removes the file.
- The mu-plugin reads one autoloaded option and does nothing unless a rule matches. It never acts in wp-admin, on AJAX, REST, cron, WP-CLI, XML-RPC, logins, form submissions (only GET and HEAD), WooCommerce API callbacks, post previews or page builders, on cart, checkout and account pages (order endpoints included), or on the addresses in a never-list you keep on the page (payment and shipping callbacks, landing pages). WooCommerce and Lagom itself (by its real folder name) are never switched off. Addresses are compared in one form — decoded, lower case, without `index.php`, with a trailing slash — so `/Cart`, `/cart` and `/index.php/cart/` are the same page, and prefixes match whole path segments (`/blog/` covers `/blog/x/`, not `/blog-posts/`). Plugin rules need pretty permalinks; with plain ones the mu-plugin does nothing and new rules are refused. A preview with the token is kept out of page caches.
- Plugin rules go through the same steps as file rules: preview, verification, live, log. A plugin rule cannot go live without the current mu-plugin, and a verification counts only if the mu-plugin was installed and current, since without it the rules were not applied. Their page types are the scanned pages the address covers; a rule that covers no scanned page, or lies inside a never-listed address, is refused. Preview links carry a short-lived signed token, since no user is known when the mu-plugin runs; the coverage runner gets one that dies with its run key.
- `tests/plugin-rules-test.php` (`wp eval-file`).

**Ändrat**

- Verification opens each page twice without rules and leaves out whatever moves between those visits (sliders, rotating banners), so only changes caused by the rules count; elements are still compared by identity (tag, id, classes), so a slider whose script stops running still fails the check. Before, a slider on the front page could fail a check at random.

### 0.13.0 — 2026-09-24

**Tillagt**

- **Rule proposals** (M6d): after a coverage run, Tools → Lagom lists per page type the files that are not ruled yet and were unused (CSS, ticked) or only ran their start-up code (JS, left to choose). A file stays out when something used on that page type depends on it. "Add ticked as preview rules" adds them in dependency order.
- **Verification run**: `bin/lagom-coverage --verify` opens every page type that has rules in preview with the rules off and on, at desktop and phone size, runs the same interactions and compares console errors, failed requests, interactions that stopped working and the layout of every visible element (more than 2 % changed fails). `--shots dir` saves both screenshots per page. Results go to a new REST route `egenverk-lagom/v1/coverage/verify`.
- A passed check lets the page type's rules go live **without** the skip confirmation. A check belongs to the exact set of rules on that page type: adding or removing a rule there makes it stale. Rules verified together go live together (every preview rule sharing a page type with it), so visitors never get a combination that was not checked. The rules table shows per page type "verified", "verification failed" (with the reasons), "rules changed, verify again" or "not verified"; the change log records each outcome.
- Cart and checkout pass only in a full run with something in the cart: a read-only pass on them is recorded as failed (also when the runner itself was made stricter with `--read-only`), and an empty cart fails the check. A full verification run adds a simple product from the plan to the cart first. Order-pay and order-received need a real order and never pass a run, so no rule may target them (a rule there would also hold back every rule verified together with it).
- Console errors are compared with where they came from, so a new error with the same text as an existing one still fails the check; the layout comparison uses every class and the horizontal scroll position.

**Ändrat**

- Coverage runs visit every page at a desktop and a phone size and count a file as used if either used it, so phone-only stylesheets are no longer reported unused.
- CSS files with `@font-face` or `@keyframes` and no matching style rule are shown as "fonts or animations only, check by eye" instead of "unused" and are never proposed: rule usage cannot tell whether a font or an animation is used.
- The rules option is created with the module's current switch, so a site whose rules option was removed does not ignore previews until the next settings save.

### 0.12.0 — 2026-09-24

**Tillagt**

- **Coverage runs** (M6c, `docs/coverage-runner.md`): Tools → Lagom lists a plan of one page per page type (scanned pages and suggestions, own addresses can be added), creates a **run key**, and shows per file and page type what the last run saw: "unused here", "start-up only" or "used", with the share of the file that ran.
- `bin/lagom-coverage` (developer tool, not in the zip): a headless browser with a temporary profile that opens each planned page once, records which JS functions are called after load and which CSS rules match, runs standard non-destructive interactions, and uploads a per-file summary.
- **Run keys** are shown once, valid for at most 30 minutes, bound to one user and one run, stored only as a hash, logged (created, plan fetched, results uploaded, revoked) and revocable; revoking or replacing a key also ends the login it handed out. A new key retires the user's previous one.
- **Production is read-only**, decided by the site: tracking blocked (on by default everywhere), every non-GET request aborted, cart-changing requests aborted, no navigation away from the planned page, and no clicks in wp-admin. Service workers may not start and WebSockets are closed in every run, so nothing reaches the network outside the runner's checks. Full runs on other environments put a simple product in the cart before visiting cart and checkout.
- REST routes `egenverk-lagom/v1/coverage/plan` and `/results`, each needing a valid run key; uploads are kept only for planned pages the scan recorded (a page that redirected is filed under the page type it landed on, if the scan saw it there), and every text is sanitised.
- Uninstall removes the plan, runs, results and log.
- `tests/coverage-test.php` (`wp eval-file`).

### 0.11.0 — 2026-09-24

**Tillagt**

- **Asset rules** (M6b, `docs/BRIEF-assets.md` section 3): "Unload…" on a scanned CSS or JS file stops it loading on the page types you tick, front end or admin screens. Rules act on whole handles only.
- Every new rule starts in **preview**: it applies only when an administrator opens a page with `?egenverk_lagom_assets=preview`, with a note on the page saying how many rules were applied. Preview links per page type sit next to each rule.
- **Going live needs a passed verification run** (arrives with the coverage runner). Until then an administrator can make a rule live only by ticking an explicit confirmation that the verification is skipped, which is logged and shown on the rule. Rules on cart, checkout, order-pay or order-received can never skip it.
- A rule is refused when a file that depends on it would still load on that page type, when the file was not seen there in the last scan, or when it targets Tools → Lagom itself. Because WordPress re-adds a removed file when something still loaded depends on it, a rule only goes live once the rules on its dependants are live, and a dependant's rule cannot leave live (or be deleted) while a rule it depends on is live.
- `?egenverk_lagom_assets=off` turns every rule off for one request; scans ignore rules so the list stays complete. Back to preview and delete per rule; known page caches (WP Rocket, LiteSpeed, W3 Total Cache, WP Super Cache) are emptied after a change, otherwise the page asks you to.
- Change log of the last 200 rule changes (who, when, what) on Tools → Lagom.
- The switch and rules live in one small autoloaded option, so the front end reads them without a query; nothing is hooked while the module is off or no rule exists. Uninstall removes rules and log.
- `docs/decisions.md` records the decisions behind the asset manager and order search; the brief now describes coverage (runner A plus PageSpeed B), the production safeguards and the PR order.
- `tests/asset-rules-test.php` (`wp eval-file`).

### 0.10.0 — 2026-09-24

**Tillagt**

- **CSS and JS per page** module (off by default), first step of the asset manager (`docs/BRIEF-assets.md`). While it is on, an administrator opens any front-end page or admin screen with `?egenverk_lagom_assets=scan` and Lagom records every CSS and JS file that page printed: handle, the file actually served, whether it is minified and whether the other build (`.min` or source) is on disk, size, inline code, owner (plugin, theme, core or external host), and what depends on it. Uninstall removes the stored scans. Scans are kept per page type (front page, shop, product, product category, cart, checkout, order received, account, page template, post type, admin screen), at most 40 page types.
- The module card links one page of each type to scan; the list under the settings groups the files by plugin or theme, largest first, with the page types each file loads on. A small note on the scanned page confirms what was recorded.
- Read-only: nothing is unloaded yet. Requests without the scan argument add no hook, and a scanned page is kept out of page caches.
- `tests/asset-scan-test.php` (`wp eval-file`).

**Ändrat**

- The header chip on Tools → Lagom names the asset scan among the live modules.

### 0.9.0 — 2026-09-24

**Tillagt**

- **Lagom's own order search index** for orders stored as posts, when no other plugin answers the search filter. A table `{prefix}egvl_order_index` holds one row per order with the normalised billing email, phone (E.164), postcode, first and last name, company and transaction id, each column indexed. Fast order search uses it once it is complete, so email, phone, postcode, transaction id and name are found fast without HPOS; until then only order numbers are, as before.
- The index fills in batches of 500 orders through Action Scheduler with a 20-second pause between batches, optionally only between 00 and 06 in the site's time zone. Unticking "Build Lagom's own search index" pauses it and keeps what is built. Any admin page load restarts a fill whose last batch died, until the fill is done. The module card shows how many orders are indexed.
- Once the table exists, orders are kept in step when they are created or saved (checkout included), and their row is removed when the order is deleted or anonymised by the WooCommerce personal data eraser, also while the fill is paused or the module is off. Uninstall drops the table and its options.
- readme.txt has a Privacy section.
- `tests/order-index-test.php` (`wp eval-file`).

### 0.8.0 — 2026-09-23

**Tillagt**

- **Fast order search** module (off by default). It reads the search term on the order list before anything is searched (order number, email, phone, postcode, transaction id or name) and looks each reading up in one indexed column. It never runs a `LIKE '%…%'` across order data, and WooCommerce's own search does not run.
- A notice on the order list says how the term was read ("phone number +46722900612") and links to "Search all fields (slow)", which runs WooCommerce's normal search.
- Answers come from another plugin through the new `egenverk_lagom_order_search` filter, with the query shape wc-customer-hub 4.125.0 answers (`term`, `type`, `candidates`, `limit`; documented in `docs/order-search-filter.md`), so on a site with wc-customer-hub email, phone, order number and name are fast without HPOS; otherwise from the HPOS tables: order number, email, transaction id and phone always, and name, company and postcode where the site has an index on those columns. With orders stored as posts, only order numbers are found fast until another plugin answers.
- The module card says where answers come from on this site and which kinds of lookup have no index.
- `tests/order-search-classifier-test.php` (plain `php`) and `tests/order-search-test.php` (`wp eval-file`, HPOS).

### 0.7.1 — 2026-09-23

**Tillagt**

- wordpress.org screenshots 1–3 in `.wordpress-org/` (1280×800, the real page on a test site) and their captions under `== Screenshots ==` in readme.txt.

**Ändrat**

- The "by egenverk" signature on Tools → Lagom, the bundled kit's brand files, the Lagom glyph and the wordpress.org banners and icons use the redrawn egenverk mark (even bars, a wider middle stroke).
- CHECKLIST records the move of the GitHub repository to `egenverk/egenverk-lagom`, marks brand v3 done, and adds the order search brief (`docs/BRIEF-order-search.md`) and the asset manager module (M6).

### 0.7.0 — 2026-09-23

**Tillagt**

- Migration from the old keys: settings, recorded dashboard widgets and each user's skin and scheme choice move from `lagom_*` to `egenverk_lagom_*` once, on activation or on the first load after an update (a new folder means WordPress sees a new plugin, so the old one is deactivated and this one activated). Keys that already exist under the new name win. Uninstall removes both generations.
- `tests/migration-test.php` (`wp eval-file`) checks the migration on a dev site and restores what it touched.
- `bin/package.sh` builds `dist/egenverk-lagom-<version>.zip` with the folder `egenverk-lagom/`.

**Ändrat**

- **Renamed to Egenverk Lagom** (shown as "Lagom by egenverk"). Slug, folder and text domain are `egenverk-lagom`, the main file is `egenverk-lagom.php`, PHP uses the `Egenverk\Lagom` namespace and the `egenverk_lagom_` / `EGENVERK_LAGOM_` prefixes, and CSS classes, custom properties and the JS global use `egvl-` / `egenverkLagomAdmin`. Author is Egenverk (https://egenverk.se); the wordpress.org contributor is `vigg3`.
- The settings page moved to `tools.php?page=egenverk-lagom`; the old `tools.php?page=lagom` redirects there.
- `?egenverk_lagom_skin=0` turns the skin off for one page; the old `?lagom_skin=0` keeps working.

### 0.6.0 — 2026-09-23

**Tillagt**

- wordpress.org banners (1544×500, 772×250) and icons (256, 128) in `.wordpress-org/`, with their sources in `.wordpress-org/src/`; not part of the plugin zip.

**Ändrat**

- Tools → Lagom follows the Egenverk look (brand v3): the shared Egenverk UI kit is bundled in `assets/egenverk-ui/` and loads only on this page, with Geist and Geist Mono self-hosted (no external request). The header carries the Lagom glyph, the title and the "by egenverk" signature; the accent is Lagom's product colour Mynta; the save bar and loading indicator come from the kit. The teal page palette and logo are gone from this page; the Skin module is unchanged.
- The figures open with Revisions (a new read-only count, cached like the rest), Expired transients and Space, followed by autoloaded options, finished scheduler jobs and shop sessions, three to a row.
- Switched-off module cards keep their text at full contrast instead of fading it below 4.5:1, and the page's own switches, text buttons and widget rows have 44 px targets.
- readme.txt opens with what Lagom does, and the Plugin URI and donate link point to egenverk.se.
- The header comment of the generated `skin-strong.css` reads "Built by bin/build-skin.php".

### 0.5.0 — 2026-09-23

**Tillagt**

- Every component in `docs/skin-components.html` now has skin rules: keyboard focus rings on every interactive element (`:focus-visible`, with a transparent outline that forced-colors mode shows), a CSS spinner in core's 20 px box that only turns while active (white inside primary buttons, slower under reduced motion), read-only, disabled and invalid fields, multiple selects with a tinted choice, the file picker button, a tinted list-table header with the active sort in the accent, a roomy empty state, active plugin rows in the palette, the folded menu's flyout heading as a mono label, WooCommerce help tips, plain `<progress>` bars, skeleton placeholders and confirmation dialogs (core's jQuery UI dialog and WooCommerce's modal, without clipping so dropdowns inside can spill out).
- The components fixture loads WooCommerce's admin styles and core's dialog styles, so every section can be screenshot as it looks on a real screen.

**Åtgärdat**

- Plugin notices with `notice-alt` lost the status dot since 0.4.0; only core's update rows (which bring their own icon) go without it now.

### 0.4.0 — 2026-09-23

**Tillagt**

- The skin on `wp-login.php`: logo with the site name, the form as a card with the accent line, a remember-me switch, links side by side. Logged-out visitors follow `prefers-color-scheme`. Other front-end requests still read nothing.
- Dashboard: widget handles appear on hover or keyboard focus (always on touch screens), the empty column is a dashed drop zone, and WooCommerce status is a grid of icon tiles.
- WooCommerce order status pills per status in both schemes (processing, completed, on hold, failed/cancelled/refunded, pending).
- A 3 px accent line on key-figure cards, the first card of an edit screen, WooCommerce status and the login form, in both schemes.
- Order notes as soft cards; the customer's note tinted; the meta line in mono.
- `tests/fixtures/components-fixture.php`: an admin page with WordPress markup for every section of the component sheet.

**Ändrat**

- Radii follow reference v3: cards 10 px, buttons 7 px, fields 6 px, menu items 8 px (the 4 and 8 px settings scale with it).
- The admin bar sits on the menu's surface instead of black.
- Notices are cards with a status dot and halo instead of the left stripe; update rows and other alt notices keep their own icon and get no dot.
- Tabs are underlined instead of boxed, still floated like core so plugins' floated siblings keep their place.
- Update and comment counters are an orange warning pill; plain counters stay teal.
- Tools → Lagom follows reference v3: mono labels, 30 px figures with the accent line, gradient icons on active modules, gradient save button.

### 0.3.0 — 2026-09-23

**Tillagt**

- Components on core's own selectors: notices as soft cards with the status stripe, postboxes and `.card` as cards, the settings `.form-table` on core screens as a card, quieter list tables with row hover, tabs, tablenav pagination, screen options, secondary text and delete links in the palette.
- Checkboxes inside `.form-table` become 32×18 switches, never in list tables and not on WooCommerce order screens or the checkout settings tab. Lagom's own page uses the same switches.
- Transitions of 150–200 ms everywhere, and none under `prefers-reduced-motion`.
- Dark scheme per user (Light / Dark / Auto on the profile), set on `<html>` before the page renders, so no light flash. All colours are tokens; the admin menu's SVG icons are repainted to match.
- `skin.js` guards the dark scheme against plugin styles: near-white panels get the dark surface and text under 4.5:1 is lightened towards white, keeping its hue. No filters, no inversion. db_schenker's own dark theme is switched on instead.
- Admin menu on the page's own surface, light or dark, with only the active item debossed, flyout submenus as raised cards and counters as gradient pills.
- Entrance: the first twelve blocks of a page rise and fade in once per load, 55 ms apart; switchable on the Skin card and always off under reduced motion.
- Expression: 34 px page titles with a mono eyebrow (menu group and date; the room for it is only reserved when JavaScript runs), a faint glow and grain behind the content, cards that lift on hover, the status filter as a segmented control, readable WooCommerce status pills in both schemes.
- Lagom's own page in the new palette with the new logo and a dark mapping.

**Ändrat**

- `bin/build-skin.php` keeps comments in the generated `skin-strong.css` (they were dropped since 0.2.0's review fix).

### 0.2.0 — 2026-09-22

**Tillagt**

- Skin module (off by default) with its own card on Tools → Lagom and a live preview of font, corner radius and gradient.
- Bundled fonts Manrope (default), Inter and Figtree as self-hosted woff2 (OFL), or the system font. Only the chosen font loads, on admin pages only, preloaded once from the plugin folder.
- Skin tokens as CSS custom properties switched by body classes (`lagom-skin`, `lagom-font-*`, `lagom-radius-*`, `lagom-grad`); one static stylesheet, nothing generated per request.
- Fields get a soft fill and a teal focus ring (1px solid plus a soft halo) instead of the blue outline, and turn white on focus; buttons and "Add new" get the teal palette, rounder corners and an optional gradient on primary buttons.
- Cascade: the skin loads after core's admin styles on core's own selectors, so plugins printed later keep their look. "Lagom wins over other plugins' styles" switches to `skin-strong.css`, built by `bin/build-skin.php` with one extra class per selector.
- Off switches: `?lagom_skin=0` for one page, "Use Lagom skin" on each user's own profile, a list of screen ids without the skin, and the block editor is always left alone.
- Token defaults also sit on `body`, so a screen that prints its own `<body>` without the admin body classes still gets readable buttons.
- `.distignore` for the shipped files; Plugin Check runs against those.

### 0.1.0 — 2026-09-22

**Tillagt**

- Settings page under Tools → Lagom with live health figures (database size, autoload, Action Scheduler backlog, expired transients, WooCommerce sessions), cached for 10 minutes.
- Five module cards with switches and options; every module ships off and none acts on the site yet.
- Logo files, Swedish translation, README and .gitignore.

## [Egenverk Babbla](https://egenverk.se/plugins/babbla/changelog)

### 0.46.1 — 2026-09-29

**Åtgärdat**

- With the chat closed, the unread badge could miss a message for up to two minutes when two arrived within a few seconds of each other. Tabs of one browser share the unread count for eight seconds (0.18.0), and a tab that noticed the second message could take the count another tab had fetched just before it. A shared count is now used only when it is at least as new as the change the tab noticed; otherwise the tab asks the server.

### 0.46.0 — 2026-09-29

**Tillagt**

- Settings → Babbla → Diagnostics can turn on **lean chat reads**. Every time a chat checks for new messages — the conversation list, a conversation's messages, the unread count and the combined check the open chat makes — WordPress normally starts with every plugin and the theme, WooCommerce included, although none of them is needed to answer. With lean reads on, those requests load only Babbla and no theme, so each one holds a PHP worker for a fraction of the time. Sending messages, the assistant and everything else on the site are unchanged. It installs a small must-use plugin, `wp-content/mu-plugins/egenverk-babbla-lean.php`, and is off until an admin turns it on: a plugin that grants chat access through its own capability rule is not loaded for those requests either, and its users would then be refused — turn lean reads off again in that case. Deactivating Babbla removes the file (on a multisite network only a network-wide deactivation does, since the file serves every site). `wp egvb lean on|off|status` does the same from WP-CLI. When a Babbla update changes the file, Diagnostics offers to update it.

### 0.45.0 — 2026-09-29

**Tillagt**

- Diagnostics now has a Server load card showing Babbla REST requests by route, request rate and response time. It needs a persistent object cache such as Redis; without one it counts nothing. Measurements remain for at most two hours.

### 0.44.0 — 2026-09-29

**Ändrat**

- A new message or an assistant answer now wakes only the chats of the people who can see it, instead of every open wp-admin tab on the site. Each user has their own change signal file: an assistant conversation wakes only its owner, a direct message its two participants, a private room its members. A public room still wakes everyone. Before, every write — including each step of an assistant answer ("Searching the chat…", "Calculating…") in someone's private assistant chat — made every open tab of every user ask WordPress, so one question could cost more than a hundred WordPress starts across the site. The file still says only that something changed; its name is derived from the user id, a random key and the site's secret salt, so no one can work out a colleague's file.
- Settings → Babbla → Diagnostics tests the admin's own signal file, so opening the tab no longer wakes every other open chat.
- A wp-admin tab left open across the update reads the old shared file, which is deleted once the first user file is created: it gets three 404s and asks the server on a schedule until it is reloaded. Uninstall (with purge on) removes every user file.

### 0.41.0 — 2026-09-24

**Tillagt**

- Settings → Babbla → Diagnostics has a "Live updates" row. When the tab opens it rewrites the change signal file and fetches it back over HTTP the way a browser does: it says the signal works, that the file is cached or unreachable (with the HTTP status, or "stale content" when an old token comes back), that the test could not run from the server (a blocked loopback, which says nothing about the browser), or that uploads is not writable. The result is kept for five minutes, so reloading the tab does not rewrite the file each time.

**Ändrat**

- The change signal file moved from the uploads root into its own directory, `wp-content/uploads/egenverk-babbla-signal/`, which carries an `.htaccess` that sends `Cache-Control: no-store, private` (Apache with mod_headers) and an empty `index.php`. The directory and both files are created on activation, on the first write, and again whenever one is missing. The old file in the uploads root is deleted once the new one exists; uninstall (with purge on) removes the directory as well. A wp-admin tab left open across the update still reads the old address: it gets three 404s and falls back to asking the server on a schedule until it is reloaded.
- A cache that freezes the signal file no longer delays new messages by up to a minute for the rest of the page. When the server has reported a token for more than 30 seconds that the file still has not shown, the page stops reading the file, gives up its place as the browser's reader (0.40.0), falls back to the schedule it uses without a signal (0.38.0/0.39.0) and writes one warning to the browser console naming a cache in front of uploads as the likely cause. A file that lags less than 30 seconds, or a burst of writes, does not trigger it.

### 0.40.0 — 2026-09-24

**Ändrat**

- With several wp-admin tabs open, only one of them reads the change signal file: the visible tab that holds a browser lock (Web Locks) reads it at the pace the fastest visible tab needs and passes every token to the other tabs (BroadcastChannel). Each tab still asks WordPress itself when the token changed, so a new message costs one request per tab as before, and every tab keeps its own safety net and the tab-return rule. A hidden tab never leads; a tab that hears no token for three of its read steps plus two seconds (a frozen or throttled leader) reads the file again itself. Measured in the browser tests: one hour of three visible tabs (one open idle conversation, two closed chats) is 1 826 file reads instead of 3 207, and the same 120 requests to WordPress (60 + 30 + 30). A browser without Web Locks or BroadcastChannel behaves exactly as in 0.39.0.

### 0.39.0 — 2026-09-24

**Ändrat**

- An open chat asks the server about once a minute instead of every 4–16 seconds while nothing happens; new messages still appear within one to two seconds. The open conversation and the conversation list read the small change file every second for a minute after something happens (a message arrives or is sent, the user types, a conversation is opened) and every two seconds otherwise, and ask WordPress only when the file changed, when the tab comes back after more than 30 seconds, or on a one-minute safety net. Measured in the browser tests: one hour of an open conversation with nothing new is 60 requests to WordPress and 1 767 file reads, against 226 requests in 0.38.0. The list is refreshed with every change and on the safety net instead of on every fourth request.
- The assistant's progress no longer costs a request every 1.5 seconds: while it works the chat reads the file every second, and the assistant's status and answer arrive with the change that carries them.
- The open chat follows the server's `poll_interval` and `signal_token` from `GET /poll` too (see 0.38.0); a busy site's `egvb_poll_interval` now also stretches the open chat's safety net. Without the file (uploads not writable, or three failed reads) the open chat keeps the 0.38.0 schedule unchanged.

### 0.38.0 — 2026-09-24

**Tillagt**

- `GET /unread` and `GET /poll` return `poll_interval` (seconds) and `signal_token` (the change signal's current token, read before the answer is built). A new filter, `egvb_poll_interval` (default 0, clamped to 0–600 seconds), lets a busy site make every closed chat wait longer between its scheduled requests without a release.

**Ändrat**

- An idle wp-admin tab with the chat closed asks the server about once every two minutes instead of every ten seconds; the unread badge still updates within about five seconds of a new message. The closed chat now checks the same small file as the open chat, every five seconds, and asks WordPress for the unread count only when that file changed (at most once per five seconds), when the tab comes back after more than 30 seconds, or on a two-minute safety net. Measured in the browser tests: one hour of a closed chat with nothing new is 30 requests to WordPress and 720 file reads, against 360 requests before.
- A hidden tab asks every five minutes instead of every 30 seconds, and does not read the file. Without the file (uploads not writable, or three failed reads) the closed chat keeps the 10- and 30-second schedule of 0.37.1. An open conversation and the conversation list are unchanged.

### 0.37.1 — 2026-09-24

**Ändrat**

- Database schema 8: updating converts that one column in place (`ALTER TABLE … MODIFY request_id`). Existing messages and ids are kept; if the conversion fails, the plugin retries it on the next request.

**Åtgärdat**

- A message containing å, ä, ö or any other non-ASCII character was never saved: the chat showed "Send status unknown" and a retry failed the same way. The same fault could stop the assistant's answers and chat searches that contained such characters. Since 0.17.0 the column holding each message's request id used the ascii character set; WordPress then treats the whole messages table as ascii and refuses any query on it that carries other characters. The column now uses the table's own character set, still compared case-sensitively.

### 0.37.0 — 2026-09-24

**Tillagt**

- A new route, `GET /babbla/v1/poll`, answers in one request what an open conversation used to ask for in two: its new messages and, when due, the conversation list (and, on request, the unread count). Each part is exactly what its own route returns. The existing routes are unchanged.

**Ändrat**

- A new message in an open conversation now costs the server one request instead of two: the chat fetches the message and the refreshed conversation list together, so WordPress starts once per update instead of twice. A page loaded before the update keeps working: when the server does not know the new route, the chat goes back to the separate requests.

### 0.36.0 — 2026-09-23

**Tillagt**

- Messages from colleagues appear within about two seconds instead of up to sixteen. The open chat checks a small file in the uploads folder every two seconds (three on the conversation list) and only asks the server for messages when that file says something changed. The file holds a timestamp and nothing else: it reveals that something changed in the chat, never what, where or by whom. It is removed on uninstall when "Purge data on uninstall" is on.
- The assistant's answer, and its "Searching the chat…"-style status, show sooner: while it works the conversation is checked every one and a half seconds, for up to two minutes; after that the small file wakes the chat when the answer is ready.

**Ändrat**

- Less load on the server: an open chat with nothing new asks WordPress for messages every 16 seconds in a conversation and every 20 seconds on the list, instead of every 4 to 16 seconds. A new message elsewhere also refreshes the conversation list right away, so its unread count shows without waiting.
- When the uploads folder is not writable, or reading the file fails three times in a row (an error page or something that is not the file; a dropped network connection only skips that check), the chat falls back to exactly the checking schedule of 0.35.2. A CDN that caches the file only slows the chat to the 16- and 20-second checks. A hidden tab or a closed chat is unchanged.

### 0.35.2 — 2026-09-23

**Åtgärdat**

- The thin loading stripe at the top of the chat no longer blinks every few seconds on its own. Checking for new messages, unread counts, read markers and the colleague list now run quietly in the background; the stripe shows only while something you did (send, create, search, open) is waiting for the server.

### 0.35.1 — 2026-09-23

**Åtgärdat**

- On the full-page chat, the mention picker and the other panels (new message, new room, room info) cover only the conversation, not the whole admin screen.
- The full-page chat's conversation list is wider (300 px), so room and person names are no longer cut to a few letters.

### 0.35.0 — 2026-09-23

**Ändrat**

- Access, Usage and Diagnostics follow the Egenverk plugin shell like the Settings tab: each section is a card with its heading, tables use the Egenverk table style, and confirmations ("Access saved.", "Babbla ownership claimed.", "The log files were deleted.") are green notices.
- Access: claiming ownership, finding a user and choosing a user's Babbla role are each a card of rows, label and help on the left and the field and its button on the right.
- Usage: the period's figures are a row of tiles (questions, failed, tokens in, tokens out, and the estimated cost when prices are set) instead of one sentence. The daily bars use the Babbla colour, and a day over the budget is red.
- Diagnostics: each status row names its state in a chip ("OK", "Warning", "Error") instead of a coloured dot alone. The log file picker has a visible label, and the log scrolls inside its own box. "Delete all log files" is a red button that shows a loader while it works.
- On a phone the rows stack and the tables scroll sideways inside their card.
- Form fields, what each form sends and who may use it are unchanged.

### 0.34.0 — 2026-09-23

**Ändrat**

- Settings → Babbla follows the Egenverk plugin shell. The version sits beside the Babbla name in the header, and each tab's content fades in on load.
- The Settings tab is one page of cards: Overview, Provider, Behaviour, Limits and cost, and Data. The left section menu is gone. Each setting is a row with its label and help on the left and the field on the right. On a phone the rows stack.
- The floating bubble, "Purge data on uninstall" and each integration are now on/off switches.
- Provider is one choice at the top of the Provider card. Only the chosen provider's key and model fields show. The "In use" badge and the second "Use … for the assistant" radio are gone. "Save everything and test the connection" is a secondary button with its description on its own line, and it tests the chosen provider.
- Saving works the Egenverk way. A save bar slides up from the bottom only when something has changed, and a "Settings saved." toast confirms the save. The always-visible save bar at the top and the second Save button at the bottom are gone. Pressing Enter in a field saves, and it does not run the connection test.
- Option names, stored values and what is saved are unchanged.

### 0.33.4 — 2026-09-23

**Åtgärdat**

- The assistant can be mentioned from the picker again. In a room (public or private) it is the first row of "Mention a colleague or assistant", with its avatar and "Answers once in this room", and it filters with the search like everyone else; the near-invisible "@Agent" button above the list is gone. Direct messages still do not offer it, since the assistant never answers there.
- The picker's search field is visible in the dark theme: it has a fill, a border and a search icon, and a clear (×) button empties it.
- Every overlay (mention picker, new DM, new room, room info) has a close (×) button in its header that returns to where you were, like Back and Esc.
- Selected mentions above the composer are pills with the name and a separate × to remove them.

### 0.33.3 — 2026-09-23

**Åtgärdat**

- Picking a person under New direct message no longer turns the row into "PAPatrik" with a spinner. The busy state flattened the row to text, so the avatar's initials ran into the name and the avatar never came back after a failed request. The spinner now takes the avatar's place and the avatar returns when the request fails.

### 0.33.2 — 2026-09-23

**Åtgärdat**

- Plugin Check reports no code errors. The access compare-and-swap passes `$wpdb->prepare()` straight to `$wpdb->query()`; the analytics queries that build their SQL from fixed fragments and placeholder lists say so where they run; calculator error messages escape the function names they quote; uninstall deletes log files with `wp_delete_file()`; the "Selected user" string has a translators comment.
- `Tested up to` is 6.9, the version the acceptance runs used.

### 0.33.1 — 2026-09-23

**Ändrat**

- The Egenverk mark is centred on its grid (kit sync from `_commercial/brand/egenverk-ui/`): the symbol and the Babbla glyph in `assets/egenverk-ui/`, the "by egenverk" signature on Settings → Babbla, and the wordpress.org banner and icons, now rendered from sources kept in `.wordpress-org/src/`.

### 0.33.0 — 2026-09-23

**Ändrat**

- **One menu entry: Settings → Babbla.** The top-level Babbla menu is gone. Settings, Access, Usage and Diagnostics are tabs of one page (`options-general.php?page=egenverk-babbla&tab=…`), each shown only to users who may open it; a chat user without Babbla administration sees Access only. The Plugins screen gets a Settings link to it.
- The full-page chat has no menu entry. The admin-bar chat opens it, and the drawer's ⋯ menu has "Open as full page". Its url is `admin.php?page=egenverk-babbla-chat`.
- Settings → Babbla is built with the Egenverk UI kit 1.1.0 (`assets/egenverk-ui/`, copied unchanged, loaded only on that page): header with the Babbla glyph and the egenverk signature, lead, tabs and footer. Fields and behaviour are unchanged; each tab's own heading replaces its old page title, and the settings sidebar no longer repeats links to Access and Usage.
- wordpress.org banner (1544×500, 772×250) and icon (128, 256) in `.wordpress-org/`, kept out of the release zip.
- The settings help texts point at the Usage and Diagnostics tabs instead of the old "Babbla → …" menu path, and only the end of the page keeps room for the floating chat bubble (every form used to reserve it, leaving a gap under the usage period selector).
- The admin urls of 0.31.0 (`admin.php?page=bbl-*`) and 0.32.0 (`admin.php?page=egvb-*`) redirect to their tab or to the full-page chat for one version, keeping their other query args. This replaces 0.32.0's `bbl-*` → `egvb-*` redirect.

### 0.32.0 — 2026-09-23

**Ändrat**

- **The plugin is now Egenverk Babbla** (slug, folder and text domain `egenverk-babbla`, main file `egenverk-babbla.php`, Author Egenverk). WordPress sees the new folder as a new plugin: activating it deactivates the old `babbla/babbla.php` copy, so the two never answer the same question twice.
- Every PHP, CSS and JavaScript identifier moves from `BBL_`/`bbl_`/`bbl-` to `EGVB_`/`egvb_`/`egvb-`: classes, constants, options, capabilities (`egvb_chat`, `egvb_manage`), transients, admin-post actions, the log cron, admin page slugs, CSS classes and the `egvbConfig` client global. The WP-CLI command is `wp egvb job`. wordpress.org requires a prefix of at least four characters for global symbols.
- Filters are renamed to `egvb_*` (`egvb_agent_tools`, `egvb_integrations`, `egvb_capability`, `egvb_admin_capability`, `egvb_tool_capability`, `egvb_tool_status_label`, `egvb_redacted_keys`, `egvb_rate_limit`, `egvb_anthropic_request`, `egvb_openai_request`).
- The API key constants are `EGVB_ANTHROPIC_KEY` and `EGVB_OPENAI_KEY`.
- A `readme.txt` for wordpress.org, with the external services the assistant uses.
- The `bbl_*` filter names still run for this version, through `apply_filters_deprecated()`. So do `BBL_ANTHROPIC_KEY`/`BBL_OPENAI_KEY` in `wp-config.php`, the `wp bbl job` command, and agent jobs Action Scheduler queued under `bbl_run_job` before the update.
- `EGVB_Install::migrate_from_bbl()` runs on activation and on the first request after the update, after the 0.8.0 `wctc_` move: `bbl_settings`, `bbl_access`, `bbl_db_version`, `bbl_log_dir` and `bbl_purge_on_uninstall` are copied to their `egvb_` names (an existing `egvb_` value wins) and deleted; a stored `agent_capability` of `bbl_chat`/`bbl_manage` and any role granted those capabilities move to the new names; the `bbl_log_cleanup` event is cleared. It runs once and a second run changes nothing. Deleting the old keys also disarms the old copy's `uninstall.php`, which would otherwise read its purge flag and drop the tables this version uses. Transients are left to expire. `uninstall.php` cleans the old and the new keys.

### 0.31.0 — 2026-09-22

**Ändrat**

- The SQLite double used by the tests can be told to fail a chosen statement (`fail_on`), the way a timed-out query fails in production: no rows plus `last_error`, never an exception. Every fix above is pinned by a test that fails without it.

**Åtgärdat**

- A chat search that the statement guard stops no longer reads as "nothing found". `get_results()` answers a failed query with `false`, the array cast turned that into zero rows, and the agent reported — with every appearance of certainty — that nothing in the chat mentioned what was asked about. `BBL_Repository::search_messages()` returns null when the query itself failed, and `search_chat` refuses with an error that tells the model to say so instead of answering from it.
- The channel pre-scan that orders a search now runs inside the same statement guard, and a failure there is an error rather than an empty channel list — which reached the model as "nothing matched" by another route. `list_conversations` refuses in the same way instead of printing every conversation as empty and never touched.
- `POST /channels/{id}/read` answers 500 when the marker was not stored. `mark_read()` ignored both write results and read back a missing row as 0, so the client cleared its badge on a marker the server had never saved and the unread count returned on the next poll.
- `finish_job()` reports whether a row actually moved. `BBL_Agent_Runner`'s `finally` branch treats `status = 'running'` as unfinished, so a silently failed UPDATE could fail an answered job and post "The agent could not answer" underneath the answer it had just given. A job whose answer reached the channel is now closed as done by that branch, and the failure notice is posted only by whoever actually closed the row.

### 0.30.0 — 2026-09-22

**Ändrat**

- The settings sections work the way Customer Hub's do: the server renders the section the url names and the nav links are real urls, so a section is reachable, bookmarkable and shareable with no JavaScript at all. `assets/js/bbl-settings.js` only upgrades the click to an in-place swap, so switching costs no request and keeps unsaved edits in the fields. A url with an unknown or missing section opens Overview, and a connection test still opens Provider, where its result renders.
- The back button walks the sections that were opened instead of leaving the page.

**Åtgärdat**

- Clicking a settings section no longer jumps the page down a step. The switcher assigned `window.location.hash`, and the browser answers that by scrolling the named element to the top of the viewport; every click pushed the screen further down. The section now lives in the url as `?section=<slug>` and the client swaps the card with `history.pushState`, which does not scroll.

### 0.29.0 — 2026-09-22

**Tillagt**

- The floating launcher bubble can be hidden entirely and scaled in three sizes: small (40px), medium (56px, today's size, unchanged) and large (72px). The site sets a default (`BBL_Settings::get( 'launcher_visible' | 'launcher_size' )`, new fields on the settings page); a signed-in user's own choice, made from the bubble's own menu, is stored in `uiPrefs` in `localStorage` exactly like theme and drawer/window are, and overrides the site default until the user changes it back — a later site-default change does not silently undo it. Hidden means not rendered at all, not `display: none` on an element that still exists, so no keyboard trap is left behind.
- The admin-bar Babbla icon carries a second badge, `@N` (`@99+` above 99), for unread mentions, next to the existing total-unread badge. With the bubble hidden it is the only place `@`-mentions surface outside the panel itself.

### 0.28.0 — 2026-09-22

**Ändrat**

- The composer no longer waits for `GET /channels`. The cache paints the conversation on the first frame, and the textarea, the mention button and Send used to stay disabled until the channel list answered — on a host that takes seconds per request, that wait was the whole delay made visible in the one place the user came to type. A channel the cache still has but the server does not is answered with 403/404 and cleared, as before.
- The cached (or linked) conversation's messages are requested in parallel with the channel list instead of after it. The list only confirms that the conversation still exists, so running the two in series cost a second round trip for nothing; when the list lands on the same conversation it no longer spends a second request on it.
- An unanswered first load shows placeholder bubbles rather than "No messages yet". An empty pane under a request in flight is a claim the client cannot make, and in the assistant conversation it also offered the starter chips over a history the user had not seen yet.
- Asking the assistant shows it working immediately instead of at the next poll, and that poll is brought forward to a second after the send is confirmed. The indicator is optimistic: a failed send clears it, and the next `agent_busy` corrects it either way.
- Opening the panel, and picking a conversation, puts the cursor in the composer.

### 0.27.0 — 2026-09-22

**Tillagt**

- A conversation menu (⋯) in the chat header: Room info, Open as drawer/window, Copy link to conversation, and Clear conversation. The padlock that used to sit in the header is gone — it opened Room info, which no padlock has ever meant.
- `DELETE /channels/{id}/messages` empties a conversation, and only the caller's own assistant conversation: a DM or a room holds what colleagues wrote, and no menu item deletes that. Refused with 409 while a job for that channel is queued or running, since the answer would land in a conversation the user just emptied. Mention rows and read markers go with the messages, so the next answer is not born already read.

**Ändrat**

- The surface toggle moved from its own header icon into the menu, where its label says which surface it switches to.

### 0.26.0 — 2026-09-22

**Ändrat**

- The name picker paints the people it already knows, filtered locally, and refreshes behind that instead of showing "Loading…" until the server answers. The list is fetched once when the panel opens and kept in the same per-user cache entry as the conversations — names only, never anything about who may see what.
- Picking someone you already have a direct message with opens it immediately, with no request at all. Only a genuinely new conversation waits for the server.
- The picker fills the panel instead of a 220px window inside it.
- A click outside the drawer closes it, the way other wp-admin overlays behave. Page mode is a screen, not an overlay, so it is exempt, and the launcher is excluded because it toggles the panel itself.

### 0.25.1 — 2026-09-22

**Ändrat**

- The plugin header names Viktor Borg as the author, with `https://github.com/vigg3` as the author URI. Regenerated catalogs carry the same name.

### 0.25.0 — 2026-09-22

**Ändrat**

- The settings screen follows the same shape as Customer Hub's: a sticky bar that always carries Save and says whether there are unsaved changes, a sectioned left column, and one card at a time instead of four headings down a long page. Form rows are label/control pairs in a 220px grid rather than a WordPress table.
- A new Overview section opens first and answers what an administrator checks before reading any field: whether a provider key is stored (and whether it comes from `wp-config.php`), which model is entered, and what the daily budget is. "Getting started" moved into it.
- Section links switch cards instead of jumping down the page, and the open section is kept in the URL hash so a bookmark or a browser back step returns to it. With no hash the server decides which card opens: the Provider card after a connection test, so its result is on screen rather than one click away, and Overview otherwise.

### 0.24.0 — 2026-09-22

**Ändrat**

- The "What is sent" help tab says what tool results now contain, and that the conversation itself is still sent as colleagues wrote it.

**Säkerhet**

- No tool result carries personal data to the provider any more. `BBL_Tools::run()` scrubs every result through the new `BBL_Redact`, before the transient is filled, so a cached entry is clean too and an integration cannot forget the step. Removed keys cover names, e-mail addresses, phone numbers, street addresses, postcodes, cities, personal numbers and IP addresses, at any depth and whatever their case; an e-mail address written inside free text is masked in place. Filter `bbl_redacted_keys` lets an integration add keys of its own.
- `order_lookup` no longer reads the customer's name or e-mail at all. It returns whether the order is a guest order; staff have the customer on screen in wp-admin next to the answer.

### 0.23.0 — 2026-09-22

**Tillagt**

- Integration registry: a plugin that adds agent tools through `bbl_agent_tools` declares itself through the new `bbl_integrations` filter with a name, version, the tools it registers and a note about what they read. Settings → Assistant → Integrations lists them with a checkbox each, so an administrator can see what a plugin offers the assistant and switch it off without deactivating the plugin.
- Setting `enabled_integrations`. A newly installed integration starts **off**: activating a plugin must not change what leaves the store for the provider until someone has seen what it does and said yes.
- Tools that arrive through the filter without a declaration are grouped under "Tools from an undeclared plugin" — visible and switchable rather than silently trusted or silently dropped.

**Ändrat**

- `BBL_Tools::resolve_tools()` drops a filter-registered tool whose integration is not enabled, so the model is never offered it and `run()` refuses it as an unknown tool. Tools registered in core are unaffected.
- The "What is sent" help tab says that an enabled integration's results go to the provider like every other tool result.

### 0.22.0 — 2026-09-22

**Ändrat**

- Schema 7: `bbl_jobs.message_id` is unique, so two workers retrying the same question cannot both create a job and answer twice. `BBL_Install::DB_VERSION` is 7; `maybe_upgrade()` adds the index through dbDelta.
- The SQLite `wpdb` double returns an empty result and sets `last_error` when a select fails, the way `wpdb` does, instead of throwing. A missing table used to throw in tests while production returned null, which let a test prove something production does not do.

### 0.21.0 — 2026-09-22

**Tillagt**

- The channel says what the agent is doing while it works: `GET /channels/{id}/messages` returns `agent_status` with the tool now running and a label already translated for display ("Searching the chat…"). The label is built server-side from the tool name and its validated arguments, never from model text, and is always null when `agent_busy` is false, so a status left behind by a crashed job is never shown under a finished answer. The value lives in a two-minute transient rather than a `bbl_jobs` column: it is worthless once the job ends. Filter `bbl_tool_status_label` lets an integration label its own tools.
- An answer records what it looked at: `meta.sources` holds up to twelve items a tool actually returned, each with its `source_id`, a label and a URL built server-side. The assistant is asked to cite a claim with `[[s:<id>]]`. The client renders those markers in the next release, and only for ids present in `meta.sources` — a reference the model invents has nothing to render.

**Ändrat**

- The default system prompt carries the citation instruction. A customized prompt is untouched; an untouched one is not stored and follows the new wording.
- `tests/test-i18n.php` no longer freezes the sv_SE default prompt to the 0.5.0 wording. That invariant kept a Swedish site on the text it read before the prompt became translatable, but it also meant no new instruction could ever reach one. It is replaced by the requirement that the translation keeps `calculate`, `analytics_report`, `source_id` and the `[[s:` marker syntax untranslated — a translated marker would make every citation unrenderable.

### 0.20.0 — 2026-09-22

**Tillagt**

- The agent can read the store's own published documents through two read-only tools: `list_documents` (what is readable) and `read_document` (one page as plain text). The readable set is WooCommerce's terms page, the site's privacy policy page and the pages an administrator picks under Settings → Assistant → Readable pages — nothing else. Drafts, password-protected pages, posts and unknown slugs are refused with one wording that does not reveal whether the page exists.
- Setting `context_pages`: up to 20 published pages the assistant may quote, such as shipping, returns and warranty. Ids are validated when saved and again on every read, so a page that is unpublished or protected afterwards stops being quotable at once.
- Each read carries a `source_id` (`doc:<slug>`) and the page's permalink, built server-side — the anchor the reference chips in a later release attach to.

**Ändrat**

- Document text comes from the stored content with shortcodes and markup stripped; shortcodes and dynamic blocks are never executed, since that would run third-party callbacks inside an agent job outside any page request. A page whose text lives only in a dynamic block therefore reads as empty rather than appearing complete.

### 0.19.0 — 2026-09-22

**Tillagt**

- The agent can read the chat itself when a question calls for it, through three read-only tools: `list_conversations` (the conversations you can see), `search_chat` (text search, default the last 90 days) and `read_conversation` (a window of messages, paged with `before`). Every read is scoped to the person asking — `BBL_Repository::visible_channels()` and `channel_for_user()` decide, so the agent never returns a private room, a DM or another user's assistant conversation the asker could not already open. A channel id the asker cannot see is refused with the same wording as one that does not exist, so the tools cannot be used to probe which conversations exist.
- Every match carries a `source_id` (`msg:<channel_id>:<message_id>`), the anchor the reference chips in a later release attach to.

**Ändrat**

- A tool callback now receives the asking user id as its second argument: `fn( array $args, int $user_id )`. Existing callbacks that only read shop data ignore it. A user-scoped tool must take it from there and never call `get_current_user_id()`, because the agent job runs in a background request with no user set.

### 0.18.1 — 2026-09-21

**Tillagt**

- A WP-CLI migration rehearsal (`tests/migration-rehearsal.php`) that exercises the schema 4 to 6 upgrade, the request-id constraint and the legacy rename against a real WordPress and MySQL, on an isolated table prefix. Every option the migration reads or deletes is snapshotted and put back, since option names carry no table prefix.

**Ändrat**

- Settings screen: help text and the system prompt are capped at a readable line length instead of spanning the full admin width; the section links read as navigation; "Getting started" collapses once a provider key is stored; the provider panel no longer repeats the card's numbered steps or the "in use" status the tab badge already shows; both price fields share one note that says the provider quotes US dollars while the labels carry the store currency; and the test button says it saves everything.

**Åtgärdat**

- The trailing read marker posts the newest message on screen instead of deferring itself indefinitely, so the last message of a burst no longer stays unread.
- The first message page is authoritative for the ids it covers: a locally cached message the server no longer returns is dropped instead of staying on screen. Older loaded history is kept, and a send confirmed while the request was in flight is never dropped. The poll cursor is the newest id the response covers: it moves down when a stale cached id is dropped, and it does not jump to a send confirmed mid-request, so nothing another author wrote below that send is skipped.
- After a dropped request an open conversation retries after eight seconds instead of sixteen.
- Inbox and divider dates follow the site language instead of the browser's: the client now receives the locale the interface is translated against and passes it to `toLocaleDateString`. A Swedish site in an English browser showed `9/18/2026`. A WordPress locale that is not a valid BCP 47 tag, such as `pt_PT_ao90`, falls back to the browser's format instead of throwing inside the inbox render.
- Swedish: `assistantsvar` corrected to `assistentsvar` in the new-group screen and the shared-answer label.
- The unread count on the floating launcher has its red background again. The launcher sits outside the themed chat root, where the colour variable it referenced is not defined, so the number was white on transparent.
- Browser test cases destroy their apps and remove their fixtures even when they fail, so one failure no longer makes later cases fail for the wrong reason.

### 0.18.0 — 2026-09-21

**Tillagt**

- The floating launcher shows a separate unread-mention count, alongside ordinary unread messages, with accessible labels. Confirmed reads clear both immediately.
- Background tabs share short-lived unread counts through a site/user-scoped lease; no conversation content is shared. Closed visible chat checks notifications every ten seconds and on startup. Older in-flight snapshots cannot replace newer shared counts.

**Ändrat**

- Conversation aggregates reuse persistent object cache when available. Fresh visibility checks, live message ids and read markers determine each cache key; role and membership checks remain uncached. Without persistent object cache, the existing database path is retained.

### 0.17.0 — 2026-09-21

**Tillagt**

- Session-scoped chat recovery after admin-page navigation, including drafts, selected mentions, reading position and explicit retry of uncertain sends. Recovery retains the most recently used conversations, including revisited chats after the 20-conversation limit.
- Durable per-user/per-channel request identity prevents duplicate sends after the former 15-minute retry window. Schema version 6 adds a nullable request id and a unique index without changing existing messages.
- Backward message pagination for loading older conversation history.

**Ändrat**

- Recently visited conversations render from a bounded memory cache before server revalidation.
- Conversation-list requests are coordinated and unchanged rows avoid unnecessary redraws. Idle polling backs off without overlapping requests. Opening the chat or returning to the tab during an active request queues an immediate refresh.
- Conversation previews fetch bounded text instead of complete message bodies and metadata.

### 0.16.0 — 2026-09-21

**Tillagt**

- Colleague and assistant mention picker, personal unread-mention badges, and explicit private-group creation from a personal assistant conversation. Only the composed message and an explicitly selected answer are shared; existing private history is never merged.
- Delegated access remains effective when capability filters use custom names, while external WordPress and WooCommerce capabilities are never granted.
- Site-scoped Babbla ownership and delegation. The installer owns a fresh single-site installation; existing or unattended installations use explicit owner setup. Owners assign administrators and chat users. Administrators manage settings and usage without delegation rights or additional private-conversation access.
- Navigable settings sections and an access page with user search. Delegation preserves existing grants by default, and colleague search includes directly granted chat roles. Store-data tools retain separate permissions when chat access is delegated.

**Ändrat**

- Chat lists and headers state their audience. Shared rooms keep a visible warning about shared assistant answers and requester-authorized store data. Nonmember administrators can no longer invite each other into private rooms.
- Mobile chat uses one pane with a back action. User pickers search the server, show retry actions and use accessible multi-selection controls. Assistant activity is shown in shared rooms too.
- Mention recipients and room creation are stored transactionally; room creation retries reuse the same room. Schema version 5 adds an indexed mention table while retaining existing conversations.

**Åtgärdat**

- Drafts and send responses remain scoped to their channel. Initial snapshots and concurrent messages merge in order without hiding replies or showing them in another chat. Failed read acknowledgements remain retryable.
- Queued assistant jobs recheck the original actor's access and use history ending at the triggering question. Later questions cannot replace the queued request's context.
- Private member lists require current membership even for administrators. User search accepts partial WordPress query rows without hiding eligible colleagues.
- Membership database failures return an error and roll back partial changes. Private-channel listing fetches memberships once instead of once per room.

### 0.15.0 — 2026-09-21

**Tillagt**

- **Swedish.** The plugin now loads its text domain and ships a complete `sv_SE` catalog: the chat client, the settings, usage and diagnostics screens, the system messages the assistant writes into a conversation, and the provider errors it reports. A site running any other locale keeps the English source strings.
- `languages/babbla.pot` for translators, plus `bin/make-pot.sh`, which regenerates it, merges the new strings into every shipped `.po`, and compiles the `.mo` and `.l10n.php` catalogs WordPress reads. Header updates normalize wrapped fields and only touch the catalog header; malformed headers and invalid printf translations stop generation.
- A test over the translation surface: every client string the JavaScript asks for is localized in PHP with the same English text (a missing key would silently show the fallback), every source string reaches `babbla.pot` (including echoing helpers, contexts and plurals), and every Swedish entry and plural form is translated and present in both compiled catalogs. Client translation calls require literal arguments, enforced by lint; `composer test` also runs the catalog-generator regression.

**Ändrat**

- Persisted assistant notices and errors use the configured site language during both enqueue and job execution, with the caller language restored afterward. Background dispatch no longer changes the language stored in a conversation.
- **The default system prompt is translatable**, and asks the assistant to answer in the language of the site instead of always in Swedish. On a Swedish site the prompt is the same text as before; on an English one the assistant now answers in English rather than Swedish under an English interface. The configured site locale (the `WPLANG` option, with WordPress fallbacks) takes precedence over the administrator's profile language, and saving an unchanged default keeps it following future locale and wording changes.
- An installation still running the 0.5.0–0.14.0 default prompt untouched picks up the new one on upgrade, the same rule that has applied to the 0.3.0 default since 0.5.0. A prompt the admin edited is never touched.
- Provider errors that reach the user ("Could not reach the OpenAI API.") are translatable. Two kinds stay English on purpose: a tool's argument errors, which only the model reads, and the provider's own HTTP error text, which the connection test shows verbatim because it comes from the API.
- A cleared system prompt falls back to the shipped one when the settings are saved. Empty prompts stored by older versions also fall back immediately on read, without requiring another save. An empty prompt still reached the provider, only with none of the instructions that keep an answer to tool data — a worse state than the default, and one nothing on the page announced.
- The release zip no longer carries the `.po`/`.pot` sources — WordPress loads the compiled catalogs, so those are development files.

### 0.14.0 — 2026-09-21

**Tillagt**

- **The assistant can answer customer questions.** `analytics_report` gains three metrics — `customers` (distinct customers with a paid order in the period), `new_customers` and `returning_customers` (split by WooCommerce's own `returning_customer` flag, so the numbers match its Analytics screens) — and a `customer` breakdown that returns one row per customer with their id and name, for "who buys the most".
- New and returning are decided per customer, not per order: someone who first orders during the period is new once, even if they order again within it, and returning is everyone else — so new plus returning always equals customers, including rows whose flag WooCommerce never set.
- An order with no customer record at all is left out of the customer counts and out of a per-customer breakdown, rather than becoming a customer named nobody.
- The tool's description lists the customer metrics among the order-level ones that cannot be split by product or category, so "how many customers bought product X" is not a question the model is steered into asking and having refused.
- The tool's note now states what a customer count includes: guests count, because WooCommerce keeps a customer record per guest email, so one person who ordered under two emails counts twice.

**Ändrat**

- Customer names come from `wc_customer_lookup`, the table Analytics maintains — no `wp_users`, no `usermeta`, no free-form SQL, and the same 50-row cap and paid-status predicate as every other breakdown. A customer breakdown is order-level, so it is refused together with a product or category split for the same reason the other order totals are.

### 0.13.0 — 2026-09-21

**Tillagt**

- **The assistant's answers are rendered.** It replies in Markdown, and the chat showed the markers verbatim ("**4 st**"). Headings, bullet and numbered lists, paragraphs, line breaks, bold, italic and inline code are now rendered as elements. Only the assistant's own messages are parsed — a colleague who types `3*4*5` means `3*4*5`.
- **A last-view cache** in `localStorage`, per user: the conversation you left is on screen at the first paint instead of the inbox → an empty conversation → the messages, which is three states for two round trips. The network result then replaces it in place.

**Ändrat**

- The view follows the agent while it is still working: the typing indicator now scrolls the conversation the way an arriving message does, instead of catching up only once the answer lands.
- The message list, the channel list and the overlay panels have a thin, dimmed scrollbar of their own instead of the platform slab, in both themes.
- An underscore only opens emphasis between word boundaries, so `net_total` and `wc_order_stats` survive the renderer intact, and a body over 20 000 characters is shown as it came rather than parsed.
- A message the server has confirmed is cached as soon as it is stored, not at the next poll, so a reload in between cannot come back without it.
- A conversation restored from the cache keeps its own poll cursor, so a refresh that fails does not make the next poll re-fetch the history and append it under what is already on screen. A failed refresh over cached messages says so, rather than letting them read as current.

**Säkerhet**

- Nothing is cached when the client has no user id: a key that cannot name its owner would be shared between whoever uses that browser.
- The Markdown renderer builds DOM nodes and never assigns HTML, so a message body cannot introduce markup. A tag typed into a message stays visible text, exactly as before.
- The cache key names the site as well as the user: a subdirectory multisite serves every site from one browser origin and a network user keeps the same id across them, so the key alone decided whether site A's conversation was painted over site B. Pruning leaves another site's entry alone.
- The cache is bounded on every axis: one key per user and site (other users' and older versions' keys are removed on boot), 24 hours, the 30 most recent messages of one conversation, and a 48 KB ceiling above which the messages are dropped and then the whole entry. Leaving a conversation clears its messages from the cache, and a conversation that no longer exists falls back to the inbox.

### 0.12.0 — 2026-09-21

**Tillagt**

- **Babbla → Diagnostics** (chat administrators only): a status panel that answers why a question went unanswered — whether a key is set and where it comes from, which of the three routes runs a job on this host, how many jobs have been waiting over five minutes, the last failure with its job id and error, and whether the log is on — plus a reader for the log itself and a button that deletes every log file.
- **A "Save and test connection" button** in each provider panel on the settings screen. It saves the form, then asks that provider one throwaway question with the key and model just entered, and reports either the model that answered and how long it took, or the provider's own error text verbatim. The test sends no tools, so it isolates "can this site reach this provider at all" from what the assistant does with tools.
- **A diagnostic log** (`BBL_Log`): the job's whole path — queued, dispatched (with the route), started, the provider request and its outcome, the answer or the failure — plus refusals (no key, missing capability, rate limit, budget) and settings changes. Level is `Off / Errors only / Errors and warnings / Everything the agent does / Debug` on the settings page, default *Errors only*.
- Log files are day-rotated under `wp-content/uploads/babbla-logs-<random>/`, closed off with an `.htaccess` and an `index.html` and named unguessably, deleted after 14 days by a daily cron event, and removed with the rest of the data when **Purge data on uninstall** is ticked.
- `BBL_Repository::stuck_jobs()` and `BBL_Agent_Runner::dispatch_method()`, both read by the status panel.

**Ändrat**

- Neither adapter sends an empty `tools` array any more: with no tools the key comes off the request entirely, which is what the connection test needs and what both APIs expect.

**Åtgärdat**

- The log reader read the whole day file into memory before showing 200 lines of it; at debug level on a busy site that is the file most likely to exceed an admin request's memory limit. It reads backwards from the end in 8 KB steps, capped at 1 MB.
- `stuck_jobs()` aged a running job from when it was asked rather than from when it was claimed, so a question that queued for a while and is being answered right now was reported as stuck. A running job is now judged on `started_at`, a queued one on `created_at`.
- Testing the provider that is not in use left its result inside a hidden tab: the redirect after a test opens the tested provider's panel (`view=`), without changing which provider the assistant uses.

**Säkerhet**

- Nothing from a conversation reaches the log: messages, tool results and channel names are never logged, a context value that is not a scalar is written as its size instead of its content, and anything key-shaped (an `sk-` key, a `Bearer` token, the configured key itself — whether it is stored in the database or defined in `wp-config.php`) is redacted from both the message and the context before the line is written.

### 0.11.0 — 2026-09-21

**Tillagt**

- **Setup help on the settings screen.** A "Getting started" card states the four steps from an empty install to a working assistant, each provider tab carries the link to that provider's API key page, the prefix its keys start with, and example model ids, and the price fields say where the two numbers come from.
- **A Help panel** (WordPress's own, top right of the settings screen) with four tabs: API keys (including the `wp-config.php` constants, which never reach the database), Models (the field takes any id the provider documents, so a model released after this build works), Cost and limits (what the budget, rate limit, prices and cache TTL actually do), and What is sent (which data leaves the site, and that staff-to-staff chat never does).

**Åtgärdat**

- **The assistant could not answer at all on a reasoning model.** OpenAI's `/v1/chat/completions` refuses to combine function tools with the reasoning effort such a model applies by default, and the error reached the channel as "Function tools with reasoning_effort are not supported for …". The adapter now retries that one error once with `reasoning_effort: none`, which is the API's own advice; the parameter is never sent up front, because a model that does not know it would reject the request.
- The provider is no longer decided by which tab is open: the tabs are navigation (`bbl_view`, ignored when saving) and each panel carries an explicit "Use <provider> for the assistant" radio, with an *In use* badge on the provider actually in use. Looking at the other provider's key no longer switches the assistant over on save, and a form that arrives without the field keeps the stored provider instead of resetting to Anthropic.
- Number and price fields were `small-text` (50px), which clipped a seven-digit token budget and a four-decimal price; they now have a width that fits the values they accept.
- The provider tabs' focus ring was a `box-shadow`, which forced-colors mode drops — a keyboard user in Windows High Contrast saw no focus at all. There is an `outline` for that mode, and the active tab's bottom border works there too.
- The radios are wrapped in a `fieldset` with a screen-reader `legend`, so the two options are announced as the Provider group rather than as two loose radios.
- The active tab carries `nav-tab-active` in the markup, not only through a CSS sibling rule.
- The active tab's `border-bottom-color` was a no-op: WordPress sets `border-bottom: none` on `.nav-tab`, so the colour applied to a zero-width border.

### 0.10.0 — 2026-09-21

**Ändrat**

- The settings screen is grouped into **Provider**, **Assistant**, **Limits and cost** and **Data** instead of one twelve-row table.
- The provider is picked with **tabs** (Anthropic / OpenAI) instead of a dropdown, and each tab's panel holds that provider's API key and model, so only the provider in use is on screen. The tabs are two radio inputs named `provider` styled as WordPress nav tabs, with the panels revealed by a CSS sibling rule — the visible tab *is* the stored setting, it works without JavaScript, and nothing has to be kept in sync.
- Number and price fields use WordPress's `small-text` width, price labels carry the store's currency code, and the system prompt is a taller monospace field.

**Åtgärdat**

- The top-level menu no longer shows a duplicate **Babbla** entry above Chat: `add_menu_page()` mirrors the root as a submenu item, and with no page behind the root that entry was dead.
- The "Remove key" checkbox only appears when a key is actually stored, and the key field's state line ("Not set." / "Saved (…abcd)") is tied to the field with `aria-describedby`.

### 0.9.0 — 2026-09-21

**Tillagt**

- **Agent usage page** (`Babbla → Usage`, `BBL_Usage`, chat-admin capability only): a period selector (1/7/30 days), a summary line (questions, tokens, failures, estimated cost), a per-user table (questions, failures, tokens in/out, average answer time, cost), a 30-day tokens-per-day bar graph marking days at or over `daily_token_budget`, and the 50 most recent jobs with status, tokens and error text. Job metadata only: the page never reads a message body or a channel name, so the chat admin still cannot see other people's conversations.
- `BBL_Repository::usage_by_user()`, `usage_by_day()`, `usage_totals()` and `recent_jobs()`. All four filter on `finished_at` (the indexed column, `KEY finished`), so an unfinished job is never counted; per-user rows are capped at 50, days at 400 and recent jobs at 50. The summary has its own total query rather than summing the capped list.
- Two settings, `price_in` and `price_out` — the price per one million tokens in the store's currency. Both default to 0, which hides every cost figure. A comma is accepted as the decimal separator.

**Ändrat**

- Babbla is its own top-level admin menu (`dashicons-format-chat`, just below WooCommerce) instead of three entries under WooCommerce: **Babbla → Chat / Usage / Settings**. The root has no page of its own, so WordPress opens the first submenu the user may see, and it is registered with whichever tier the user holds — a chat admin without chat access still reaches Usage and Settings. Page slugs (`bbl-chat`, `bbl-usage`, `bbl-settings`) are unchanged, so existing links still work.
- `tests/support/sqlite-wpdb.php` rewrites `TIMESTAMPDIFF(SECOND, …)` into SQLite's `strftime` arithmetic, and the rewrite now runs for reads as well as writes.

**Åtgärdat**

- `usage_by_day()` kept the oldest days when its LIMIT bit, so today's bar read zero on a full 30-day window; it now takes the newest days and hands them back oldest-first.
- The per-user average answer time counted failed jobs, letting one timeout dominate the column; it averages answered jobs only.
- The price field's `step` allowed two decimals while the setting stores four, so a sub-cent price could not be submitted.
- The estimated cost follows `woocommerce_currency_pos` instead of always suffixing the symbol.

### 0.8.0 — 2026-09-21

**Tillagt**

- `BBL_Install::migrate_from_wctc()` (`DB_VERSION` 4): an existing 0.7.0 install is moved in place — `RENAME TABLE wp_wctc_* TO wp_bbl_*` for all five tables, then `wctc_settings`, `wctc_purge_on_uninstall` and `wctc_db_version` copied to their `bbl_` keys and deleted. It runs from both entry points: `activate()` (the rename changes the plugin's file and folder, so WordPress installs Babbla as a new plugin and fires activation before `maybe_upgrade()` ever runs) and `maybe_upgrade()`. An empty table already sitting under the new name is dropped so the rows can still move; two populated tables are left untouched for a human to resolve. A failed `RENAME TABLE` or such a conflict makes the migration report failure: the schema is still created so the plugin runs, but `DB_VERSION` is left unrecorded and the next request retries instead of declaring the site migrated. A value already stored under the new option key is not overwritten, and `wctc_t_*`/`wctc_rl_*` transients are left to expire. Public, so a failed upgrade can be re-run with `wp eval 'BBL_Install::migrate_from_wctc();'`. Table existence is compared case-insensitively, since MySQL with `lower_case_table_names=1` answers in lower case whatever case `$table_prefix` uses.
- `uninstall.php` drops the legacy `wctc_` tables and options too, and reads the purge flag from `wctc_settings`/`wctc_purge_on_uninstall` when the site was uninstalled before the upgrade ran.

**Ändrat**

- **The plugin is renamed to Babbla** ("Babbla for WooCommerce"). Every prefix moves with it: classes `WCTC_*` → `BBL_*`, functions, hooks, options and table names `wctc_*` → `bbl_*`, constants `WCTC_VERSION`/`WCTC_ANTHROPIC_KEY`/`WCTC_OPENAI_KEY` → `BBL_*`, text domain `wc-team-chat` → `babbla`, main file `wc-team-chat.php` → `babbla.php`, `includes/class-wctc-*.php` → `class-bbl-*.php`, `assets/{js,css}/wctc-chat.*` → `bbl-chat.*`, CSS classes `.wctc-*` → `.bbl-*`, the JS globals `wctcConfig`/`window.WCTC` → `bblConfig`/`window.BBL`, and the per-user UI preference key `wctc.ui.<id>` → `bbl.ui.<id>` (a user's remembered surface and theme reset once).
- The REST namespace is `babbla/v1` (was `wctc/v1`) and every error code is `bbl_*` (was `wctc_*`); `docs/rest-contract.md` is bumped to v0.3. No route, payload or field changed.
- No `wctc_` alias is kept: the old hooks, options and namespace are gone, since nothing outside this workspace consumes them before 1.0.

### 0.7.0 — 2026-09-21

**Tillagt**

- New chat UI (`assets/js/wctc-chat.js`, `assets/css/wctc-chat.css`): a floating launcher bottom-left opens a compact chat window (inbox → conversation); the admin bar node opens a two-pane drawer that overlays the page without moving it; the Team chat page keeps a two-pane layout. Messenger-style grouped bubbles (same author within 5 minutes, avatar on the last bubble), time dividers after 15 minutes, sending/sent/failed/too-long states, an agent typing indicator, starter suggestions in the empty agent channel, a light/dark/system theme scoped to the chat root, and room creation and management (rename, members, leave, archive/restore).

**Ändrat**

- The dock/undock drawer mode is removed; the drawer no longer adds a body class or pushes page content.

**Åtgärdat**

- The window surface always opens on the inbox, and the drawer/page auto-selects the channel the inbox actually lists first (sorted by `last_message`), not the first channel in fetch order.
- A background poll no longer marks the open channel read unless the conversation is the view on screen: the window surface can sit on the inbox, and any surface can cover the conversation with an overlay (room info, new room, new DM).
- Esc now dismisses an open menu first, then an open overlay (new DM, new room, room info), and only then the surface. An overlay opened on top of another overlay keeps the view underneath as the way back, so Esc and Back can no longer bounce between two overlays.
- Room mutations (create, rename, add/remove member, leave, archive) report the server's error: an invalid member, a bad name, a forbidden action or a 429, instead of failing silently.
- Room info fills the member list and its actions in place once membership resolves, so "Leave room" reflects real membership on first open without discarding a half-typed room name, and a failing members request shows an error instead of re-rendering the panel in a loop. "Add people" is only offered to a caller who can manage the room. A failed member refetch after a successful add/remove reports the error and keeps the list it had, instead of blanking it. Leaving a room clears the button and closes the panel.
- The user picker in new DM / new room / add people has a name filter; the members subtitle uses a singular string at one member. `wctc_bad_room` only claims the name is wrong where there is a name field — the REST API also returns it for a bad audience and an empty archive payload.
- The open inbox with no channel selected (the window surface's default) polls the channel list every 8 seconds instead of falling through to the badge-only 30-second `/unread` poll, so a new room or message shows up without selecting a channel first.
- The closed floating surface marks only its own root `inert`; it no longer sets `inert` on the parent node (the page body in window/drawer mode).
- `destroy()` now also removes the document-level menu click handler and the `matchMedia` theme/reduced-motion listeners, and the global `* { box-sizing }` rule is scoped to the chat root so the chat stylesheet cannot restyle wp-admin.

### 0.6.0 — 2026-09-18

**Tillagt**

- Chat rooms (`WCTC_Install::DB_VERSION = '3'`): a new `wctc_members` table and `wctc_channels.archived_at`; `uninstall.php` drops `wctc_members` too. Any user with chat access can create a room (`POST /rooms`) with audience `everyone` (dynamic, stored as `type: public`) or `members` (`type: private`, explicit membership). Manage (rename, add/remove members, archive/unarchive) is the room's creator or the chat admin; any member can leave. The chat admin can manage a private room's membership without being able to read its messages, same rule as a DM/agent channel, and is never auto-added. `general` and the `dm`/`agent` types are never manageable through the room routes.
- A caller who can manage a room but not see it (chat admin who is not a member, or a creator who left) gets `last_message: null`, `unread: 0` and `last_id: 0` in every Channel response. `PATCH /rooms/{id}` without `name` or `archived` returns 400 `wctc_bad_room`; a value equal to the current one writes nothing and posts no system message. `last_message.author_name` is `""` for `system` messages.
- `PATCH /rooms/{id}`, `GET/POST /rooms/{id}/members`, `DELETE /rooms/{id}/members/{user_id}`, and `GET /rooms` (chat-admin-only room list, no message text). Every manage action posts a system message ("created", "renamed", "archived"/"restored", "added", "left"/"removed").
- `POST /channels/{id}/messages` into an archived room now returns 409 `wctc_archived`; reads still work. An archived room drops out of `GET /channels` but stays reachable by id.
- `deleted_user` now removes that user's room memberships (`WCTC_Repository::delete_user_memberships()`).
- The Channel object gains `last_message` (author, truncated body, timestamp, `mine`, `kind`, or `null`), `created_by`, `archived` and `can_manage`; `last_message` is fetched in one extra query across every visible channel, never per channel.

**Ändrat**

- `docs/rest-contract.md` bumped to v0.2: rooms, the archived-room 409, and the Channel object's new fields, documented for every route that returns a Channel.
- `@<agent name>` mentions target the agent in `public` and `private` rooms (`WCTC_Agent_Runner::targets_agent()`).
- `PATCH /rooms/{id}`, `POST /rooms/{id}/members` and `DELETE /rooms/{id}/members/{user_id}` share the existing per-user rate limit with posting a message and creating a room.

### 0.5.0 — 2026-09-18

**Tillagt**

- `calculate` tool (`WCTC_Calculator`): a whitelisted expression evaluator with its own tokenizer and recursive-descent parser — never `eval()`/`create_function()`, no identifier outside `sum`/`avg`/`min`/`max`/`round`/`abs`/`pct_change`/`pct`. Numbers, `+ - * / %`, parentheses and unary minus; errors (division by zero, an unknown token/function/bare name, unbalanced parentheses, a malformed or oversized number literal, an over-length expression, a >200-token expression, a non-finite intermediate or final result) are returned as tool errors, never thrown out of the loop. A number token is strictly `\d+(\.\d+)?` (a leading, trailing or repeated `.` is rejected) and `%` scales decimal operands to an exact integer remainder instead of `fmod()`'s binary-float noise.
- `analytics_report` tool (`WCTC_Report_Tool`): a generic, whitelisted sales/refund breakdown over WooCommerce Analytics — 1-4 metrics from `net_sales`, `gross_sales`, `orders`, `items_sold`, `avg_order`, `refunds`, `product_net_revenue`, `product_qty`, optionally grouped by `day`/`week`/`month`/`product`/`category`/`customer_country`/`payment_method` (HPOS only), with `product_ids`/`category_ids`/`customer_country` filters. No free SQL: every fragment is keyed by a fixed whitelisted name and every value goes through `$wpdb->prepare`. An order-level metric (`net_sales`, `gross_sales`, `orders`, `items_sold`, `avg_order`, `refunds`) is always rejected with a product/category breakdown or filter, to avoid double counting; `refunds` is rejected with any filter and only supports no grouping or a date grouping, and (unlike the other metrics) runs its own unbounded `s.parent_id > 0` query, merged back in by group and re-sorted in PHP so a `limit` never drops the group the merge needed. A `category_ids` filter matches via `EXISTS` (never a row-multiplying join) and expands to descendant categories through `wc_category_lookup`; `group_by category` keeps the join instead, so a product in two categories counts in both, same as WooCommerce Analytics.
- `WCTC_Agent`: the tool-calling loop now allows up to 8 rounds (was 5); `WCTC_Agent_Runner` gives it a 90s deadline (was 60s) under a 120s `set_time_limit()` (was 90s), so a question needing several `analytics_report`/`calculate` round trips has room to finish.

**Ändrat**

- The default system prompt now tells the assistant to use `calculate` for every calculation and state which numbers it used, and to reach for `analytics_report` for any question about a period, a breakdown or a comparison — replacing the older "never guess, never work out a number yourself" line from `docs/brief-2026-09-18.md`. An install that never touched the previous default (kept as `WCTC_Settings::LEGACY_DEFAULT_PROMPT`) picks up the new default on upgrade; any prompt actually edited is left as stored.

**Åtgärdat**

- `analytics_report`: the SQL `ORDER BY`/`LIMIT` used to rank by the wrong column whenever `avg_order` was the primary metric (its own alias doesn't exist in SQL — the literal average expression does now) or whenever `refunds` was combined with another metric over a `limit` (each query's own top-N could disagree, silently zeroing out a shown period's refund total or returning the earliest days instead of the largest). `order_by: group_asc` with `group_by: product` is now rejected instead of actually sorting by product id. `order_by: group_asc` with `group_by: category` no longer re-sorts rows by name in PHP after the SQL query already ordered them by `t.name ASC`, which could scramble non-ASCII names under PHP's byte-wise comparison.
- `WCTC_Tools::validate_value()` now enforces a schema property's `maxLength`/`minLength` for every tool, not just its `type`/`enum`/`minimum`/`maximum`.
- `calculate`'s `%` on decimals falls back to `fmod()` (rounded to 6 dp) whenever the integer-scaled exact-remainder path would lose precision — either scaled operand at or above 2^53, or a divisor with more decimals than the 6 dp cap keeps, whose own scaled value rounds to 0 without the divisor being zero (previously misreported as "Division by zero."). A number literal's significant-digit count now ignores leading zeros, so `0.000000000000001` is accepted while a 16-significant-digit literal is still rejected.

### 0.4.0 — 2026-09-18

**Tillagt**

- `WCTC_Tools`: schema-validated, transient-cached tool registry (`register`/`all`/`run`/`resolve_tools`), a `guarded()` server-side statement time limit (MariaDB `max_statement_time` / MySQL `MAX_EXECUTION_TIME`), `analytics_ready()` and `date_range()` (400-day cap, real-calendar-date validated). `run()` also refuses per-call when the asking user doesn't hold the tool's capability, and resolves both core and `wctc_agent_tools` filter tools from the same merged registry as `all()`.
- Core tools (`WCTC_Core_Tools`): `shop_info`, `sales_summary`, `top_products`, `order_lookup` against WooCommerce Analytics (`wc_order_stats`, `wc_order_product_lookup`) and `wc_get_order`, read-only, indexed predicates only, always the paid statuses (no `status_set`/`all_non_cancelled`). `order_lookup` is never cached.
- `WCTC_Agent`: the provider-agnostic tool-calling loop (max 5 rounds, one shared deadline), history → conversation mapping, token/tool accounting. The tool set is scoped to `$config['user_id']` (the asking user from the job row), not inferred from the last human author in history; a tool name outside that set is refused as an `is_error` tool_result without ever running its callback.
- Provider adapters `WCTC_Provider_Anthropic` (Messages API, thinking-safe verbatim replay, byte-faithful `"input":{}` echo) and `WCTC_Provider_OpenAI` (Chat Completions), both `disable_parallel_tool_use`/`parallel_tool_calls: false`. Both allowlist the provider's stop/finish reason so a `max_tokens`/`length` cutoff, a `content_filter`/`refusal` decline, or anything else unrecognised (e.g. `pause_turn`, an empty final text) always errors instead of returning a partial answer; provider error text is scrubbed of API-key-shaped fragments before it's returned.

### 0.3.0 — 2026-09-18

**Tillagt**

- `wctc_jobs` table (`WCTC_Install::DB_VERSION = '2'`), one row per agent question; `uninstall.php` drops it.
- `WCTC_Settings`: the `wctc_settings` option, provider/model/agent-name/system-prompt/capability/rate-limit/budget/cache-TTL settings, `wp-config.php` API key constants taking precedence over the option, and the WooCommerce > Team chat settings admin page.
- `WCTC_Agent_Runner`: targets agent channels and `@<agent name>` mentions in public channels, gates on agent-enabled/capability/rate-limit, queues a job and dispatches it (`fastcgi_finish_request()`, else Action Scheduler, else a signed loopback `POST /jobs/{id}/run`), with a single-UPDATE claim lock (stale after 5 min, max 3 attempts), a daily token budget check, and try/catch/finally so a PHP-level error never leaves a job stuck `running`. Calls `WCTC_Agent` (provided by a separate PR) only through `class_exists()`.
- `wp wctc job run <id>` and `wp wctc job list [--status=<s>]` WP-CLI commands.
- `GET /channels/{id}/messages` now also returns `agent_busy`.

**Ändrat**

- The agent's display name now reads from `WCTC_Settings::get( 'agent_name' )`; the old `wctc_agent_name` option is no longer used.

**Åtgärdat**

- The settings page shows a purge opt-in made through the legacy `wctc_purge_on_uninstall` option as ticked, so saving the page no longer cancels it.
- `uninstall.php` reads the purge flag from `wctc_settings['purge_on_uninstall']` (falling back to the legacy `wctc_purge_on_uninstall` option), instead of an option `WCTC_Settings` never writes.
- The channel never sees a raw Throwable message (which may contain paths or SQL) — only a generic notice with the job id; the raw text still lands in the job row's `error` column. A `WP_Error` from the engine, already user-readable, is still shown as-is.
- A blank agent name falls back to the default "Agent"; the `@<name>` mention regex no longer matches a bare `@` when the stored name is empty.
- `maybe_enqueue()` leaves a system message and queues no job when `insert_job()` fails, instead of dispatching job id `0`.
- `wp wctc job run` now validates its `<id>` argument and errors on a missing or non-numeric one, instead of silently running job `0`.

### 0.2.0 — 2026-09-18

**Tillagt**

- Chat client (`assets/js/wctc-chat.js`, vanilla JS, no jQuery): drawer opened from the admin bar and a full WooCommerce → Chat page, channel list, composer with optimistic send, polling with backoff, unread badge, dock mode, reduced-motion support.
- `WCTC_Admin`: admin bar node, `WooCommerce → Chat` submenu page, and asset/config loading for capable users.
- Browser test `tests/browser/chat-flow.html` driving the client end to end.

### 0.1.0 — 2026-09-18

**Tillagt**

- Plugin skeleton: activation/upgrade hooks, HPOS compatibility declaration, WooCommerce presence check.
- Storage: `wctc_channels`, `wctc_messages`, `wctc_reads` tables (`WCTC_Install`), seeded with a public `general` channel.
- `WCTC_Repository`: channel visibility (public, private per-user agent, DM), message pages, read markers, unread counts, lazy DM and agent channel creation.
- REST API `wctc/v1`: `GET /channels`, `GET /channels/{id}/messages`, `POST /channels/{id}/messages` (idempotent, rate-limited), `POST /channels/{id}/read`, `GET /unread`, `POST /channels/dm`, `GET /users`. Contract documented in `docs/rest-contract.md`.
- `uninstall.php` purge, opt-in via the `wctc_purge_on_uninstall` option.
- `README.md` for GitHub: status, features and roadmap, requirements, access model, privacy, hooks, development gates.
- `composer php:compat` gate (PHPCompatibility, `testVersion 7.4-`) next to PHPStan's PHP 7.4 target.

**Ändrat**

- Message bodies are stored as plain text (valid UTF-8, normalised line breaks, control characters removed) instead of through `sanitize_textarea_field`, which HTML-escaped a lone `<` and dropped `%xx` sequences.
- Read markers are written atomically (`INSERT IGNORE` + conditional `UPDATE`) and the stored value is returned, so concurrent `POST /read` calls no longer collide on the primary key and the marker never moves backwards.
- Idempotency keys are scoped per user and channel; a failed insert returns `500 wctc_store_failed` instead of a `201` with an empty message.
- DM and agent channel lookups read before inserting, so `GET /channels` no longer consumes an `AUTO_INCREMENT` value on every call.

## [Egenverk Docs Viewer](https://egenverk.se/plugins/docs-viewer/changelog)

### 2.7.0 — 2026-09-23

**Tillagt**

- **Syntax highlighting.** Prism 1.30.0 (MIT) ships in `assets/vendor/prism/` (core plus markup, clike, javascript, markup-templating, php, json, bash, yaml, sql, diff, css, scss, typescript, jsx, tsx, ini; the npm package's `.min.js` files concatenated unchanged, source and rebuild steps in its README). It is enqueued only on the viewer screen, in manual mode, and `viewer.js` highlights the rendered document (Markdown code blocks and non-Markdown files shown as code) before any heading jump; the Raw view stays plain. `htm`, `conf` and `less` map to markup, ini and css. Token colours use kit tokens in `viewer.css` (all at least 5:1 on the code background), not a Prism theme.

**Åtgärdat**

- `docs/ARCHITECTURE.md` no longer says "Two options".

### 2.6.0 — 2026-09-23

**Tillagt**

- **What's new after an update.** When a scan finds a plugin at a higher version than in the previous index (`version_compare()`), it records `{ from, to, at }` in the new option `egenverk_dv_updates` (not autoloaded). The plugin's row on the Plugins screen then leads with **What's new in X.Y** (plugins with a root changelog or `readme.txt` only), linking to the changelog with `&dv_version=<to>&dv_since=<from>`.
- `Changelog::since()` cuts a Markdown changelog (a `readme.txt` is converted first) to the release sections newer than `from` and not newer than `to`: a release heading is one that opens with a version (`[2.1.0] — …`, `v2.1.0`, `2.1.0`), sections are split at the level of the first such heading, the cut stops at the next shallower heading (so a readme's Upgrade Notice is not included), and fenced code is skipped (a fence closes only with the same character and at least its length). The viewer shows that cut under a note with a link to the full changelog; when nothing matches it shows the whole file.
- An administrator (`manage_options`) opening the cut changelog for the announced update (`dv_since` equal to the stored `from`) clears its entry for all users; readers with only the `egenverk_dv_capability` capability do not. Entries also expire after 30 days (`Plugin::UPDATE_TTL`), and are dropped when the plugin is gone or downgraded below the announced version. A second update before anyone reads the first keeps the original `from`, so both releases show. Only plugins are tracked, not themes or must-use plugins. The old-URL redirect keeps `dv_since`.

**Åtgärdat**

- The Changelog link's `dv_version` is URL-encoded, so a version with `+` build metadata no longer arrives with a space.

### 2.5.0 — 2026-09-23

**Tillagt**

- **Themes and must-use plugins.** The scan also indexes every installed theme (`wp_get_themes()`, all theme roots) and every must-use plugin that sits in its own folder under `WPMU_PLUGIN_DIR`, with the same file rules as plugins (root docs plus `docs/`). They are keyed `theme:<stylesheet>` and `mu:<folder>`; plugins keep their folder name. Records carry a `type` (`plugin`, `theme`, `mu`); records from an older index have none and are read as plugins, and the next scan rewrites them. Order: plugins, then themes, then must-use plugins.
- `Scanner::root_of()` maps a key to its folder and URL from WordPress's own roots (never from stored paths); `resolve_path()` and `asset_url()` use it, so the three file gates and relative images work the same for themes and must-use plugins. Plugin folders whose name contains `:` are skipped so they cannot pass for a prefixed key. Keys whose folder part is empty or starts with `.` are rejected (so `theme:` cannot mean the active theme and `theme:.` cannot mean the whole themes folder), and `asset_url()` now also requires the key to be in the index.
- The top-bar switcher groups entries under Plugins, Themes and Must-use plugins when more than one kind is indexed. Search covers all of them.
- Theme installs, updates (`upgrader_process_complete` with `type` `theme`) and deletions (`deleted_theme`) queue a rescan like plugin changes.

**Ändrat**

- The scan button reads "Scan for docs" (was "Scan plugins folder"); the empty state mentions themes.
- The "View Docs" link setting shows only for plugins, since themes and must-use plugins have no Plugins-screen row.
- `Admin::clean_slug()` rejects values containing `/`, `\\` or `..` instead of stripping characters from them.

### 2.4.0 — 2026-09-23

**Tillagt**

- **Read access for other roles.** The `egenverk_dv_capability` filter sets the capability needed to open Tools → Docs Viewer and search (`Admin::read_cap()`, default `manage_options`; a non-string or empty value falls back to it). Scanning, the multisite auto-rescan and the "View Docs" link setting still require `manage_options`: users without it see no scan button and no link setting, the empty state asks them to have an administrator run a scan, and the admin-post handlers reject them as before. Documented in the readme FAQ, `docs/USER_GUIDE.md` and `docs/SECURITY.md`.
- Plugins-screen links (View Docs, Changelog, Browse Docs) are only added for users who can open the viewer.

**Ändrat**

- Third-party admin notices are removed only on the viewer screen itself (`tools_page_egenverk-docs-viewer`), not on any admin page that carries `page=egenverk-docs-viewer`.

### 2.3.0 — 2026-09-23

**Tillagt**

- The old `admin.php?page=egenverk-docs-viewer` URL redirects (301) to Tools, keeping `dv_plugin`, `dv_file`, `dv_mode`, `dv_search`, `dv_version` and `scanned`; the browser keeps any `#heading`. To be removed after 2.3.x.

**Ändrat**

- **Tools → Docs Viewer.** The viewer is a Tools sub-page (`add_management_page`, `tools.php?page=egenverk-docs-viewer`) instead of a top-level menu, per the Egenverk admin-menu rule; the menu icon constant is gone (sub-pages carry no icon). All links (Plugins screen row links, the plugin's own Browse Docs link, relative Markdown links, search, scan and toggle redirects) go through `Admin::page_url()`. Capability unchanged.

### 2.2.2 — 2026-09-23

**Ändrat**

- `Link_Injector` hooks on `load-plugins.php` (and on `wp_ajax_search-plugins`, the Plugins screen's live search) instead of `plugins_loaded`, so front-end and other admin requests no longer load the scan index (a non-autoloaded option, one query per request) or add per-plugin filters. The rescan hooks stay registered on every request, so updates from cron and WP-CLI still rebuild the index.

**Åtgärdat**

- Multisite: on a site a network activation did not reach, the injector read the settings before the lazy migration on `admin_init`, so the first Plugins screen visit had no View Docs links. `load-plugins.php` runs after `admin_init`.
- `egenverk_dv_scanned_at` and `egenverk_dv_plugins_changed_at` are written with `sprintf( '%.6F' )` (`Plugin::stamp()`). PHP 7.4 converts floats to strings using the locale, so with a comma decimal locale the fraction was lost and a site could miss a rebuild within the same second. Stored values stay numeric strings, read as before.
- A rescan after a plugin change also removes option keys of the earlier names, which an old copy of the plugin that is still active can write back after the migration.

### 2.2.1 — 2026-09-23

**Ändrat**

- The Egenverk symbol (signature lockup) and the Docs Viewer glyph are redrawn with centred, evenly spaced bars (kit 1.1.0 re-copied: `brand/`, `glyphs/docs*.svg`). `Admin::MENU_ICON` and the inline signature use the new paths; wordpress.org icon, banners and screenshots rebuilt.

### 2.2.0 — 2026-09-23

**Ändrat**

- **Egenverk admin look.** The viewer and the empty state run on the shared Egenverk UI kit 1.1.0, shipped in `assets/egenverk-ui/` (CSS, a small dependency-free JS, Geist and Geist Mono woff2 under the OFL, the Docs Viewer glyphs and the Egenverk symbol). It is enqueued only on the plugin's own screen and makes no external request. The wrapper carries `egenverk-ui` with `data-product="docs"`, so links, the active file, the Preview/Raw switch and search highlights use the Docs Viewer colour; the plugin's own colours are mapped to kit tokens. Zebra rows in rendered Markdown tables are gone.
- The empty state has the kit header (glyph, title, signature, `hr.wp-header-end`); the viewer's top bar shows the glyph in place of the dashicon. The admin menu icon is the Docs Viewer glyph (`Admin::MENU_ICON`, a base64 SVG data URI) instead of `dashicons-media-document`.
- The "by egenverk" signature is the Egenverk lockup SVG. "by" is written literally and is no longer translatable, so the `by` entry is gone from the `.pot`.
- The scan button shows the kit loader while the scan reloads the page.
- New wordpress.org icon and banners (source in `.wordpress-org/src/`) and screenshots.

**Åtgärdat**

- On screens up to 782 px the top bar wraps, so the search field and the scan button no longer run off the right edge.
- The narrow-screen media query used range syntax (`width <= 782px`), which Safari before 16.4 ignores; it is `max-width: 782px` again, and stylelint now requires the prefix form.
- "Tested up to" is declared only in `readme.txt`; the plugin header no longer repeats it (wordpress.org review).

### 2.1.1 — 2026-09-23

**Tillagt**

- Activation moves settings and the scan index from the option keys of both earlier names (newest first, so the most recent value wins) and removes them, including the earlier network option on multisite. Sites a network activation did not reach migrate on their first admin visit; an autoloaded `egenverk_dv_migrated` flag keeps that check free once done.

**Ändrat**

- **Renamed to Egenverk Docs Viewer**, on the slug wordpress.org assigned (`egenverk-docs-viewer`). Main file `egenverk-docs-viewer.php`, folder and text domain `egenverk-docs-viewer` (translation template `languages/egenverk-docs-viewer.pot`), namespace `Egenverk\DocsViewer`, constants `EGENVERK_DV_*`, option keys `egenverk_dv_plugins` / `egenverk_dv_settings` / `egenverk_dv_scanned_at` and the network option `egenverk_dv_plugins_changed_at`, admin page `admin.php?page=egenverk-docs-viewer`, admin-post actions `egenverk_dv_scan` / `egenverk_dv_toggle`, asset handles and CSS classes `egenverk-dv-*`, template helper `egenverk_dv_render_tree()`. `npm run package` builds `dist/egenverk-docs-viewer-<version>.zip`. Author is Egenverk (https://egenverk.se); the wordpress.org contributor is unchanged. The GitHub repository is `egenverk-docs-viewer`.
- The admin menu and the viewer read "Docs Viewer", signed "by egenverk" with the egenverk symbol and wordmark.
- Directory screenshots updated; wordpress.org banner (1544×500, 772×250) and icon (256, 128) added to `.wordpress-org/`.

**Åtgärdat**

- In 2.1.0 heading ids gained a `dv-h-` prefix, but a link from another document to a heading (`docs/guide.md#setup`) and any bookmarked `#slug` URL still carry the bare slug, and nothing read the URL fragment on load, so the document opened at the top. The viewer now maps the fragment to the heading on load and when only the fragment changes (a link to a heading in the same document written as `guide.md#setup`).
- On multisite, network activation built the index for the main site only and subsites showed the empty state; it now stamps the network so every site builds its index on its next admin visit. The change and scan stamps are now microtime floats, so a scan and a change in the same second are ordered correctly without rescanning the site that made the change.
- The 2.1.0 changelog described heading ids as `intro`, `intro-1`; they are `dv-h-intro`, `dv-h-intro-1`. `docs/ARCHITECTURE.md` now covers the multisite rescan.

### 2.1.0 — 2026-09-23

**Tillagt**

- **Search all docs** in the top bar: a case-insensitive search through every scanned document of every plugin, each read through `Scanner::read_file()` and its file gate. Results show plugin and file with up to three escaped, highlighted lines per file; queries need 2–100 characters and at most 100 files are listed. The query is matched as typed (so `<div` or `%20` can be found; control characters are dropped), and files that are not UTF-8 are read as ISO-8859-1.
- **On this page** in the sidebar: the current document's level 2–3 headings (1–3 when it has no level-2 heading) as jump links, shown when there are at least two. `Markdown::document()` returns the headings alongside the HTML. Jumps scroll the document pane itself, so the top bar stays in place (on narrow screens, where the page scrolls, the page moves instead). Heading ids now carry a `dv-h-` prefix so they cannot clash with wp-admin element ids; in-document `#anchor` links written with the bare slug still work.
- **Changelog** action link on the Plugins screen next to View Docs, for plugins with a root `CHANGELOG` or `readme.txt`. It opens the file at the heading for the installed version (`&dv_version=`, matched as a whole version so 2.2.1 does not hit 2.2.10), else at the Changelog heading.
- The index is rebuilt automatically after plugin installs and updates (`upgrader_process_complete`), activation, deactivation and deletion — once per request, on shutdown — and when this plugin is activated. On multisite, where plugins are shared but each site has its own index, a change also stamps the network (a network option) and every other site rebuilds its index on its next admin visit by an administrator; each site records when it last scanned. The scan button stays for changes made outside WordPress.

**Ändrat**

- `docs/USER_GUIDE.md` rewritten for the current viewer (it still described a card-based list from before 1.0.0); `docs/MARKDOWN_SUPPORT.md`, `docs/SECURITY.md` and `docs/ARCHITECTURE.md` cover folder links, search and rescans. Directory screenshots updated, with a fourth showing search.

**Åtgärdat**

- Two headings with the same text in one document got the same `id`, so the second could never be linked. Ids are now unique the way GitHub makes them (`dv-h-intro`, `dv-h-intro-1`, …).
- A relative link to a folder found the folder's README case-insensitively on the folder name too, so `x` opened `docs/README.md`. Only the README file name is matched case-insensitively now.

### 2.0.1 — 2026-09-23

**Tillagt**

- `readme.txt` FAQ: security issues go to the WordPress.org Plugins Team (plugins@wordpress.org), not the support forum.

**Åtgärdat**

- Relative links and images in a rendered document were broken: `esc_url()` prefixes a target without a scheme with `http://`, so `Guide` pointed to `http://docs/guide.md` and `![](docs/shot.png)` never loaded. Relative targets are now resolved against the document's folder. A link to another scanned doc of the same plugin, or to a folder whose README is scanned, opens in the viewer through the usual file gate; an image inside the plugin gets its `plugins_url()` address (image extensions only, realpath-contained, nothing is read); any other relative target renders as plain text. Targets with control characters are rejected, because a NUL byte reaching `realpath()` throws on PHP 8.
- `docs/ARCHITECTURE.md` named a non-existent `page=…-view` viewer page.

### 2.0.0 — 2026-09-22

**Tillagt**

- On activation, settings and the scan index move from the old `docs_viewer_*` option keys to the new ones (`Plugin::migrate_legacy_options()`); the stored format is unchanged, a value already under the new key wins, and the old keys are deleted.

**Ändrat**

- **Renamed** from Docs Viewer to an interim name, because "Docs Viewer" is already the name of a commercial add-on and close to "Document Viewer" in the wordpress.org directory, and the directory rejects names confusingly similar to existing plugins. Main file, folder/slug, text domain, namespace, constants and option keys changed with it; this is breaking because WordPress sees a new plugin folder. The interim name was replaced by Egenverk Docs Viewer before the plugin was published (see 2.1.1).
- Directory screenshots show the plugin under its new name.

**Åtgärdat**

- The scanner skipped its own folder by the hard-coded slug `docs-viewer`; it now uses the plugin's real folder name, so it keeps skipping itself after the rename.

### 1.1.4 — 2026-09-22

**Ändrat**

- The viewer template's own variables are prefixed with `docs_viewer_`, so Plugin Check reports no warnings. No visible change.

**Åtgärdat**

- `readme.txt` still carried the `WPORG_USERNAME` placeholder in `Contributors`, which wordpress.org would reject. It now names the plugin's wordpress.org account.

### 1.1.3 — 2026-09-22

**Åtgärdat**

- `languages/docs-viewer.pot` sent translation bug reports to the private GitHub repository that 1.1.2 stopped linking to. `Report-Msgid-Bugs-To` now points to the WordPress.org support forum.
- `README.md` still listed the removed Plugin URI, and directory screenshot 3 showed the 1.1.1 plugin row with the removed "Visit plugin site" link. Both now match the current header.

### 1.1.2 — 2026-09-22

**Tillagt**

- Directory screenshots in `.wordpress-org/` (viewer, Raw mode, Plugins screen link) for the wordpress.org SVN `assets/` folder; they are not part of the plugin zip.
- `npm run package` checks every shipped file against an optional, untracked deny list (`scripts/.release-deny`) and refuses to build on a match, so no tooling reference can reach the published zip.
- `languages/docs-viewer.pot` regenerated for 1.1.2; the removed header URIs are no longer translatable strings.

**Åtgärdat**

- The Plugins screen showed a "Visit plugin site" link and the author name linked to GitHub, but the Plugin URI pointed at a private repository, so the link led to a 404. The `Plugin URI` and `Author URI` headers are removed.

### 1.1.1 — 2026-09-22

**Tillagt**

- wordpress.org readiness: a `readme.txt` in the wp.org format (description, installation, FAQ on the security model, screenshots, changelog), a `Domain Path: /languages` header with a generated `languages/docs-viewer.pot`, and `Tested up to: 7.1`. `load_plugin_textdomain()` is deliberately not called: WordPress loads translations for wp.org-hosted plugins automatically since 4.6, below the plugin's 5.0 minimum.
- `npm run package` builds `dist/docs-viewer-<version>.zip` from an allowlist (`docs-viewer.php`, `readme.txt`, `LICENSE`, `CHANGELOG.md`, `includes/`, `templates/`, `assets/`, `languages/`) and refuses to build when the version header, `DOCS_VIEWER_VERSION` and the readme's `Stable tag` disagree.

**Ändrat**

- Every `phpcs:ignore` comment now states why the sniff does not apply. Two of them named a non-existent sniff (`EscapingOutput` instead of `EscapeOutput`), so Plugin Check reported the scan-form output as unescaped; they now name the real sniff.
- The `dv_plugin` request value passes through `sanitize_text_field()` before the existing slug filter, and the scan count is cast to `int` at output, so Plugin Check sees the input sanitized and the output escaped. Accepted slugs are unchanged.

**Åtgärdat**

- The Raw view's "Copied!" confirmation was hard-coded English in `viewer.js`, so translators could not reach it. The label now comes from the template through `esc_attr_e()`.

### 1.1.0 — 2026-06-30

**Tillagt**

- wordpress.org `readme.txt` files now render formatted instead of as a raw code block. The wp.org heading syntax (`=== Title ===`, `== Section ==`, `= Sub =`) and the `Key: value` metadata block under the title are converted to Markdown and passed through the existing formatter; `.txt` files without that structure keep the code-block fallback.

### 1.0.0 — 2026-06-18

**Tillagt**

- Initial release.
- Scanner that traverses `wp-content/plugins` for `README`, `CHANGELOG` and `docs/` files (including nested `docs/*` folders).
- Dependency-free, escaping-first Markdown formatter (headings, nested lists, tables, fenced & inline code, blockquotes, links, images, emphasis, rules).
- Single GitHub-style viewer page: a file-tree sidebar, a plugin switcher and a scan button in the topbar, and the document with **Preview / Raw** tabs. The sidebar and document each scroll independently (vertical only); the page itself stays put.
- Per-plugin opt-out toggle (in the sidebar) to inject a **“View Docs”** link onto the plugin's row on the Plugins screen — enabled by default.
- Triple-gated file access: extension allow-list, scanned-file membership check, and realpath containment.
- Developer tooling: PHPStan 2.x (level 5, targeting PHP 7.4) via `szepeviktor/phpstan-wordpress`, PHPCompatibility (`testVersion 7.4-`), ESLint flat config, Stylelint (`stylelint-config-standard`), and a dependency-free `php:lint` script. Lint everything with `npm run lint`.
- Plugin switcher in the viewer topbar — a dropdown to jump straight to another plugin's docs.

**Åtgärdat**

- Third-party admin notices (Elementor/Envo promos, update nags) no longer leak into the rendered document. WordPress's `common.js` relocates `.notice` elements after the first `<h1>` in `.wrap` — which was the document heading — so notices are now suppressed on the viewer screen, backed by a `wp-header-end` anchor and a CSS safety net.

## [Egenverk Customer Hub](https://egenverk.se/plugins/customer-hub/changelog)

Inga versioner än.
