# Changelog

All notable changes to this project are documented in this file.

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

## [Unreleased]

## [0.8.31] - 2026-09-04

### Changed

- All sales page screenshots are now higher resolution: the seven admin
  tour screenshots are true 4K, 16:9 (3840x2160, up from the previous
  1442px-wide 16:10.5 captures), and the hero claim-page screenshot is
  now 4K-wide (3840px) at its native full-page height. `.tour-shot`'s
  CSS aspect-ratio changed from 16/10.5 to a real 16/9 to match.

This release ships to the "dev" channel first (tag `v0.8.31-dev`).

## [0.8.30] - 2026-09-04

### Fixed

- The Claim Links, Users, and other wide-table tour screenshots on the
  sales page were captured at only 1000px wide, narrower than those
  panels' own content (their `overflow-x:auto` container was clipping
  columns like the claim link URLs and Users' action buttons) -- not a
  live-site bug, just too narrow a capture. Re-captured all seven tour
  screenshots at 1442px, which fits every panel without its own
  horizontal scroll kicking in.

This release ships to the "dev" channel first (tag `v0.8.30-dev`).

## [0.8.29] - 2026-09-04

### Changed

- Re-captured all seven admin tour screenshots on the sales page
  (Dashboard, Campaigns, Claim Links, Users, Analytics, Design,
  Keys/Codes) -- the previous ones were stale and didn't reflect the
  current sidebar, the added platform regions, or the Keys/Codes page's
  new grouped-by-platform view.

This release ships to the "dev" channel first (tag `v0.8.29-dev`).

## [0.8.28] - 2026-09-04

### Changed

- Sales page: added a flagship feature callout at the top of "Why teams
  switch" for full key-lifecycle management (active/inactive/redeemed),
  one of the product's core hoster-facing features.
- Sales page: the Keys/Codes screenshot's caption bar now shows a lock
  icon and just "Keys/Codes" instead of three window-chrome dots and a
  "Family Bowling · Steam" breadcrumb -- clearer, and doesn't reference
  the demo product/platform by name.
- Sales page: the hero screenshot's fake URL and "the admin side"
  showcase's demo-product text now read "your-product" / "Your Product"
  and "Target Platform" instead of naming the internal demo data
  ("family-bowling" / "Steam").
- Sales page: the footer now centers itself on narrow screens instead of
  each item sitting flush left.
- Re-captured the hero screenshot; the previous capture had the claimed
  key's title wrap onto two lines in a couple of spots even though there
  was plenty of room -- a font-loading race in the capture tooling itself,
  not a real page bug (confirmed the live claim page never wraps there).

This release ships to the "dev" channel first (tag `v0.8.28-dev`).

## [0.8.27] - 2026-09-03

### Fixed

- The claim page's lone, full-width platform tile (e.g. Xbox/Microsoft
  Store when it has no sibling to share a row with) still rendered at
  half width for an *available* platform, because the CSS grid item is
  the `<form>` wrapping the claim button, not the button itself -- the
  `tile-full` class that spans both grid columns was only ever applied
  to the button. The form now carries the class too.

### Changed

- Re-captured the sales page hero screenshot against keylair.com itself
  (the previous capture came from keys.secret-item-games.com, which
  turned out to be running an older, unrelated deployment that hadn't
  picked up the earlier tile-width fixes) -- both the single-platform
  tile and the expanded platform-region grid now render at full width
  in the screenshot too.

This release ships to the "dev" channel first (tag `v0.8.27-dev.1`).

## [0.8.26] - 2026-09-03

### Fixed

- Same root cause as 0.8.24's single-platform-tile fix, one level
  deeper: the two-column region grid inside an expanded platform-family
  group (e.g. Nintendo Switch 2's US/EU/AU/... options) left visible
  empty space beside each option, since the `<form>` wrapping each
  claimable tile is the actual grid item and defaulted to content-based
  sizing instead of filling its column. Fixed with an explicit
  `width:100%` on every direct child of `.grid` and `.tile-group-body`.

This release ships to the "dev" channel first (tag `v0.8.26-dev.1`).

## [0.8.25] - 2026-09-03

### Changed

- The sales page's hero screenshot now shows the entire claim page,
  top to bottom including the footer: the Nintendo Switch 2 platform
  group expanded to show its regional sub-options, a claimed key's
  reveal card, and the full remaining platform picker below it. The
  Family Bowling demo product also got real intro/outro copy instead
  of the placeholder "Intro text" / "Outro text".

This release ships to the "dev" channel first (tag `v0.8.25-dev.1`).

## [0.8.24] - 2026-09-03

### Fixed

- A lone single-platform tile with no siblings (e.g. Xbox/Microsoft
  Store, when nothing else needs the second grid column) rendered
  narrower than every other row on the claim page's platform picker --
  a `<button>` grid item defaults to content-based sizing instead of
  stretching to its track the way a plain block element (a
  platform-family `<details>` group) does. Fixed with an explicit
  `width:100%` on `.tile`.

This release ships to the "dev" channel first (tag `v0.8.24-dev.1`).

## [0.8.23] - 2026-09-03

### Changed

- The sales page's hero screenshot is now taller and shows the full
  platform picker with every platform (PC, Xbox/Microsoft Store,
  Nintendo Switch, Nintendo Switch 2, PlayStation 4, PlayStation 5) as
  "Available", not just PC -- previously most of them were cut off or
  showed "Sold out" since only two platforms had keys.

This release ships to the "dev" channel first (tag `v0.8.23-dev.1`).

## [0.8.22] - 2026-09-03

### Changed

- "Powered by ..." in the footer of every customer-facing and preview
  page (claim.php, product.php, welcome.php, admin preview mockups,
  product_preview.php) now links to https://www.keylair.com, to help
  drive new customers. The text itself is unchanged -- an install with
  its own configured site name still shows that name, only the link
  target is now always keylair.com.

This release ships to the "dev" channel first (tag `v0.8.22-dev.1`).

## [0.8.21] - 2026-09-03

### Added

- keylair.com sales page: "See it live" now logs straight into the demo
  account instead of just scrolling to the screenshot -- a hidden form
  posts to `admin/login.php` with the demo credentials and a valid CSRF
  token generated for the visitor's own session.
- Every screenshot on the sales page now opens a full-resolution
  lightbox on click.
- A new public, unauthenticated download endpoint
  (`public/download.php`) streams the current stable release zip using
  the same read-only Deploy Token the update mechanism already uses --
  "Download Community" no longer sends visitors to the private GitLab
  repository, which most of them can't sign into anyway.
- The Premium waitlist and Enterprise "Contact us" mailto links now
  prefill a short message stating the visitor's interest, not just a
  subject line.

### Changed

- The hero screenshot is now a real, full claim page (cover art,
  screenshot gallery, and the live platform picker with actual
  claimed/sold-out counts) instead of just the game's cover art.
- Removed the three "Family Bowling" screenshots from The Admin Side
  section's gallery -- they didn't belong there.

This release ships to the "dev" channel first (tag `v0.8.21-dev.1`).

## [0.8.20] - 2026-09-03

### Changed

- Renamed the PHP namespace from `KeyVault\` to `KeyLair\` throughout
  the codebase (79 files, including the class autoloader in
  `public/bootstrap.php`), matching the product's rename from Key
  Vault to Key Lair. Purely internal -- no user-facing behavior
  change.

This release ships to the "dev" channel first (tag `v0.8.20-dev.1`).

## [0.8.19] - 2026-09-03

### Changed

- Four terms that were deliberately kept as English loanwords in every
  language ("Analytics", the "Admin" sidebar group, "Claim Links", and
  "Keys/Codes") are now properly translated in German, French, and
  Spanish, e.g. "Claim Links" -> "Einlöselinks" / "Liens de
  réclamation" / "Enlaces de canje". `role.admin` (the Admin role name,
  shown on System Users) is intentionally left alone -- it's a
  different string from the sidebar's "Admin" group label.

This release ships to the "dev" channel first (tag `v0.8.19-dev.1`).

## [0.8.18] - 2026-09-03

### Fixed

- The two customizable-stylesheet labels on the Design page ("Overview
  page" / "Product claim page") were still hardcoded in English --
  they came from a `PageStyle` class constant outside the pages the
  localization pass covered. Now resolved through `I18n::t()` too.

This release ships to the "dev" channel first (tag `v0.8.18-dev.1`).

## [0.8.17] - 2026-09-03

### Fixed

- The Design page's background-image hint showed the literal text
  "&nbsp;" instead of a space, same root cause as 0.8.16 below.
  Switched it to a real `&nbsp;` entity (that key renders raw, so it
  needs the literal entity, not `&amp;nbsp;`).

This release ships to the "dev" channel first (tag `v0.8.17-dev.1`).

## [0.8.16] - 2026-09-03

### Fixed

- Several translated strings that embed inline HTML (bold text, links,
  code snippets on Updates, Migrations, Mail Server, API Access, Design,
  Claim Links) were showing their raw tags as visible text (e.g.
  `<strong>Stable</strong>`) instead of rendering as HTML -- the CSV
  stored them as HTML-entity-encoded markup, which browsers never
  re-parse as real tags. Switched those 17 entries to raw markup.
- Several other strings that render "&" inside an already-escaped
  context (e.g. "Legal &amp; Imprint", "Drag &amp; Drop") were showing
  the literal text "&amp;" instead of "&", from being escaped twice.
  Switched those 16 entries to a plain "&" in the CSV.

This release ships to the "dev" channel first (tag `v0.8.16-dev.1`).

## [0.8.15] - 2026-09-03

### Added

- Multi-language backend UI, phase 2: every remaining admin page is now
  localized (Dashboard, Campaigns, Products, Platforms, Keys/Codes,
  Claim Links, Users, Creator Groups, Redemptions, Analytics, Design,
  Bug Reporting, System Users, Branding, Legal & Imprint, Migrations,
  Updates, Mail Server, API Access, and the sign-in/forgot-password/
  reset-password screens) -- not just the sidebar and Account page from
  the last release. Over 850 new strings, translated into English,
  German, French, and Spanish in `src/lang/strings.csv`, following the
  same per-page key convention throughout (e.g. `campaigns.*`,
  `keys_pool.*`).
- The sign-in and password-recovery pages route their copy through the
  same `I18n::t()` calls, but always render in English for now --
  there's no signed-in admin yet at that point to read a language
  preference from, and no visitor-side locale detection exists yet.
  That's a known gap, not a bug.

This release ships to the "dev" channel first (tag `v0.8.15-dev.1`).

## [0.8.14] - 2026-09-03

### Changed

- The shared minimum width for the Theme and Language buttons is now
  120px (was 100px).

This release ships to the "dev" channel first (tag `v0.8.14-dev.1`).

## [0.8.13] - 2026-09-03

### Changed

- The language picker's translated sub-label now shows for the active
  language too (not just the others), so every button in the row is the
  same two-line height. Both the Theme and Language button rows now
  share a common minimum width, so shorter options like "Hell" or
  "Deutsch" line up with the widest one instead of shrinking to fit
  their own text.

This release ships to the "dev" channel first (tag `v0.8.13-dev.1`).

## [0.8.12] - 2026-09-03

### Changed

- The language picker's translated name now sits on its own line below
  the autonym instead of in parentheses after it, so buttons for longer
  names (e.g. "Español – Latinoamérica") don't stretch out the row.

This release ships to the "dev" channel first (tag `v0.8.12-dev.1`).

## [0.8.11] - 2026-09-03

### Added

- French and Spanish added as backend languages, alongside English and
  German (`src/lang/strings.csv`, `I18n::AVAILABLE`). Draft translations
  covering the sidebar, nav, and Account page -- like the existing
  English/German pair, meant to be reviewed by a localization service
  before relying on them for real users.

### Changed

- The language picker on the Account page now follows Steam's client
  convention: each option shows its own name first, with the translated
  name in parentheses after it (e.g. on a German UI, "Français
  (Französisch)") -- readable to the current viewer, while the native
  name alone still lets someone who can't read the current language spot
  their own. The active language shows without the parenthetical.

This release ships to the "dev" channel first (tag `v0.8.11-dev.1`).

## [0.8.10] - 2026-09-02

### Fixed

- Switching backend language on the Account page now shows its "Language
  updated" confirmation in the newly selected language instead of the
  previous one.

This release ships to the "dev" channel first (tag `v0.8.10-dev.1`).

## [0.8.9] - 2026-09-02

### Changed

- The "Update to X.Y.Z" button on Updates now confirms with an in-page
  dialog instead of the browser's native `confirm()` popup.

This release ships to the "dev" channel first (tag `v0.8.9-dev.1`).

## [0.8.8] - 2026-09-02

### Added

- Multi-language backend UI, phase 1: the sidebar navigation, the "Admin"
  nav group and its labels, role names, and the Account page are now
  translated via a new `I18n` class (`src/I18n.php`). Every admin can pick
  their own backend language from a new "Language" section on the Account
  page (mirrors the existing personal "Backend theme" picker) -- it's a
  per-admin preference stored on `admin_users.language`, same as `theme`,
  and doesn't affect what other admins see.
- Translated strings live in `src/lang/strings.csv` (one `key,en,de` row
  per string) rather than in PHP, so the file can be handed to a
  translation service and edited directly in a spreadsheet. Adding a
  future language is: add a column to the CSV, add one entry to
  `I18n::AVAILABLE`.
- English and German are supported at launch. Other pages keep their
  existing English-only copy for now; this covers only the mechanism plus
  the sidebar and Account page, as agreed.

This release ships to the "dev" channel first (tag `v0.8.8-dev.1`).

## [0.8.7] - 2026-09-02

### Added

- A dedicated sales page for keylair.com (`public/welcome_sales.php`) --
  the official brand mark, real screenshots of the claim page and admin
  panel (Dashboard, Campaigns, Keys/Codes, Claim Links, Users, Analytics,
  Design), and a draft Community/Premium/Enterprise pricing comparison.
  `welcome.php` now branches on the request host: only a request to
  keylair.com gets this, every other install (including Secret Item
  Games' own production instance) keeps the existing generic welcome
  pitch unchanged. Pricing/tier scope shown is an early draft, not final
  -- Premium has no license enforcement yet.

This release ships to the "dev" channel first (tag `v0.8.7-dev.1`).

## [0.8.6] - 2026-09-02

### Added

- A "How to use this" section on API Access, collapsed by default -- a
  curl example, the response shape, error codes, and a pointer to the
  Sendy sync section for anyone trying to use the lookup endpoint
  directly from Sendy (which can't call it).

This release ships to the "dev" channel first (tag `v0.8.6-dev.1`).

## [0.8.5] - 2026-09-02

### Changed

- Batch create claim links now also accepts emails, or "Name,Email" rows
  (the shape a CSV list exported from Sendy, or any other mail tool,
  usually comes in) -- not just plain creator tags. Sendy's API has no
  way to list a list's subscribers itself, so this is the way to turn a
  Sendy export into real Key Lair users with real claim links before
  running the Sendy sync (API Access) to write those links back in --
  otherwise a subscriber who isn't already a Key Lair user comes back
  from that sync with nothing to write.

This release ships to the "dev" channel first (tag `v0.8.5-dev.1`).

## [0.8.4] - 2026-09-02

### Added

- Sendy sync on the API Access page -- since Sendy has no live per-recipient
  lookup at send time (only pre-stored custom field values), this pushes
  every Key Lair user's claim link into Sendy as a subscriber custom field
  via Sendy's own API, to run before a campaign that uses the merge tag.
  Configurable per install: Sendy URL, API key, list id, custom field name,
  and whether to sync the overview link or one specific product's direct
  link.

This release ships to the "dev" channel first (tag `v0.8.4-dev.1`).

## [0.8.3] - 2026-09-02

### Added

- API Access (new page, admin/master_admin only) -- create and revoke
  server-to-server bearer tokens.
- `GET /api/claim_link_lookup.php?email=` -- given an email and a bearer
  token, returns every matching user's claim links (overview + per-product
  URLs). Built for external tools like Sendy to look up the right
  personalized link when composing a campaign email, without a logged-in
  session. Requested via Canny.

### Changed

- Consolidated `Auth::canViewMasterAdminTier()`/`requireMasterAdminTierView()`
  into the existing `canViewAdminTier()`/`requireAdminTierView()` -- they
  were functionally identical (both just "demo can look, admin or above
  can also edit"), so Migrations, Updates, Mail Server, and the new API
  Access page all use the same one now instead of two confusingly-named
  synonyms for the same check.

This release ships to the "dev" channel first (tag `v0.8.3-dev.1`).

## [0.8.2] - 2026-09-02

### Changed

- "Preview claim page" on the product editor now shows a true live view
  of whatever's currently in the form -- name, intro/outro text, custom
  CSS, and page theme -- instead of only ever the product's last-saved
  row. Images and screenshots still reflect the last save, since those
  only ever change via a real upload.

This release ships to the "dev" channel first (tag `v0.8.2-dev.1`).

## [0.8.1] - 2026-09-02

### Added

- Redemptions now has the same per-page pagination and fixed
  jump-to-first/last-row buttons as Keys/Codes, Users and Claim Links.

This release ships to the "dev" channel first (tag `v0.8.1-dev.1`).

## [0.8.0] - 2026-09-02

### Added

- Creator group is now editable inline on the Users page too, as a
  dropdown next to Name/Email -- since a user (not a claim link) is what
  this page treats it as, picking one there applies it to every one of
  that user's claim links at once.
- The Claim links count on the Users page is now a link -- opens Claim
  Links in a new tab, filtered down to just that user's own links.
- A search box on the Users page, filtering the table by name or email.

### Changed

- Redemptions on the Users page now reads "used / available" (summed
  across all of a user's claim links' products) instead of a bare
  redemption count, same shape as the Claimed column elsewhere.
- The Users table's "Save changes" button appears only once now, below
  the table, instead of duplicated above and below it.

This release ships to the "dev" channel first (tag `v0.8.0-dev.1`).

## [0.7.99] - 2026-09-02

### Fixed

- The User picker's search field didn't select its current text on
  focus, so typing a search term appended to "+ Create new user" (or a
  previously picked user's label) instead of replacing it -- the
  resulting query matched nothing and the dropdown silently stayed
  closed. Focusing the field now selects it all, same as clicking into
  any other pre-filled search box.

This release ships to the "dev" channel first (tag `v0.7.99-dev.1`).

## [0.7.98] - 2026-09-02

### Added

- Users and Claim Links now have the same per-page pagination (10/20/50/
  100/200/Show all) and fixed jump-to-first/last-row buttons that
  Keys/Codes already had, for the same reason -- both lists can grow into
  the thousands, and "Show all" alone has no fast way back to either end.

This release ships to the "dev" channel first (tag `v0.7.98-dev.1`).

## [0.7.97] - 2026-09-02

### Changed

- Editing a user's name/email now happens directly in the Users table --
  every row's Creator/Email cells are editable inputs, with a "Save
  changes" button to save every edited row at once (mass edit), plus a
  per-row "Save" for just that one. Replaces the separate Edit page from
  0.7.96, which never shipped to a wider audience.

This release ships to the "dev" channel first (tag `v0.7.97-dev.1`).

## [0.7.96] - 2026-09-02

### Added

- A "+ New user" button on the Users page -- creates a user and a claim
  link for them together in one form, for the common case of starting
  from a new creator rather than an existing link. To add a second link
  to a user that already exists, New Claim Link's own User picker is
  still the way.
- An "Edit" action on the Users page, to change an existing user's name
  or email without touching their claim links.

This release ships to the "dev" channel first (tag `v0.7.96-dev.1`).

## [0.7.95] - 2026-09-02

### Changed

- The User picker on New/Edit Claim Link is now actually searchable --
  a plain `<select>` only jumps to an option by its first letter, which
  doesn't scale once there are more than a handful of users. Replaced
  with a text field that filters the list as you type (by name, email,
  or id), with "+ Create new user" always available at the top.

This release ships to the "dev" channel first (tag `v0.7.95-dev.1`).

## [0.7.94] - 2026-09-02

### Fixed

- Migration `024_finalize_users_integration.sql` failed partway through on
  a live install ("Column 'user_id' cannot be NOT NULL: needed in a
  foreign key constraint ... SET NULL") -- `claim_links.user_id`'s
  foreign key had to be switched from `ON DELETE SET NULL` to `ON DELETE
  RESTRICT` before the column could become `NOT NULL`, not after. The
  backfill steps that had already run are left alone (they're
  idempotent -- rerunning only ever affects rows still missing a
  `user_id`), so this just reorders the rest.

This release ships to the "dev" channel first (tag `v0.7.94-dev.1`).

## [0.7.93] - 2026-09-02

### Added

- A "Delete personal data" action on the new Users page, for GDPR
  erasure requests -- it wipes a user's name and email but keeps the
  row itself, so their claim links, redemptions and stats stay intact
  (`claim_links.user_id` still needs a valid id to point at). Once
  anonymized, the user shows up everywhere as "User #<id>" instead of
  a name.

This release ships to the "dev" channel first (tag `v0.7.93-dev.1`).

## [0.7.92] - 2026-09-02

### Changed

- Full integration of the `users` table introduced in 0.7.91: every page
  that used to read or write a claim link's free-text `creator_tag`
  (New/Edit Claim Link, Batch Create, Claim Links, its CSV export,
  Redemptions, Analytics' Top creators, the Users directory, the
  Dashboard, and Keys' Claimed-by quick-fill) now works against a real
  `users.id` instead. New Claim Link and Batch Create either pick an
  existing user from a dropdown or create a new one (name and email both
  still optional, and deliberately never deduplicated -- two people can
  share a display name). A user with no name shows as "User #<id>"
  wherever a name would otherwise be missing.
- `claim_links.user_id` is now required and `creator_tag` has been
  dropped -- existing links are backfilled into `users` automatically
  by this release's migration, each keeping its original creator tag as
  that new user's name.

This release ships to the "dev" channel first (tag `v0.7.92-dev.1`).

## [0.7.91] - 2026-09-02

### Added

- A `users` table -- a real claimant/creator entity with its own id,
  optional name, and optional email, so two people sharing the same
  display name are still distinguishable by id. Purely additive for
  now: claim_links gained a nullable `user_id` alongside its existing
  free-text creator_tag, not a replacement for it -- no existing data
  changes, and nothing yet requires picking a real user instead of
  typing a tag. Wiring the actual admin forms/pages to create and pick
  real users is a separate follow-up.

This release ships to the "dev" channel first (tag `v0.7.91-dev.1`).

## [0.7.90] - 2026-09-02

### Added

- A new Users page (next to Claim Links in the nav) -- one row per
  distinct creator tag used across every Claim Link, install-wide,
  with their Creator Group (if any), how many claim links they have,
  how many redemptions, and when their most recent link was created.
  Read-only: a creator has no id of its own, so "editing" one still
  only ever happens through the claim links that use their tag.

### Changed

- The admin-tier "Users" page (managing staff logins: admin/content
  manager/reviewer/demo accounts) was easy to confuse with the new
  Users page above -- renamed to "System Users" throughout, including
  the file itself (`admins.php` → `system_users.php`) so the URL
  matches the page name.

This release ships to the "dev" channel first (tag `v0.7.90-dev.1`).

## [0.7.89] - 2026-09-02

### Added

- The key pool list (over 20 rows) now has fixed "jump to first row" /
  "jump to last row" buttons in the bottom-right corner -- especially
  useful with "Show all" selected, where this product/platform's
  entire key list (potentially thousands of rows) renders at once
  with no other fast way back to either end once scrolled into it.

This release ships to the "dev" channel first (tag `v0.7.89-dev.1`).

## [0.7.88] - 2026-09-02

### Changed

- The previous release's Creator Group picker for Claimed-by was the
  wrong concept -- "assign to a user" meant picking from the existing
  creator tags already used on Claim Links, not a Creator Group.
  Replaced with a dropdown of those (a quick-fill convenience into the
  same free-text note, since a creator tag has no id of its own to
  really link to) and the now-unused claimed_by_creator_group_id
  column/migration removed again.

This release ships to the "dev" channel first (tag `v0.7.88-dev.1`).

## [0.7.87] - 2026-09-02

### Added

- Claimed-by can now optionally link to a real Creator Group (picked
  from a dropdown of existing ones) instead of only ever being free
  text -- rendered as a real link, same as a key claimed through the
  actual system already is.
- Locking a Claimed-by note or Creator Group now also marks the key
  Redeemed (unlocking/clearing reverses that, back to Available) --
  treated exactly the same as a real system claim, since as far as
  the key is concerned, someone has it either way.
- A key claimed through the real system can now be released too, not
  just a manual note/group: "Unlock" on it deletes the redemption
  record, gives the creator's claim link that claim back against its
  limit, and makes the key available (and selectable) again. Confirms
  first, since it can't be undone.

This release ships to the "dev" channel first (tag `v0.7.87-dev.1`).

## [0.7.86] - 2026-09-02

### Fixed

- "Apply to selected" reloads the page (a real save), which
  re-rendered every checkbox unchecked -- so a selection was lost the
  moment you applied anything to it, useless for applying a second
  bulk-set to the same keys right after. The selection now survives
  the reload.

This release ships to the "dev" channel first (tag `v0.7.86-dev.1`).

## [0.7.85] - 2026-09-02

### Fixed

- Locking a Claimed-by note showed a flash message (a real save,
  reloading the page); Unlock showed nothing at all, since it's purely
  client-side with no reload. Unlock now shows its own instant flash
  too, no reload needed for it either.

This release ships to the "dev" channel first (tag `v0.7.85-dev.1`).

## [0.7.84] - 2026-09-02

### Fixed

- A locked Claimed-by note's "Unlock" button sat right after the note
  text instead of staying at the same right-hand position the Lock
  button holds when unlocked -- its horizontal spot shifted depending
  on how long the note was. Now pinned to the right edge either way.

This release ships to the "dev" channel first (tag `v0.7.84-dev.1`).

## [0.7.83] - 2026-09-02

### Changed

- The Claimed-by flash message now says which actually happened --
  `Locked as "name"` or `Unlocked -- note cleared` -- instead of a
  generic "Claimed-by note saved"/"cleared" that didn't name the note
  itself.

This release ships to the "dev" channel first (tag `v0.7.83-dev.1`).

## [0.7.82] - 2026-09-02

### Changed

- A locked Claimed-by note now reads as plain text, same as every
  other cell in the table, with just a small "Unlock" action next to
  it -- it no longer looks like a (disabled) input box while locked.
  Unlock swaps it back to the editable input+Lock design.

This release ships to the "dev" channel first (tag `v0.7.82-dev.1`).

## [0.7.81] - 2026-09-02

### Changed

- "Export selected" moved from its own row above the table up to the
  topbar, next to "Export keys" and "+ Import keys".

This release ships to the "dev" channel first (tag `v0.7.81-dev.1`).

## [0.7.80] - 2026-09-02

### Changed

- The key pool list's Imported date filter is now one bordered field
  (from/to side by side with a dash between, no separate Clear button)
  instead of its own row of separate pieces -- folded into the main
  filter row alongside Campaign/Status/Claimed by.
- "N shown" moved next to "N selected" (both by the Export selected
  button) instead of sitting at the far end of the filter row above.

This release ships to the "dev" channel first (tag `v0.7.80-dev.1`).

## [0.7.79] - 2026-09-02

### Fixed

- The Claimed-by field's button still read "Save" in its editable
  state -- only "Unlock" (its locked-state label) was ever added.
  Renamed to "Lock", since it both saves the note and locks the field
  in one action.

This release ships to the "dev" channel first (tag `v0.7.79-dev.1`).

## [0.7.78] - 2026-09-02

### Added

- The key pool list's Release selected/Hold back selected buttons are
  now one bulk-set row: Campaign/Release/Status selects plus one
  "Apply to selected" button, so a selection's campaign or status can
  also be set in bulk, not just its release state. Setting Status
  manually confirms first, since it overrides the real claim/export
  history for those keys.
- Every column header in the key pool list is now sortable (click to
  sort ascending, click again for descending), including Claimed by
  and Status (which sorts on the real status, not the "Available ·
  Released" text it renders as).
- An Imported date range filter (from/to) for the key pool list.
- A saved Claimed-by note now locks the field read-only with an
  "Unlock" button in place of "Save", so it can't be edited by
  accident -- Unlock makes it editable again (and turns back into
  "Save") without a page reload.

### Fixed

- Analytics' Total keys/Redeemed/Redemption rate cards ignored the
  Campaign/Product filters above them (previously deliberate, but read
  as broken -- picking a product and seeing an unfiltered "Total
  keys" looks like the filter silently didn't apply). They now scope
  to the same filters as everything else on the page.

This release ships to the "dev" channel first (tag `v0.7.78-dev.1`).

## [0.7.77] - 2026-09-02

### Fixed

- The welcome page's default hero image still pointed at
  `/assets/vault-hero.png` after that file was renamed to
  `hero-image.png` -- 404'd for any install that never uploaded its
  own custom hero image.

This release ships to the "dev" channel first (tag `v0.7.77-dev.1`).

## [0.7.76] - 2026-09-02

### Added

- Batch create claim links -- a new page (Claim Links → "Batch create")
  for creating many links at once, sharing one Campaign/Creator group/
  Expires/Products setup across every one, with a per-line creator tag
  either pasted or uploaded as a .csv/.txt file (same paste-or-upload
  pattern as Import Keys).
- Claim Links can now be exported as CSV: "Export CSV" in the topbar
  for every link, or "CSV" on a single row for just that one --
  Creator, Campaign, Status, Created/Expires, and every product's
  claim URL with its used/limit count.

This release ships to the "dev" channel first (tag `v0.7.76-dev.1`).

## [0.7.75] - 2026-09-02

### Fixed

- Design page's "Download default template"/"Download active" links
  (previous release) grew to fill the entire row below them, reaching
  under the Upload button's own column instead of stopping at the end
  of Preview's. An invisible spacer now reserves Upload's width so the
  two links only ever split Choose-file+Preview's span.
- The full-width fix for Edit/New product, Edit/New claim link, and
  Settings/Account (previous release) widened the actual fields/text
  inside each card along with the card itself -- only the dark
  background box was meant to reach full width. Fields/text are capped
  back to a normal reading width; only the box behind them is wider.
- New/Edit claim link's "Cancel" link had its text sitting off-center
  in a wider-than-its-text box on desktop -- an `<a class="btn">` is
  inline by default with no such centering, unlike a real `<button>`.
  Every `.btn` (link or button) is now a centered flex box instead, the
  same fix already applied to specific buttons elsewhere this session,
  just moved to the shared class so it can't recur one-off like this.

This release ships to the "dev" channel first (tag `v0.7.75-dev.1`).

## [0.7.74] - 2026-09-02

### Changed

- The key pool list's Claimed by column moved to the very end (after
  Imported), instead of sitting before it.

This release ships to the "dev" channel first (tag `v0.7.74-dev.1`).

## [0.7.73] - 2026-09-02

### Changed

- Edit/New product, Edit/New claim link, and Settings/Account all
  capped at 820px and stacked in a single narrow column, leaving a
  large unused strip down the right side of any wider screen -- now
  full width like every other admin page, same as their own
  .field-row's already did at whatever width the page happened to be.
- Settings/Account specifically also flowed its cards (Profile,
  Backend theme, Change password, Two-factor, Delete account) into
  several side by side on a wide screen instead of reading top to
  bottom as one continuous settings form -- back to a single column,
  just a full-width one now instead of the old 820px-capped one.

This release ships to the "dev" channel first (tag `v0.7.73-dev.1`).

## [0.7.72] - 2026-09-02

### Fixed

- Design page's "Download default template"/"Download active" links
  (each page's card) sized to their own text, both together falling
  well short of the Choose-file/Preview/Upload row's full width right
  below them, and unevenly wide next to each other. Desktop only --
  they already split 50/50 full-width on mobile.

This release ships to the "dev" channel first (tag `v0.7.72-dev.1`).

## [0.7.71] - 2026-09-02

### Added

- The key pool list now shows who claimed each key: the real claimant
  (via its redemption's claim link) linked to their Creator Group when
  it belongs to one, or an admin-editable free-text note for a key
  that left circulation outside the system entirely (e.g. handed out
  manually). A matching "Claimed by" search filters the list.

### Changed

- "Pool" was a confusing, undefined term for what a key's Active/
  Inactive flag actually controls. Renamed throughout to Release/
  Released/Held back (key pool list, CSV export, Import Keys, and the
  Keys/Codes dashboard's counts) -- e.g. "Released" instead of
  "Active", "Held back" instead of "Inactive". The underlying data is
  unchanged, only the label.
- The key pool list's Status and (renamed) Release columns/filters are
  now one combined column/filter ("Available · Released") instead of
  two, and its per-row padding is tighter on mobile -- both changes
  meant to cut down how much scrolling a long key list takes.

### Fixed

- A demo (or reviewer) account got "Your account has read-only
  access" just for opening a product/platform's key pool list. It can
  now browse it read-only like every other admin page -- key values
  are masked the same way Redemptions already masks them for these
  roles, and every actual mutation (release/hold back, export,
  editing a claimed-by note) still requires real edit access.

This release ships to the "dev" channel first (tag `v0.7.71-dev.1`).

## [0.7.70] - 2026-09-02

### Fixed

- The key pool list's "N shown"/"N selected" count, now a 4th
  equal-width column on desktop, left the 3 filters/buttons too
  cramped to read on mobile (truncating to "All cam...", "Move
  sel..."). The count now drops to its own full-width row on mobile
  instead, still right-aligned there.

This release ships to the "dev" channel first (tag `v0.7.70-dev.1`).

## [0.7.69] - 2026-09-02

### Added

- The key pool list now paginates -- a page-size selector (10/20/50/
  100/200/Show all, centered under the table) plus Prev/Next controls,
  instead of always rendering every key at once.

### Fixed

- The new "N shown"/"N selected" count labels sat outside the filter
  row's and button row's equal-width column layout (auto-width,
  pushed right by margin), so adding them had thrown the columns
  between the two rows out of alignment again. They're now full
  members of the same flex:1 1 0 grid as the selects/buttons, right-
  aligned within their own column instead of via margin -- all 4 cells
  in both rows line up again.

This release ships to the "dev" channel first (tag `v0.7.69-dev.1`).

## [0.7.68] - 2026-09-02

### Fixed

- The key pool list's header "select all" checkbox selected every key
  on the page, including ones hidden by the active Campaign/Status/Pool
  filters -- visible now that the new "N selected" count could show
  100 selected while only 50 were shown. It now only selects the rows
  the current filters actually leave visible.

This release ships to the "dev" channel first (tag `v0.7.68-dev.1`).

## [0.7.67] - 2026-09-02

### Added

- The key pool list now shows a right-aligned "N shown" count next to
  its filters (updating live as they're applied) and a right-aligned
  "N selected" count next to the Move/Export selected buttons.

This release ships to the "dev" channel first (tag `v0.7.67-dev.1`).

## [0.7.66] - 2026-09-02

### Fixed

- The key pool list's filter row and its Move/Export selected buttons
  each sized themselves to their own text, leaving both rows visibly
  uneven -- now all six controls share one flex:1 1 0 column layout
  and the same explicit min-height, so both rows line up as matching
  columns.

This release ships to the "dev" channel first (tag `v0.7.66-dev.1`).

## [0.7.65] - 2026-09-02

### Fixed

- The key pool list's new Campaign/Status/Pool filter row rendered as
  unstyled native browser dropdowns instead of the site's dark theme
  -- it was missing the same background/border/color styling every
  other filter row (Redemptions, Analytics) already carries inline.
  Also moved the filter row above the Move/Export selected buttons,
  since you filter before acting on a selection, not after.

This release ships to the "dev" channel first (tag `v0.7.65-dev.1`).

## [0.7.64] - 2026-09-02

### Added

- A product/platform's key pool list now has a Campaign column and
  Campaign/Status/Pool filters above the table, so you can narrow down
  a large key list instead of scrolling through everything.

This release ships to the "dev" channel first (tag `v0.7.64-dev.1`).

## [0.7.63] - 2026-09-02

### Changed

- A product/platform's key pool page no longer duplicates a full
  Campaign/Status/Limit export toolbar (and its own "Back to
  Keys/Codes" link) inline -- now that Export and Import each have
  their own dedicated pages, it just links out to those, the same way
  the main Keys/Codes page does, pre-scoped to this product and
  platform so the destination page opens already filtered.

This release ships to the "dev" channel first (tag `v0.7.63-dev.1`).

## [0.7.62] - 2026-09-02

### Changed

- Keys/Codes' cramped inline Export filter row (four tiny selects
  squeezed into the toolbar) is now a dedicated Export page, mirroring
  Import Keys' own layout -- Product/Platform/Campaign/Status as
  labeled fields instead of tiny inline selects, with a matching
  "Export keys" topbar button next to "+ Import keys".

This release ships to the "dev" channel first (tag `v0.7.62-dev.1`).

## [0.7.61] - 2026-09-02

### Fixed

- The key pool export's Campaign filter moved to the first row, above
  Status/limit, instead of sitting between the filter row and the
  action row.
- .campaign-select's "Export (CSV)" button rendered shorter than its
  "Back to Keys/Codes" link neighbor despite identical padding -- a
  <button>'s UA default line-height doesn't automatically match
  surrounding text the way it does for other elements, so the two
  computed different total heights from the same padding. An explicit
  line-height removes that per-tag difference everywhere this class
  is used.

This release ships to the "dev" channel first (tag `v0.7.61-dev.1`).

## [0.7.60] - 2026-09-02

### Fixed

- A product's status pill and "Preview claim page" link sat grouped
  together on the right on mobile -- now split to the two edges of the
  row (status left, Preview right), same as they do beside the title on
  desktop.

This release ships to the "dev" channel first (tag `v0.7.60-dev.1`).

## [0.7.59] - 2026-09-02

### Added

- Keys/Codes gained its own Export panel with Product, Platform,
  Campaign, and Status filters (any combination, all independent) --
  previously its top-level export could only ever grab unused-or-all
  keys across the whole install, with no way to narrow it down short of
  opening a specific product+platform's own key pool. That per-product
  key pool's own export gained a Campaign filter too, alongside its
  existing Status and limit.

### Removed

- "New product"'s "Preview claim page" link -- the generic mockup it
  pointed at was never actually related to whatever's typed into the
  form above it, which read as broken rather than just generic. An
  existing product's own Preview (real, built from its saved data) is
  unaffected.

This release ships to the "dev" channel first (tag `v0.7.59-dev.1`).

## [0.7.58] - 2026-09-02

### Fixed

- The Updates page's stacked mobile version arrow centered itself under
  nothing in particular, since Current/Available's own text stayed
  left-aligned -- centering both blocks lines everything up under the
  same midpoint. The "Update to X" button now fills its row on mobile
  too, same as every other lone action button on the site.

This release ships to the "dev" channel first (tag `v0.7.58-dev.1`).

## [0.7.57] - 2026-09-02

### Added

- A "Clear" button next to the Expires date field -- mobile browsers
  don't show a native clear control on input[type=date] the way desktop
  ones do, so once a claim link had an expiration date set, there was
  no way back to "no expiration" from a phone.

This release ships to the "dev" channel first (tag `v0.7.57-dev.1`).

## [0.7.56] - 2026-09-02

### Fixed

- The Expires date field's height still changed based on its own value
  after the previous line-height fix -- empty was still shorter than
  filled, confirmed live. An explicit height (with !important, since a
  plain height on an input fights its UA styles otherwise) pins it to
  the same number regardless of what's inside.

This release ships to the "dev" channel first (tag `v0.7.56-dev.1`).

## [0.7.55] - 2026-09-02

### Fixed

- The Expires date field was shorter empty than it was with an actual
  date in it -- appearance:none (the previous fix for its width) left
  its line box sizing itself around whatever native placeholder chrome
  is or isn't currently drawn. A fixed line-height keeps its height
  constant either way, matching every other field on the page.

This release ships to the "dev" channel first (tag `v0.7.55-dev.1`).

## [0.7.54] - 2026-09-02

### Fixed

- "New product"'s "Preview claim page" link sat up by the title, before
  any of the form below it was even filled in -- reading as if it
  should reflect those fields when the generic mockup it points at
  never could. Moved down next to Save product, after there's actually
  something entered. An existing product's own Preview (built from its
  real saved data) stays up top, unaffected.

This release ships to the "dev" channel first (tag `v0.7.54-dev.1`).

## [0.7.53] - 2026-09-02

### Fixed

- Keys/Codes' filter+Export pair sat centered at its own content width
  on mobile instead of filling its row -- the `<fieldset>` wrapping it
  (there to gray it out for demo) has its own quirky default sizing that
  align-items:stretch didn't reliably override.
- A product's key pool toolbar: swapped "Back to Keys/Codes" and
  "Export (CSV)" to sit left/right respectively in their row.

This release ships to the "dev" channel first (tag `v0.7.53-dev.1`).

## [0.7.52] - 2026-09-02

### Fixed

- A claim link's "Expires" date field: another attempt at matching every
  other field's box on browsers where its native day/month/year layout
  ignores width/max-width -- appearance:none this time, asking the
  browser to stop drawing its own native chrome sizing for the control
  rather than trying to constrain that chrome from the outside. Stays a
  real input[type=date] with the native picker. Server-side validation
  added alongside it regardless, since nothing about that guarantees a
  well-formed date the way the browser's own picker did before.

This release ships to the "dev" channel first (tag `v0.7.52-dev.1`).

## [0.7.51] - 2026-09-02

### Fixed

- Design page's Preview/Upload (and Upload/Remove) rows genuinely
  weren't splitting 50/50 -- traced to display:contents on the <form>
  (and then a fieldset) wrapping them not reliably passing flex-grow
  through to the <a> inside. Restructured so the fieldset is the flex
  container directly and the actual upload <form> renders invisibly
  alongside it (same technique as the Users page's Manage panel), with
  no display:contents involved at all. Also fixes the vanished row gap
  between the Choose-file line and the Preview/Upload line underneath it.
- A stylesheet card's title ("Overview page (list of products for a
  claim link)") wrapped wherever it ran out of room mid-sentence -- the
  parenthetical detail now always starts on its own line.
- The Updates page's Current/arrow/Available block had ~48px of empty
  space stranded around the arrow once it stacked on mobile (the row's
  own 24px gap, sized for them sitting side by side, becoming a 24px gap
  on both sides of the tiny arrow instead).

This release ships to the "dev" channel first (tag `v0.7.51-dev.1`).

## [0.7.50] - 2026-09-02

### Fixed

- A claim link's "Expires" date field's previous fix (an overflow:hidden
  wrapper) stopped it from breaking the page's layout, but clipped part
  of the visible date instead of actually shrinking it on browsers
  where its native rendering ignores width/max-width. Smaller font size
  on the input itself instead, so there's an actual chance of it fitting
  rather than just hiding whatever didn't.

This release ships to the "dev" channel first (tag `v0.7.50-dev.1`).

## [0.7.49] - 2026-09-02

### Fixed

- A product's key pool toolbar packed its four controls onto mobile rows
  by whatever fit (three then one stranded alone) instead of two clean
  rows of two -- now explicitly grouped into a filter row and an actions
  row, each split 50/50.
- Keys/Codes' own toolbar's lone "+ Import keys" link sat at its own
  content width on the row below its filter+Export pair instead of
  filling it, same as any other single mobile control.

This release ships to the "dev" channel first (tag `v0.7.49-dev.1`).

## [0.7.48] - 2026-09-02

### Added

- "New product" now has a "Preview claim page" link too, same spot as
  "Edit product" -- pointed at the same generic mockup the Design page's
  own Preview links use, since there's no real product saved yet to
  build an actual preview from.

This release ships to the "dev" channel first (tag `v0.7.48-dev.1`).

## [0.7.47] - 2026-09-02

### Fixed

- A product's status pill + "Preview claim page" link sat left-aligned
  on mobile once it wrapped onto its own line under the page title,
  instead of staying flush right the way it does beside the title on
  desktop.

This release ships to the "dev" channel first (tag `v0.7.47-dev.1`).

## [0.7.46] - 2026-09-02

### Fixed

- Design page's Preview/Upload (and Upload/Remove) mobile rows still
  didn't split 50/50 -- flex-basis:100% on each, the previous attempt at
  forcing each onto its own full-width line, turned out not to actually
  reserve a whole line in a wrapping flex container; both switched to
  the same 0-basis 50/50 approach already proven elsewhere on this page.
- A claim link's "Expires" date field could still render wider than the
  fields above it on iOS Safari specifically, whose native date input
  layout isn't always bound by width/max-width -- now sits in an
  overflow:hidden wrapper that clips it back to size regardless.
- A product's key pool and Keys/Codes' own toolbar (the status filter,
  export limit, Export/Import/Back links) didn't wrap on a narrow screen
  and instead squeezed together, overflowing past the screen edge and
  wrapping text awkwardly inside individual buttons instead of moving
  them to a new line.

This release ships to the "dev" channel first (tag `v0.7.46-dev.1`).

## [0.7.45] - 2026-09-02

### Added

- Analytics gained a campaign/product filter and a selectable time range
  (7/14/30/90/180/365 days, still defaulting to 30) for its "Redemptions"
  trend chart -- the breakdowns beside it (by platform, top products, top
  creators, by campaign) respect the campaign/product filter too, staying
  all-time within it.

### Fixed

- Every two-button row (Cancel/Save, Back/Import, ...) split unevenly on
  mobile instead of sharing the row 50/50 -- generalized alongside the
  existing "lone button fills the row" rule instead of a separate case.
- Design page: the per-theme row, the background-image upload row, and
  each stylesheet card's Choose-file/Preview/Upload row each left a
  button (or two) sized to its own text on mobile instead of filling
  its row; "Download default template"/"Download active" and the
  per-theme buttons weren't the same width as each other on desktop;
  Background image had no Preview button, and Preview sat to the left of
  Choose-file everywhere instead of after it, both now consistent with
  every other action row.
- A page-level filter row (Redemptions, Analytics) split its selects
  unevenly on mobile and sized them to their own option text on desktop,
  ending up different widths next to each other.
- A .form-actions button's width still varied card to card, page to page,
  on desktop (e.g. Settings' "Save profile"/"Update password"/
  "Disable 2FA") -- now share a common floor width.
- A .field-row's two inputs weren't always exactly 50/50 -- a long enough
  value in one could still grow its column past its even share.
- An `input[type="date"]` could render wider than every other field in
  its section regardless of width:100%, thanks to its native calendar UI
  having its own intrinsic minimum width in some browsers.
- Settings and Mail Server didn't use the page's full width (Settings:
  narrow single column with a large empty strip; Mail Server: reverted
  back from a side-by-side grid to full-width stacked cards, matching
  Bug Reporting's own correction -- its fields are compact settings, not
  Branding/Legal's card-shaped ones). Import Keys had the same narrow-
  column issue.
- Redemptions' "Date" column (happening to be the table's last one) was
  laid out like an Edit/Delete actions cell on mobile instead of a plain
  value -- label to the right of the value instead of above it.
- Import Keys' and a product's background-image override's "Choose file"
  control, and a product's per-platform claim-limit number field, sized
  to their own content on mobile instead of filling their row.
- The Platforms page's regex reference examples and Mail Server's Postfix
  install snippet needed horizontal scrolling to read in full; both wrap
  instead now.
- Keys/Codes' and a product's key pool's toolbar controls (filter select,
  Export/Import/Back links) were different heights, and their flash/error
  messages had uneven padding and margins; unified via a new shared
  `.flash-panel` class other pages can reuse.
- The Analytics trend chart's date labels rendered as SVG text stretched
  along with the chart's own non-uniform scaling, visibly warping the
  glyphs on any screen wider than the chart's own aspect ratio -- they
  now render as normal HTML text below the chart instead, immune to it.

This release ships to the "dev" channel first (tag `v0.7.45-dev.1`).

## [0.7.44] - 2026-09-02

### Fixed

- Bug Reporting's three cards were laid out side by side like Branding/
  Legal & Imprint/Mail Server -- unlike those, its "Description" textarea
  and screenshot dropzone read better full-width and top to bottom, one
  step after another, not squeezed into a column. Back to stacked, but
  still full width instead of the old single narrow 820px column.

This release ships to the "dev" channel first (tag `v0.7.44-dev.1`).

## [0.7.43] - 2026-09-02

### Added

- The Users page's "Manage" panel now has an Account status field
  (Enabled/Disabled) and, for an account with two-factor authentication
  already set up, a 2FA field (Enabled/Disabled) -- switching it to
  Disabled suspends the account's 2FA without clearing what it had
  configured, so switching it back to Enabled restores the same method
  without the account re-enrolling. "Reset Password" now emails the
  account a reset link the same way the login page's own "forgot
  password" does, instead of typing a new password directly -- except
  for a demo account, whose placeholder email address receives nothing,
  where it still generates a password directly and shows it once.
- Redemptions now shows which key was redeemed (masked for reviewer and
  demo, like every other sensitive value), how many of a creator's
  allowed redemptions for that product have been used so far, and a
  "Redemptions by platform" breakdown for the current filters.
- Analytics gained a "Redemptions by campaign" breakdown and a 30-day
  "Redemptions, last 30 days" line chart.

### Fixed

- The Users page's "Manage" panel's Delete/Reset Password/Save buttons
  didn't look like buttons on mobile, weren't a consistent size, and
  fields and buttons were interleaved one form at a time. Now every
  field is grouped together above every button, buttons are a consistent
  size with centered text, and Delete/Reset Password/Save keep that
  fixed left/center/right order on desktop and split into two even rows
  (Delete + Reset, then Save alone) on mobile.
- Extended that same "the only button in this row shouldn't sit stranded
  with empty space next to it on mobile" fix to every single-button
  action row site-wide, not just one page's Save button.
- Two sections on a settings page whose last field before its Save
  button was a side-by-side pair (e.g. "New/confirm password") sat
  closer to that button than a section whose last field was a single
  row -- both now leave the same gap.
- Branding, Legal & Imprint, Mail Server, and Bug Reporting stacked their
  cards in a single 820px-wide column regardless of screen width,
  leaving a large empty strip on anything wider. They now lay their
  cards out side by side, using the full width.
- The Platforms page's "All platforms" table forced horizontal
  scrolling even with visible empty space to its right -- it was
  narrowed to make room for a "New platform" panel beside it, the same
  layout Campaigns/Creator Groups/Users use, but its table (unlike
  theirs) genuinely needs the full row. Both panels now stack instead.
- The "Unused only" export filters on Keys/Codes and a product's key
  pool rendered as unstyled, light-colored native form controls against
  the dark theme.

This release ships to the "dev" channel first (tag `v0.7.43-dev.1`).

## [0.7.42] - 2026-09-02

### Fixed

- The Users page's "Manage" panel had settled into a real in-flow table
  row on both mobile and desktop, but its five actions (edit
  username/email, enable/disable, reset password, change role, delete)
  were still each a self-contained little form with its own input and
  submit button stacked one after another -- fields and buttons
  interleaved instead of grouped, and every button's position moved
  around as the panel's height changed. Rebuilt so every field lives
  together in one group and every button lives together below it in a
  second group, each button still tied to its own form via HTML5's
  `form="..."` attribute (nothing about which fields submit where
  actually changed) -- same grouped layout on mobile and desktop.

This release ships to the "dev" channel first (tag `v0.7.42-dev.1`).

## [0.7.41] - 2026-09-02

### Fixed

- The Users page's "Manage" popup used a fixed 220px width positioned
  absolutely from the row that opened it -- sized for a wide desktop
  row with room to spare, but on a narrow card-stacked mobile layout
  it floated free of the card entirely, unevenly spaced and
  overlapping whatever content sat underneath instead of looking
  like it belonged to the row that opened it. Now drops into normal
  document flow at full card width on mobile, pushing the rest of
  the card down to make room instead -- equal spacing on both sides,
  and nothing to overflow since it's no longer positioned relative
  to anything.

This release ships to the "dev" channel first (tag `v0.7.41-dev.1`).

## [0.7.40] - 2026-09-02

### Fixed

- The Users page's "Manage" menu didn't show at all for demo on any
  row -- its visibility check used `$manageableRoles`, which is
  empty for demo (it can manage no one), instead of also admitting
  demo the way every other control on this page already does. Its
  role-change dropdown also only ever listed `$manageableRoles`'
  (empty, for demo) options instead of the full role list.

This release ships to the "dev" channel first (tag `v0.7.40-dev.1`).

## [0.7.39] - 2026-09-02

### Fixed

- Found testing as demo: every "visible but disabled" control from
  this session's earlier pass (branding, legal, mail server, users,
  campaigns, claim links, creator groups, design, keys, platforms,
  products) was functionally disabled but looked completely normal --
  there was no `:disabled` styling anywhere. A button/input/select
  inside a disabled `<fieldset>` can't be focused or clicked (that
  part already worked), but its own `disabled` property stays false
  since the fieldset carries the disabled state, not the descendant --
  so `[disabled]`-style selectors silently matched nothing. Added
  `:disabled` rules for buttons (including the several inline-styled
  ones with no `.btn` class), inputs, selects, and textareas.

This release ships to the "dev" channel first (tag `v0.7.39-dev.1`).

## [0.7.38] - 2026-09-02

### Fixed

- Three bugs found testing 0.7.29's key-export changes with real
  imported keys:
  - The confirmation banner showed the raw URL-encoded summary text
    instead of the decoded message.
  - A product+platform's key list still showed an exported key as
    "Available" instead of "Exported".
  - The Dashboard's "Remaining" stat and redemption rate still
    counted an exported key as if it were still available (it was
    computing "total minus redeemed", which doesn't account for the
    new exported status) -- now counts `status='available'` directly.

This release ships to the "dev" channel first (tag `v0.7.38-dev.1`).

## [0.7.37] - 2026-09-01

### Added

- The Users page's Manage menu can now edit an existing account's
  username and email too, gated by the same rule as every other
  action there (a master admin can edit anyone, an admin only
  Content manager/Reviewer/Demo accounts). Between this, role
  changes (0.7.36), password reset, active/inactive, 2FA disable, and
  delete, every admin-manageable field on the account is now covered
  -- the only thing left out on purpose is the personal backend theme,
  which only makes sense as something an account picks for itself.

This release ships to the "dev" channel first (tag `v0.7.37-dev.1`).

## [0.7.36] - 2026-09-01

### Added

- The Users page's Manage menu can now change an existing account's
  role, not just at creation -- gated by the same rule as every other
  action there: a master admin can set anyone to any role, an admin
  can only move an account between Content manager/Reviewer/Demo
  (never promote to Admin/Master admin, and never touch an existing
  Admin or Master admin account).

This release ships to the "dev" channel first (tag `v0.7.36-dev.1`).

## [0.7.35] - 2026-09-01

### Fixed

- Platforms' mobile "Upload icon" sat slightly short of the row's true
  right edge -- its own spacing margin (meant for desktop's normal
  left-to-right flow) was pushing it in, instead of ending flush with
  "Save" in the row above it.

This release ships to the "dev" channel first (tag `v0.7.35-dev.1`).

## [0.7.34] - 2026-09-01

### Fixed

- Platforms' mobile layout regressed when this session's earlier
  "visible but disabled for demo" pass wrapped several actions in a
  single `<fieldset>`: the key-format Save button no longer sat flush
  right (the field's `flex:1` had nothing to grow into once its
  parent stopped filling the row), and Upload icon/Delete lost their
  established mobile left-right reversal (`table tr td:last-child`'s
  row-reverse only had one flex item -- the fieldset -- left to
  reverse). The fieldset now stretches to fill the row for the first
  case, and uses `display:contents` for the second so its children
  count as independent flex items again, restoring both without
  losing the disabled-for-demo behavior either fix depends on.

This release ships to the "dev" channel first (tag `v0.7.34-dev.1`).

## [0.7.33] - 2026-09-01

### Changed

- Reverted the "delete a just-submitted report" feature added in
  0.7.32 -- a delete capability baked into the app is a bigger risk
  than it's worth, since the Canny API key it would use is shared
  across every installation: a single compromised install could have
  used it to wipe posts off the public board for everyone, not just
  itself. Deleting a report now stays a manual step on Canny's own
  dashboard, same as before 0.7.32.

This release ships to the "dev" channel first (tag `v0.7.33-dev.1`).

## [0.7.31] - 2026-09-01

### Changed

- Bug Reporting's "View bug board" link now points at the Canny
  board's new `key-lair` URL, matching the board's own rename.

This release ships to the "dev" channel first (tag `v0.7.31-dev.1`).

## [0.7.30] - 2026-09-01

### Fixed

- The Keys/Codes page's first product header sat almost flush against
  the table's own column headers (2px of top padding) -- bumped to
  match the breathing room every other product header already gets.

This release ships to the "dev" channel first (tag `v0.7.30-dev.1`).

## [0.7.29] - 2026-09-01

### Added

- Exporting keys (Keys/Codes' "Export (CSV)", or a checked selection
  from a product+platform's own key list) now actually takes them out
  of circulation -- a new `exported` key status, alongside `available`/
  `redeemed`, so an exported key can't also still be claimed through a
  claim link or exported again as "unused" (already-redeemed keys
  included in an "all statuses" export are left alone -- they were
  already consumed for real). A one-line confirmation ("N key(s)
  exported to file.csv") shows after the download completes, without
  ever listing the individual key values on screen.
- A product+platform's key list (keys_pool.php) can now export just a
  checked selection of keys instead of only "every unused key" or
  "every key" in bulk.
- Bug/feature reports now automatically record the installed version
  (and channel, if not stable) they were filed from, both on the
  public Canny board and in the private contact-details email -- so a
  report can be checked against the changelog for whether it's
  already fixed in a newer release the install just hasn't picked up
  yet.
- Admin (not just master admin) now has full access to Migrations,
  Updates, and Mail Server.
- A master admin/admin managing another account can no longer let a
  `demo` account self-delete -- only an admin/master admin deleting it
  from the Users page can remove a demo login now. (Every other
  role below master admin can still delete its own account, unchanged.)

### Changed

- Every edit/save/upload control that used to be hidden entirely for
  a `demo` login (across Campaigns, Claim Links, Creator Groups,
  Design, Keys/Codes, Platforms, and Products) is now shown the same
  way it already was on the admin-tier pages: visible, but disabled
  and grayed out, so a demo login looks like the real, full admin
  panel instead of a stripped-down one. `reviewer`'s existing
  hidden-entirely behavior is unchanged, and every action is still
  blocked server-side for both roles exactly as before -- this only
  changes what's shown, never what's allowed.
- Keys/Codes' per-product section header ("Family Bowling" etc.) now
  lines up with the platform rows below it instead of sitting flush
  against the panel edge with no indent.

This release ships to the "dev" channel first (tag `v0.7.29-dev.1`).

## [0.7.28] - 2026-09-01

### Fixed

- The Users page's "All users" panel showed an internal scrollbar and
  clipped the "Manage" dropdown for rows near the bottom, instead of
  letting it overlay outside the table -- `.panel`'s `overflow-x:auto`
  (for wide tables on narrow screens) implicitly forces `overflow-y`
  to compute as `auto` too, which clips any absolutely-positioned
  content that would otherwise spill outside it. Overridden to
  `overflow:visible` for this panel specifically; its table already
  reflows into stacked cards on mobile (see admin-dashboard.css), so
  the horizontal-scroll fallback was never actually needed here.

This release ships to the "dev" channel first (tag `v0.7.28-dev.1`).

## [0.7.27] - 2026-09-01

### Changed

- Finishes the "key-vault" -> "key-lair" self-update package rename
  (step 2 of 2): .gitlab-ci.yml now actually publishes releases under
  the "key-lair" package coordinates, matching what every install has
  been looking for since 0.7.25. Safe now that installs have had the
  chance to update to a build that checks the new name.

This release ships to the "dev" channel first (tag `v0.7.27-dev.1`).

## [0.7.26] - 2026-09-01

### Added

- A master admin/admin can now disable another manageable account's
  two-factor authentication from the Users page's Manage menu --
  account.php's existing "disable 2FA" control only ever worked on
  your own account. They'll be asked to set it up again next time
  they log in, same as an account that never configured it.

This release ships to the "dev" channel first (tag `v0.7.26-dev.1`).

## [0.7.25] - 2026-09-01

### Changed

- Renamed the product from "Key Vault" to "Key Lair" throughout --
  default site name, admin UI copy, install guide, README, license,
  and every other user-facing/documentation string. Past changelog
  entries below are left as "Key Vault" deliberately, since that was
  genuinely the product's name at the time each of them shipped.
- The self-updater's package name is renamed too (`key-vault` ->
  `key-lair`), but as a deliberate two-step migration: this release
  still gets *published* under the old "key-vault" package
  coordinates (see Deployer.php), so a site still running the
  previous build can find and install it. Only once that's happened
  does a following release switch the publishing side over as well --
  switching both at once would strand any site that hadn't already
  updated.

This release ships to the "dev" channel first (tag `v0.7.25-dev.1`).

## [0.7.24] - 2026-09-01

### Fixed

- A real claim page could show Nintendo Switch 2 promoted ahead of
  PlayStation while the original Nintendo Switch stayed stranded
  after it, instead of the two console generations sitting next to
  each other -- Platform::groupForClaimPage()'s generation-ordering
  only swapped the newer and older platform's two positions, which
  promotes the newer one but abandons the older wherever the newer
  used to be. It now moves both together to the earlier of their two
  original positions, so Switch 2 is always immediately followed by
  Switch (and PlayStation 5 by PlayStation 4) wherever that pair
  naturally falls. The product-page preview mockup is reordered to
  match.

### Added

- The product-page preview mockup's platform tiles are now actually
  clickable -- each simulates claiming a key: the claims bar advances,
  a new (fake, randomly generated) key reveal box appears, and once
  the bar fills up "Choose your platform" disappears entirely, same
  as the real claim page does once a link runs out of claims. None of
  it is saved anywhere -- reloading the preview always starts back
  over at its usual 1 / 5 state.

This release ships to the "dev" channel first (tag `v0.7.24-dev.1`).

## [0.7.23] - 2026-09-01

### Fixed

- The key reveal box sat flush against "Choose your platform" below
  it -- it had a gap above (from the claims bar) but none of its own
  below. Added the same-sized gap underneath, on the real claim page
  and the preview mockup alike (both load product-page.css).

This release ships to the "dev" channel first (tag `v0.7.23-dev.1`).

## [0.7.22] - 2026-09-01

### Fixed

- The product-page preview mockup showed its key reveal box below the
  platform grid instead of above it -- the real claim page has always
  shown it right after "Claims used" and before "Choose your
  platform", so a returning visitor sees their key without scrolling
  past a grid they don't need. The mockup now matches.

This release ships to the "dev" channel first (tag `v0.7.22-dev.1`).

## [0.7.21] - 2026-09-01

### Changed

- The product-page preview mockup now shows the sample product's own
  real screenshots/trailer (via the same ClaimService::buildMediaShowcase()
  the real claim page uses) when it actually has any uploaded --
  falling back to the existing placeholder trailer + numbered
  screenshots otherwise, e.g. on a fresh install with no products yet.

This release ships to the "dev" channel first (tag `v0.7.21-dev.1`).

## [0.7.20] - 2026-09-01

### Changed

- The product-page preview mockup's platform grid now also includes a
  Nintendo Switch 2 group (with its own EU/US regions), in the same
  order a real claim page shows it (ahead of PlayStation and the
  original Switch, per Platform::groupForClaimPage()'s generation
  ordering).

This release ships to the "dev" channel first (tag `v0.7.20-dev.1`).

## [0.7.19] - 2026-09-01

### Fixed

- The product-page preview mockup's platform grid merged PlayStation 5
  and PlayStation 4 into one "PlayStation" group as if they were
  regions of each other -- they're actually separate families (see
  Platform::familyOf()), each grouping its own EU/US regions. Now
  shown as two separate groups, matching how a real claim page with
  both generations configured actually renders.
- The same mockup's trailer/screenshot thumbnails didn't do anything
  when clicked -- they now switch the main showcase, same as the
  real claim page's thumbnails do.
- Its "Copy key" button didn't do anything either -- now copies the
  sample code to the clipboard, same as the real claim page's does.

This release ships to the "dev" channel first (tag `v0.7.19-dev.1`).

## [0.7.18] - 2026-09-01

### Changed

- The claim-page preview mockups (admin/preview.php, used by both
  Design's theme previews and demo accounts' masked claim links) no
  longer show an invented game -- they list this install's own most
  recently created products, or, on a fresh install with none yet,
  a couple of Secret Item Games' own real titles instead.
- The overview mockup's rows now actually link to the single-product
  mockup (and that one links back), instead of being dead `#` links,
  and both now agree on the same "1 / 5 claimed" example instead of
  showing different numbers for what's meant to be the same product.
- The single-product mockup's platform grid now shows a representative
  mix -- a multi-region family collapsed into an expandable group, a
  lone platform spanning the full row, another group sold out -- the
  same patterns the real claim page renders, instead of two flat tiles.

This release ships to the "dev" channel first (tag `v0.7.18-dev.1`).

## [0.7.17] - 2026-09-01

### Fixed

- The "All products" claim link masked for a demo login opened the
  single-product platform-picker preview instead of the multi-product
  overview preview -- it now opens the matching "all products for
  this link" mockup, same as a real link with more than one product
  attached would have shown.

This release ships to the "dev" channel first (tag `v0.7.17-dev.1`).

## [0.7.16] - 2026-09-01

### Fixed

- Claim Links masked for a demo login pointed at a scrambled token
  that didn't correspond to any real link, so clicking one just
  showed "This link isn't available" -- they now open the existing
  claim-page preview mockup (the same one Design's theme previews
  use) instead, so a prospective buyer can actually see what the
  page looks like rather than hitting a dead link.

This release ships to the "dev" channel first (tag `v0.7.16-dev.1`).

## [0.7.15] - 2026-09-01

### Added

- Demo accounts can now look around every admin-tier page too --
  Users, Branding, Legal & Imprint, Migrations, Updates, and Mail
  Server -- the same read-only way they already could everywhere
  else. Every save/create/delete/run action on those pages stays
  blocked server-side, not just hidden from the UI.

### Changed

- Everywhere a demo login can see other people's data -- other staff
  accounts' usernames and emails on the Users page, creator names on
  Claim Links/Redemptions/Analytics/the dashboard, and claim link
  URLs/tokens -- it now sees a realistic-looking placeholder instead
  of the real value. The underlying data is untouched; every other
  role still sees it as-is. A prospective buyer handed a demo login
  can now click through the entire product, including the admin
  area, without ever seeing a real customer's information.

### Fixed

- A claim page's platform list could leave a single ungrouped
  platform (e.g. Xbox) occupying only half its row when its
  neighbors were platform-family groups spanning the full row --
  it now spans the full row too instead of leaving the other half
  empty.

This release ships to the "dev" channel first (tag `v0.7.15-dev.1`).

## [0.7.14] - 2026-09-01

### Changed

- Demo accounts can now pick their own backend theme (Default/Light/
  Steam) from Settings, same as every other role -- a display
  preference isn't a "login detail" worth locking out, so it no longer
  shares the same restriction as editing profile/password/2FA.

This release ships to the "dev" channel first (tag `v0.7.14-dev.1`).

## [0.7.13] - 2026-09-01

### Changed

- Creating a demo account no longer requires an email address -- the
  field hides itself when Demo is selected as the role (every other
  role is unaffected). A demo login has no one to actually receive
  mail at it (password resets are already blocked for demo accounts),
  so a random, never-mailed placeholder is generated instead of
  leaving the database's NOT NULL/UNIQUE email column empty.

This release ships to the "dev" channel first (tag `v0.7.13-dev.1`).

## [0.7.12] - 2026-09-01

### Added

- Every account below Master admin can now permanently delete its own
  login from the bottom of Settings (password-confirmed), including
  demo accounts — the one thing they still can't do to themselves is
  edit or keep their account, not delete it.

### Changed

- Demo accounts' Settings page no longer shows the account's email
  (only the username), since it can't be changed there anyway.
- Creating or resetting a demo account's password no longer requires
  10+ characters (still can't be blank) — a demo login is a stable,
  shareable "look around" account, not a real credential worth forcing
  password complexity on.

Confirmed already correct, no change needed: two-factor authentication
is already mandatory for every non-demo role (enforced at login via
`Auth`'s `must_setup_2fa` flow), demo accounts already have no
password/2FA UI at all, and demo accounts are already excluded from
the "forgot password" self-service flow (`PasswordReset::request()`).

This release ships to the "dev" channel first (tag `v0.7.12-dev.1`).

## [0.7.11] - 2026-09-01

### Fixed

- Updates page's "Current" channel badge could get stuck showing the
  channel of the *last actual update*, even after switching to and
  confirming "up to date" on a different channel -- `installedChannel`
  only updates during a real `Deployer::pull()`, and switching to a
  channel whose latest is already installed never triggers one. Now
  also syncs to the currently-selected channel whenever a check
  confirms it's already up to date, not just after a real pull.

This release ships to the "dev" channel first (tag `v0.7.11-dev.1`).

## [0.7.10] - 2026-09-01

### Added

- Branding page can now replace the welcome page's hero image, the
  same way the favicon is replaced -- uploads to `/uploads/branding/`,
  falls back to the shipped default (`/assets/vault-hero.png`) until
  customized.

This release ships to the "dev" channel first (tag `v0.7.10-dev.1`).

## [0.7.9] - 2026-09-01

### Fixed

- `/assets/` (bare directory, no trailing filename) 403'd (directory
  listing disabled) and a missing file under `/assets/` 404'd, instead
  of the same "dead end -> welcome page" redirect every other stale
  URL already gets. `/uploads/` still excluded on purpose -- it holds
  customer-uploaded content directly linked from real pages, where
  redirecting away from that page over one missing image would be
  worse than just a broken image.
- Design page's Background image / Overview page / Product claim page
  upload rows spread Preview (or Choose file) and Upload across the
  *entire* row width on desktop, leaving a large empty gap between them
  -- was deliberate for an earlier request, but reversed here: both now
  sit left-aligned next to each other, like a normal button group.
  Mobile's stacked layout (Choose file full-width, Preview/Upload
  sharing the line below) is unaffected -- it never depended on the
  desktop spacing to begin with.

This release ships to the "dev" channel first (tag `v0.7.9-dev.1`).

## [0.7.8] - 2026-09-01

### Changed

- Dashboard's "Keys by platform" / "Recent redemptions" row is now an
  even 50/50 split, same as Analytics' equivalent row.

This release ships to the "dev" channel first (tag `v0.7.8-dev.1`).

## [0.7.7] - 2026-09-01

### Added

- Self-update now removes files that were renamed or deleted in a
  newer release (an explicit, hardcoded list of exact old paths --
  never a generic "delete anything not in the new package" sync,
  which would risk `public/uploads/` or a customer's own
  `config.php`). First entry: 0.7.6's old `public/admin/feedback.php`,
  left behind as a dead but still-working orphan since the updater
  previously only ever copied files, never removed any. Takes one
  extra update cycle to start applying, same as any change to the
  updater's own code -- the update that ships this can't run it
  against itself.

This release ships to the "dev" channel first (tag `v0.7.7-dev.1`).

## [0.7.6] - 2026-09-01

### Changed

- `admin/feedback.php` renamed to `admin/bug_reporting.php` (URL
  changes from `/admin/feedback` to `/admin/bug_reporting`), matching
  the page's "Bug Reporting" label.

This release ships to the "dev" channel first (tag `v0.7.6-dev.1`).

## [0.7.5] - 2026-09-01

### Fixed

- Bug Reporting screenshots never actually attached to the Canny post,
  even though the report itself submitted successfully with no error.
  Root cause confirmed directly against Canny's API: scalar fields
  (title, details, apiKey, ...) parse fine as
  application/x-www-form-urlencoded, but the `imageURLs` array field is
  silently dropped in that encoding (tried bracketed, unbracketed, and
  repeated-key array syntax -- none worked) -- only a real JSON request
  body gets it through. `CannyReport` now sends JSON instead of
  form-urlencoded for every Canny API call.

This release ships to the "dev" channel first (tag `v0.7.5-dev.1`).

## [0.7.4] - 2026-09-01

### Changed

- Product/overview pages' decorative gradients (hero media, thumbnails,
  overview thumbnails) now blend `--signal` with `--signal-dim`
  (blue-only) instead of `--key-gold` (green) -- 0.6.13's color-mix fix
  correctly re-themed these per-theme, but every built-in theme pairs a
  blue signal with a green key-gold, so the blend still looked like a
  similar blue-green blob on Default and Steam alike. All-blue reads
  closer to Steam's actual navy/blue look, and is a cleaner match for
  Default and Light too.
- Design page's Product claim page theme preview mockup showed "2 / 5"
  claims used next to a single revealed key, which didn't match what
  the preview was demonstrating (changed to "1 / 5"). On the real page
  that combination is correct -- the counter is link-wide, not "keys
  shown on this screen" -- but the static preview isn't backed by real
  data, so the mismatch had no explanation behind it there.

## [0.7.3] - 2026-09-01

### Fixed

- Root cause of the Bug Reporting submission failures: the GitLab
  CI/CD variable `CANNY_API_KEY` was stale, so every release was
  shipping the wrong Canny API key baked into
  `src/canny-credentials.php` (0.7.2's detailed error message is what
  finally surfaced this: `HTTP 400: {"error":"invalid api key"}`, not
  a network/TLS issue as 0.7.1 assumed). No code change here -- this
  release exists purely to rebuild with the corrected variable.

This release ships to the "dev" channel first (tag `v0.7.3-dev.1`).

## [0.7.2] - 2026-09-01

### Changed

- Bug Reporting's error messages now include the actual underlying
  failure (cURL error, or the HTTP status + response body from Canny)
  instead of a generic "try again later" -- 0.7.1's cURL fix didn't
  actually resolve the live failure, and there was no way to see why
  without server log access.

This release ships to the "dev" channel first (tag `v0.7.2-dev.1`).

## [0.7.1] - 2026-09-01

### Changed

- "Feedback" renamed to "Bug Reporting" throughout the admin panel
  (nav label, page title, form copy, the vendor-facing email subject
  for a submission with contact details).

### Fixed

- Submitting a bug report/feature request could fail outright
  ("Could not reach the feedback service") depending on the server's
  PHP setup -- `CannyReport`'s HTTP calls now prefer cURL over
  file_get_contents()/streams (more reliable in shared-hosting PHP
  setups, particularly around TLS/CA bundle handling), falling back to
  streams only if the curl extension isn't available, and log the
  actual failure reason either way instead of silently returning false.

This release ships to the "dev" channel first (tag `v0.7.1-dev.1`).

## [0.7.0] - 2026-09-01

### Changed

- Campaigns are now optional when creating a claim link, instead of a
  hard requirement alongside an active product. Campaigns are for
  grouping keys/links for analytics and reporting -- products already
  carry the keys, and links are claimed per product, so nothing about
  actually handing out a key ever needed a campaign. The "New claim
  link" gate now only asks for an active product; the campaign field
  is labeled optional with a "No campaign" choice. A campaign-less
  link's max-claims still resolves normally (override -> creator group
  default -> product default, skipping the now-absent campaign tier).
  Deleting a campaign now detaches its claim links (and their
  redemption history) instead of cascade-deleting them -- it still
  deletes the campaign's keys, same as before.

This release ships to the "dev" channel first (tag `v0.7.0-dev.1`).

## [0.6.13] - 2026-09-01

### Fixed

- Product claim page's decorative placeholder gradients (hero media,
  thumbnails, overview page thumbnails, the key-reveal card's glow) were
  hardcoded to a fixed blue/green regardless of theme, instead of
  tinting with the active theme's own accent colors -- looked fine
  under Default (close to that theme's colors by coincidence) but made
  the Steam theme's product page look inconsistent with Steam's own
  navy/sharp-cornered style used elsewhere (e.g. the backend's own Steam
  theme). Now built from the theme's `--signal`/`--key-gold` tokens via
  `color-mix()`, so it actually re-themes.

### Changed

- Analytics page's "Redemptions by platform" / "Top products" row is
  now an even 50/50 split instead of the wider-right ratio shared with
  Dashboard's differently-shaped panels.

This release ships to the "dev" channel first (tag `v0.6.13-dev.1`).

## [0.6.12] - 2026-09-01

### Changed

- Platforms' key-format regex reference (Xbox/PlayStation/Switch/Steam
  patterns) is now a distinct, styled info box instead of a faint
  paragraph with one giant pre-wrapped code blob glued to the bottom of
  the table -- each platform gets its own labeled pattern and
  explanation instead of everything running together in a single block
  of text.

This release ships to the "dev" channel first (tag `v0.6.12-dev.1`).

## [0.6.11] - 2026-09-01

### Changed

- Updates page's Current/Available version numbers now show their
  channel as a suffix on the version itself (e.g. "0.6.10-dev" next to
  Stable's "0.6.7") instead of only in a separate label above --
  VERSION is always a bare "X.Y.Z" internally, so without this a
  pre-release build sitting next to a numerically-lower stable release
  read as an arbitrary downgrade instead of what it actually is: moving
  between a pre-release build and a stable one.

This release ships to the "dev" channel first (tag `v0.6.11-dev.1`).

## [0.6.10] - 2026-09-01

### Fixed

- Dashboard's "Update available" banner fired for *any* version
  difference on the checked channel, including a channel whose latest
  is numerically behind what's already installed (e.g. switching from
  Development back to Stable, which hasn't caught up to a pre-release
  build yet) -- reading as an update prompt for what was actually a
  downgrade. Now only shows for a genuine forward update, and labels
  itself with the channel (e.g. "Update available · Development") the
  same way admin/deploy.php's Current/Available badges do.

This release ships to the "dev" channel first (tag `v0.6.10-dev.1`).

## [0.6.9] - 2026-09-01

### Added

- Updates page now labels the Current and Available version numbers
  with which channel they're actually on (e.g. "Current · Development"
  / "Available · Stable") whenever that channel isn't Stable. Without
  this, switching from a pre-release channel back to Stable could show
  a lower "Available" version than "Current" with no explanation --
  reading like a downgrade offer instead of what it actually is: Stable
  simply hasn't caught up to the pre-release build you're already
  running. The installed build's channel is recorded the moment an
  update completes (`Settings::installedChannel()`), separately from
  which channel the next check looks at.

This release ships to the "dev" channel first (tag `v0.6.9-dev.1`).

## [0.6.8] - 2026-09-01

### Fixed

- Sidebar's Admin group toggle on mobile: the chevron pointed down/up
  (closed/open), copying desktop's vertical-dropdown convention, even
  though the group actually opens into a horizontal row to the right of
  the toggle here -- it now points right when closed and left when
  open, matching the direction it actually expands/collapses.
- Sidebar's Admin group on mobile now has a visible border/background
  around its icons once expanded, so they read as a distinct group
  instead of blending into the regular top-level nav icons beside them
  (desktop conveys the same grouping via indentation, which the
  horizontal mobile layout has no equivalent of).

This release ships to the "dev" update channel first (tag
`v0.6.8-dev.1`) rather than stable directly, per the new dev -> alpha
-> beta -> stable promotion workflow.

### Changed

- Updates page's Current/Available version block, when stacked on
  mobile, now shows Available on top and Current underneath with the
  connector arrow pointing up instead of down -- it's an update, so the
  version is climbing, and the arrow should point the way it's moving.
  Desktop's side-by-side order is unchanged.

## [0.6.6] - 2026-09-01

### Fixed

- The sidebar's "Admin" group (Migrations, Updates, Mail Server,
  Branding, Legal & Imprint) couldn't actually be collapsed on mobile
  -- the top icon row's CSS forced it permanently open regardless of
  the toggle button, unlike the desktop sidebar where it starts
  collapsed. Tapping the toggle now shows/hides it there too.

## [0.6.5] - 2026-09-01

### Changed

- `/impressum` and `/datenschutz` are now `/imprint` and `/privacy`,
  and their content is in English -- matching the language of the rest
  of the app instead of standing out as the only German-language pages.
  The legal substance is unchanged (still grounded in the same German
  DDG/GDPR requirements, since that's the law that actually applies),
  just written in English the way many German companies present these
  pages to an international audience. Every footer link and the admin
  "Legal & Imprint" settings page were updated to the new URLs.

## [0.6.4] - 2026-09-01

### Changed

- Users page's Roles table renamed its "Can do" column to "Permissions".

### Fixed

- Users page's Roles table: the general action-cell mobile rule (flips
  Edit/Delete-style button cells to put the destructive action on the
  left) was also hijacking this table's last column even though it's a
  plain description, not buttons -- right-aligning the "Permissions"
  label above the paragraph and scrambling the text flow. Opted it back
  into normal block flow.
- Stat cards (Dashboard, Analytics): the fixed 2-line reserved height
  behind each label -- added to line up the numbers below when one
  label wraps and its neighbor doesn't -- also bottom-aligned the label
  text itself, which made "Redeemed" float noticeably lower than
  "Total keys"/"Redemption rate" beside it, and wasted visible space
  under every label on rows where none of them wrap (e.g. the
  Dashboard's 2x2 grid). Replaced the fixed height with the grid's
  existing per-row stretch plus anchoring each value to the bottom of
  its (now naturally-sized) card -- labels stay top-aligned like normal
  text, and a row only grows as tall as it actually needs to.

## [0.6.3] - 2026-09-01

### Fixed

- Mail Server page's "Offer forgot password self-service reset"
  checkbox was vertically centered against its whole multi-line label
  instead of its first line, so on a narrow screen (where the label
  wraps to 3 lines) it floated next to the middle line instead of
  sitting beside the text like a normal checkbox.

## [0.6.2] - 2026-09-01

### Fixed

- 0.6.1's Preview/Upload fix on the Design page's per-page CSS section
  didn't actually take effect on mobile: the form's own inline
  `display:flex` outranks a class selector regardless of media query,
  so `display:contents` needed `!important` to actually flatten it (the
  earlier theme-row fix this was modeled on never hit this because that
  form had no inline `display` to begin with).

## [0.6.1] - 2026-09-01

### Fixed

- Design page's background-image Upload button landed on the left on
  mobile instead of the right once "Choose file"'s long placeholder
  text forced it onto its own line -- same fix applied to the per-page
  Preview/Upload row, where Preview and Upload no longer end up on
  different lines (the form around Choose-file/Upload wasn't a real
  flex item of that row, same root cause as the theme-row fix in
  0.4.10/0.5.1).
- Platforms' key-format regex field now fills the rest of its row on
  mobile instead of leaving space unused next to Save, which pushes
  Save to sit flush right instead of wherever the fixed-width field
  happened to leave it.

## [0.6.0] - 2026-09-01

### Added

- Public `/impressum` and `/datenschutz` pages, linked from every
  customer-facing footer (overview page, product claim page, welcome
  page). Content is grounded in what this app actually does with
  visitor data (session cookie, salted IP hashes on redemptions,
  Google Fonts) rather than generic boilerplate, with structure/wording
  credited to common German Impressum/Datenschutz template conventions
  (e-recht24.de, datenschutz-generator.de).
- New admin page "Legal & Impressum" (admins.php-adjacent, master/admin
  role) to edit the company details those two pages are built from --
  name, address, managing director, register court/number, VAT ID,
  contact email/phone, data protection officer. Ships pre-filled with
  Secret Item Games GmbH's own details as a starting point; every
  install should replace these with their own company's information
  before going live.

## [0.5.3] - 2026-09-01

### Changed

- Every table row's actions cell (Edit/Delete, Upload icon/Delete,
  Edit/Disable/Delete, ...) now spreads across the full row on mobile
  instead of the buttons sitting bunched together on one side: Delete
  (or whichever destructive action) lands on the left, the primary
  action on the right, same left-right order every time regardless of
  the table.

## [0.5.2] - 2026-09-01

### Changed

- Design page's per-page CSS section reordered "Download default
  template" ahead of "Download active" and made both full-width on
  mobile instead of competing for room in one row; Preview and Upload
  now sit at opposite ends of their own row (Preview left, Upload
  right) instead of wherever they happened to wrap to.
- Design page's background-image upload form is now itself
  space-between (Choose file left, Upload/Replace right) instead of the
  two just sitting side by side and wrapping unpredictably on a narrow
  screen.
- "New platform"'s Name placeholder shortened -- the full sentence ran
  past the input's edge on a narrow screen.

### Fixed

- Dashboard/Analytics stat cards' numbers didn't line up: a label that
  fit on one line (e.g. "Redeemed") left its number sitting higher than
  a card whose label wrapped to two (e.g. "Total Keys", "Redemption
  Rate"). Every label now reserves the same two-line height regardless
  of how much of it it actually needs.

## [0.5.1] - 2026-09-01

### Fixed

- A long unbreakable value in a mobile stacked table card (a migration
  filename, in the report that caught this, but the same applied to
  anything similar) had nowhere to shrink to and just ran off the edge of
  the card instead of wrapping onto a second line.
- Account page's Backend theme buttons (Default/Light/Steam) had the same
  "the form, not the button, is the actual flex item" issue as 0.4.10's
  Design-page fix, so on mobile they stayed sized to their own text
  instead of growing evenly to fill the row.

## [0.5.0] - 2026-09-01

### Added

- Updates page can now track a Beta, Alpha, or Development pre-release
  channel instead of Stable (master admin only, defaults to Stable).
  Publishing a pre-release just means tagging `vX.Y.Z-alpha`/`-beta`/`-dev`
  (optionally `.N`, e.g. `v0.5.1-beta.2`) instead of a plain `vX.Y.Z` --
  `.gitlab-ci.yml` reads that suffix and publishes to its own separate
  package coordinates, so a pre-release tag never touches what a stable
  install sees and a later stable tag never touches what a pre-release
  install sees. Switching channels takes effect on the very next check,
  and switching back to Stable doesn't downgrade anything by itself.

## [0.4.11] - 2026-09-01

### Changed

- 0.4.10's fix made the theme button and Preview link split each row
  50/50, so the four theme buttons (Default/Light/Steam/Custom) ended up
  different widths from each other depending on the row's total width.
  They now keep a fixed, equal width instead -- same idea as the column
  they already line up in on desktop -- with only Preview (always the
  same text) growing to fill the rest of the row.

## [0.4.10] - 2026-09-01

### Fixed

- 0.4.7's Design-page theme-row mobile fix only made the Preview link
  fill the row -- the theme button's `flex:1` had no effect, because the
  button sits inside a `<form>` (needed to submit which theme was
  picked), and that form, not the button, is the row's actual flex
  child. Preview alone ended up absorbing all the leftover space instead
  of the two sharing it evenly. Fixed with `display:contents` on the
  form so the button becomes the real flex item directly.

## [0.4.9] - 2026-09-01

### Changed

- 0.4.8's Updates-page fix hid the "Current → Available" connector arrow
  on mobile instead of just leaving it awkwardly placed. Now rotated to
  point down and forced onto its own row instead, landing between the
  two now-stacked version blocks the same way it connects them
  side-by-side on desktop.

## [0.4.7] - 2026-09-01

### Fixed

- Claim page's platform tile grid overflowed horizontally on narrow
  screens with a long platform name (e.g. "Nintendo Switch 2 US") --
  grid items default to a content-based minimum width, not 0, so the
  name forced its column (and the whole page) wider than the viewport
  despite the tile's own text already being set up to ellipsis instead.
  Fixed by letting the grid items actually shrink.
- Admin sidebar's active-page indicator could scroll out of view on
  mobile: every navigation is a full page load, so the horizontally
  scrollable icon row always rendered back at its start regardless of
  which page you were actually on. Now scrolls the current page's icon
  into view on load.
- Every admin table required horizontal scrolling on a narrow screen
  (many columns, or a column with inherently long content like a full
  claim-link URL or a regex key format) -- now stacks into one card per
  row instead, with each value labeled by its column header. Applies
  site-wide (Products, Platforms, Claim Links, Keys/Codes, Campaigns,
  Creator Groups, Redemptions, Migrations, Users, Dashboard).
- Design page's per-theme row (theme button + Preview link) used a fixed
  90px button width, sized for lining theme names up in a column on
  desktop -- on mobile it left a large empty gap next to a button nowhere
  near the row's actual (already full-width) size. Same underlying issue
  for a topbar with a single lone action (e.g. Feedback's "View feedback
  board" link): once it wrapped onto its own line, it stayed sized to its
  content instead of filling that line. Both now stretch to fill their
  row on mobile.

## [0.4.6] - 2026-09-01

### Removed

- 0.4.5's temporary master-admin "Delete all keys" action (used once to
  clear this install's test data) — too dangerous to leave in a shipped
  product where a customer's live key inventory has no way back from one
  click.

## [0.4.5] - 2026-09-01

### Added

- Keys/Codes export now has a Status selector (**Unused only**, the
  previous and still-default behavior, or **All keys** to include
  already-claimed ones too) and, on the per-product+platform view
  (`admin/keys_pool.php`), an optional **Limit** capping the export to the
  oldest N keys instead of everything -- e.g. handing off exactly 50 keys
  without exporting the whole stock.
- Existing platforms created before 0.4.4 that match the Xbox,
  PlayStation, or Nintendo Switch/Switch 2 templates get their key format
  backfilled with the same researched pattern new ones now get
  automatically -- only where nothing was already configured.

## [0.4.4] - 2026-09-01

### Added

- A new platform created from the Xbox, PlayStation, or Nintendo
  Switch/Switch 2 templates (**Platforms** → New platform) now gets its
  key format pre-filled automatically with the matching researched
  pattern from 0.4.3, instead of only being shown as copy-pasteable
  reference text -- still just a starting point, adjustable (or
  clearable) afterward like any manually-set pattern. Steam never gets
  one pre-filled, and existing platforms are untouched either way.

## [0.4.3] - 2026-09-01

### Changed

- Refined 0.4.2's reference key-format patterns with researched
  character exclusions instead of a generic `[A-Z0-9]`: Xbox excludes
  A/E/I/O/U/L/S and 0/1/5 (easily confused with other characters),
  Nintendo Switch/Switch 2 excludes I/O/Z. Also notes PS4 and PS5 share
  the same 12-character voucher format (one PlayStation Store account
  system, not per-console).

## [0.4.2] - 2026-09-01

### Added

- Each platform can have an optional key format (a regex) configured on
  **Platforms**, checked against every value on import (**Import Keys**) --
  a key that doesn't match is skipped and listed separately from
  duplicates, same pattern as the existing duplicate handling. Blank
  (every platform's default) accepts anything, unchanged from before.
  Shows a few real-world starting points (Xbox, PlayStation, Nintendo
  Switch/Switch 2) as reference text -- deliberately not pre-filled
  anywhere, since actual formats vary by publisher, especially for Steam,
  and a wrong guess would silently reject real keys.
- Product editor's Platforms section is now collapsible (open by default).

### Changed

- Claim page's hero media widened further (760px to 940px page column) and
  screenshot thumbnails now stretch to fill the row when there are only a
  few of them, instead of leaving empty space on the right -- still
  scrolls horizontally once there are enough to need it.

## [0.4.1] - 2026-09-01

### Fixed

- 0.4.0's "newest console generation first" reordering used a single
  global family-priority list, which also pushed Xbox and PC/Steam below
  every console family -- not the intent. Reworked into two targeted
  position swaps (Switch 2 ahead of Switch, PS5 ahead of PS4) instead,
  so everything else keeps whatever order the platforms already had.

## [0.4.0] - 2026-09-01

### Added

- Product editor's "Restrict this product to specific platforms" checklist
  has "Select all" / "Deselect all" links above it, for quickly flipping
  every platform at once instead of clicking each one individually (e.g.
  select all, then uncheck the 3 that shouldn't be included).

### Changed

- Claim page's grouped platform families (0.3.22) now list the newest
  console generation first -- Nintendo Switch 2 above Switch, PlayStation 5
  above PlayStation 4 -- instead of whatever order platforms happened to
  be added in.
- A grouped family with no region in stock at all now renders dimmed, the
  same treatment an individual sold-out tile already gets, instead of
  looking exactly like an available one until actually expanded.
- Claim page's hero media (video/cover image) and screenshot thumbnails
  are larger: the page column widened (680px to 760px, scaling the hero
  proportionally since it's always 100% of it) and thumbnails themselves
  grew from 96px to 128px (74px to 92px on narrow screens).

## [0.3.22] - 2026-09-01

### Added

- Claim page's platform tiles now group regional variants of the same
  console (Nintendo Switch, Nintendo Switch 2, PlayStation 4, PlayStation
  5) behind one collapsible entry -- expand the console to then pick the
  specific region, instead of every region always listed as its own tile.
  Mirrors the same six families (PC, Xbox, and the four above) already
  used as `<optgroup>` labels in the admin's own "New platform" template
  picker. A family only collapses when a product actually supports 2+ of
  its regions; a single region (or anything outside these six recognized
  families, e.g. a custom platform) still renders as a normal standalone
  tile. Plain `<details>`/`<summary>`, no JS needed for the toggle.

## [0.3.21] - 2026-09-01

### Changed

- Product claim page's media showcase falls back to the cover image
  whenever there's no video, ahead of the screenshots -- not just when
  there's no media at all as before. A product with only screenshots (no
  video) now leads with the cover an admin actually chose to represent it,
  instead of whichever screenshot happens to be first. Extracted the
  video/cover/screenshot ordering logic (previously duplicated between
  `public/product.php` and `admin/product_preview.php`) into
  `ClaimService::buildMediaShowcase()` so the two can't drift apart again.
- Platform selection tiles are now a compact row layout (icon beside the
  name/status) instead of a tall stacked one (icon above name above
  status) -- a product can have many platforms, and the old ~130px-tall
  tile alone could push the key reveal off screen with only a handful of
  them.

## [0.3.20] - 2026-09-01

### Fixed

- 0.3.14's `upload_max_filesize`/`post_max_size` raise via
  `public/.htaccess`'s `php_value` only works for `mod_php` -- confirmed
  live it had no effect at all here, since a server hosting several
  separate apps (each potentially on its own PHP version) commonly runs
  PHP via `php-fpm` instead, where Apache's `.htaccess` `php_value`
  directives are simply not read (PHP isn't an Apache module in that
  setup). Added `public/.user.ini` -- PHP's own per-directory config file,
  honored by both `mod_php` and `php-fpm`/CGI -- with the same values, so
  the raise actually applies regardless of which one a given install uses.
  Note for anyone hitting this again immediately after updating: `php-fpm`
  caches `.user.ini` for up to 5 minutes (`user_ini.cache_ttl`) before
  re-reading it, or takes effect immediately after restarting the
  `php-fpm` service.

## [0.3.19] - 2026-09-01

### Added

- Product status (Draft/Active/Archived) is now changeable directly from
  the Products list via an inline dropdown, instead of needing to open the
  full product editor just to flip that one field.

## [0.3.18] - 2026-09-01

### Fixed

- 0.3.17's new "Copy" buttons on Claim Links silently did nothing (no
  "Copied!" feedback, nothing actually on the clipboard) if
  `navigator.clipboard.writeText()` rejected -- observed live with a
  `NotAllowedError` even in a secure-context HTTPS browser, not just the
  already-handled cases of a non-HTTPS install or a browser lacking the
  Clipboard API entirely. That promise had no `.catch()`, so the rejection
  was silently swallowed instead of falling back to the legacy
  `execCommand` copy method. Now falls back on any Clipboard API failure,
  not just its absence.

## [0.3.17] - 2026-09-01

### Added

- Claim Links page now shows each product's own individual claim link
  (`/claim/{token}/{product-slug}`) alongside the existing overview link,
  not just the product count. Every link -- overview and per-product -- is
  a real clickable `<a target="_blank">` that opens in a new tab, plus its
  own "Copy" button (Clipboard API, falling back to the legacy
  `execCommand` approach for a non-HTTPS install or older browser).
  Replaces the previous click-the-readonly-input-to-copy overview link,
  which wasn't clickable to open and had no per-product links at all.

### Changed

- Superseded 0.3.15's fixed-width fix for Dashboard/Analytics' bar-chart
  label column with a CSS Grid layout (`.bar-list`, each `.bar-row` now
  `display:contents`) that auto-sizes the column to the longest label
  actually present in that list, instead of a guessed pixel value -- every
  bar in a list now starts at exactly the same x position no matter how
  long or short the platform/product/creator names are.

### Fixed

- Number input spin buttons (e.g. Creator Groups' "Default claim limit",
  the product editor's per-platform claim limits) rendered as a stark
  light-gray widget regardless of theme, since they're browser-native
  chrome that plain `color`/`background` on the input can't reach. Each
  built-in theme's `tokens.css` now declares `color-scheme: dark` (Default,
  Steam) or `color-scheme: light` (Light), which tells the browser to draw
  every native form widget -- spin buttons, scrollbars, checkboxes,
  date/color pickers -- in the matching palette. Fixed everywhere at once
  (frontend and backend both), since every themed page loads `tokens.css`.

## [0.3.15] - 2026-09-01

### Fixed

- Dashboard's "Keys by platform" bars didn't line up: the platform-name
  column (`.bar-plat`) only had a `min-width`, so a longer name (e.g.
  "Nintendo Switch US") pushed that row's bar further right than shorter
  ones like "GOG" or "Steam" on the rows above/below it. Now a fixed
  `width:150px` with ellipsis overflow for anything longer, so every bar
  in the list starts at the same x position regardless of platform-name
  length. Same shared `.bar-plat` class also used (unaffected, both already
  had their own inline width override) by Analytics' "Top products"/"Top
  creators" bars, and (previously unstyled the same way, now fixed too)
  its "Redemptions by platform" bars.

## [0.3.14] - 2026-08-31

### Fixed

- Root-caused the "Upload failed." reports from 0.3.13: most Linux distros'
  default `php.ini` ships `upload_max_filesize` far below this app's own
  8 MB per-image cap (Debian/Ubuntu default: 2 MB) — a routine 4-5 MB
  screenshot was silently truncated by PHP itself before any app code (or
  its friendlier "Image is too large" message) ever ran. `public/.htaccess`
  now raises `upload_max_filesize` to 16M and `post_max_size` to 48M (the
  product editor can submit several images — cover, multiple screenshots,
  background — in one request) via `php_value`, so this is fixed for every
  install out of the box instead of requiring a customer to SSH in and
  hand-edit `php.ini`.

## [0.3.13] - 2026-08-31

### Fixed

- Every failed image upload (cover, screenshot, background, platform icon)
  showed a bare, unhelpful "Upload failed." regardless of the actual PHP
  `UPLOAD_ERR_*` reason. Most commonly this means the single file exceeded
  the server's `upload_max_filesize` (a php.ini limit distinct from — and
  possibly smaller than — the app's own 8 MB per-image cap); the message
  now says that specifically, along with the other PHP upload error cases
  (partial upload, no temp folder, disk write failure, blocked by a server
  extension).

## [0.3.12] - 2026-08-31

### Added

- Each product's editor (**Products**) has a new **Page theme** section:
  override the site-wide frontend theme (Default/Light/Steam/Custom) for
  just that one product's claim page, plus — only when that override is
  Custom — its own optional **Background image**, fully independent of
  the site-wide one on Design.
- **Design** page's Theme panel now has a **Preview** link next to each
  option, opening the product claim page with that theme applied in a new
  tab without changing what's actually live.

### Changed

- Simplified the theme/design-customization system down to what's
  actually used:
  - The personal backend theme (`admin/account.php`) no longer offers
    "Custom" — the backend never had a CSS upload feature to make it mean
    anything.
  - Removed admin-panel and admin-product-editor CSS customization from
    the Design page entirely — only the two customer-facing pages
    (Overview, Product claim page) remain customizable.
  - Removed the separate "Design tokens (colors, fonts)" custom upload —
    a designer overriding colors/fonts under a Custom theme now puts a
    `:root{...}` block directly in the Overview/Product-claim-page upload
    instead; it wins the cascade for whichever variables it redefines.
- Swapped the order of **Settings** and **Logout** in the sidebar account
  menu.
- **Migrations** page's "Applied" list is now sorted newest-first and
  collapsed behind a toggle by default, instead of a flat always-open
  list sorted by filename.

### Fixed

- **Migrations**: a fresh install's "Applied" list showed every migration
  file that ever existed (each marked with the install's own timestamp),
  even though none of them were actually run there — they were only
  marked as covered because a fresh `schema.sql` already includes
  everything those files add. `schema_migrations` now tracks *why* a row
  is there (`source = 'install'` vs. `'manual'`); the Applied list only
  shows migrations genuinely run (or explicitly marked applied) through
  this page, so a fresh install now starts with an empty Applied history
  instead of a misleading one.

### Removed

- The `~kv_`-prefixed database-user rename feature (added to make a
  shared server's MySQL user list unambiguous) has been removed entirely.
  It operated one layer below what it was meant to solve: Key Vault's own
  admin accounts (`admin_users`) are application-level rows inside the
  single shared database, never MySQL server accounts, so renaming the
  one shared DB-connection user could never make individual Key Vault
  logins recognizable in a MySQL user list.

## [0.3.11] - 2026-08-31

### Fixed

- **Security**: the backend theme's stylesheets (`?admin=1` requests) were
  still marked `Cache-Control: public, max-age=300` — since that content
  is now personal per admin but the URL is identical for everyone, a
  shared cache (CDN, corporate proxy) sitting between the browser and the
  server could have served one admin's chosen theme to a different admin
  requesting the same URL, and even a single shared browser profile could
  briefly do the same across two admins signing in back-to-back. Backend
  requests now send `Cache-Control: no-store`; the genuinely-identical
  frontend path keeps its short shared cache.
- `PageStyle::builtInThemeTokens()` now validates its `$theme` argument
  against an explicit whitelist before building a file path from it,
  instead of trusting every caller to have already done so — defense in
  depth, since the one current caller already passes a validated value.
- The product editor's platform-restriction form no longer risks a PHP
  "Array to string conversion" warning (and a silently-wrong `claim_limit`
  of 0) if `platform_limits` or a `platforms[]` entry ever arrives in an
  unexpected shape.

## [0.3.10] - 2026-08-31

### Changed

- Split the 0.3.9 theme switcher into two independent themes instead of
  one site-wide choice: the **frontend** theme (Default/Light/Steam/Custom,
  `admin/design.php`) applies to the customer-facing claim/product pages
  for every visitor, while the **backend** theme is now a personal
  preference each admin sets for themselves (`admin/account.php`,
  `admin_users.theme` — `sql/migrations/010_admin_backend_theme.sql`) that
  only affects how the admin panel looks when *they're* signed in, never
  anyone else. `public/css.php` resolves which one applies via a new
  `?admin=1` query flag that `layout_header.php` and every pre-login admin
  page (login, forgot/reset password) now send on their stylesheet links;
  `admin/preview.php` and `admin/product_preview.php` deliberately don't,
  since they're previewing what a real visitor sees. The uploaded
  background image is a frontend-only concept too — never shown in the
  backend even under a personal Custom theme there.

## [0.3.9] - 2026-08-31

### Added

- The Design page now has a **Theme** switcher (Default, Light, Steam,
  Custom) that applies across the whole site, including the admin panel.
  Only **Custom** uses anything uploaded on the page below it — switching
  away from Custom doesn't discard an uploaded stylesheet or background
  image, it's just ignored (and the section is visibly grayed out) until
  Custom is selected again. Since every themed stylesheet besides
  `tokens.css` only ever references its CSS variables (never a hardcoded
  color — see `resources/css-defaults/tokens.css`), Light and Steam are
  each just their own `tokens-{theme}.css` file
  (`PageStyle::builtInThemeTokens()`); nothing else needed duplicating.
- A **background image** can now be uploaded from the same page — shown
  behind every page while Custom theme is active. Appended to the end of
  every themed stylesheet's own CSS output (not just `tokens.css`), since
  each page's stylesheet sets its own `body{background:...}` and loads
  *after* `tokens.css`, which would otherwise just lose the cascade.

## [0.3.8] - 2026-08-31

### Changed

- The Keys/Codes page and the Dashboard's "Keys by platform" panel are now
  grouped by product (game) first, with platforms listed underneath each
  one, instead of a flat list mixing every product's platforms together
  (Dashboard) or repeating the product name on every row (Keys/Codes).

### Added

- Products can now have their own default max claims per link
  (`products.default_max_claims`,
  `sql/migrations/009_product_default_max_claims.sql`), used as a new
  fallback tier in `ClaimLinkFactory::resolveMaxClaims()`: per-link
  override → creator group default → campaign default → **product
  default** → 1. Set on the product editor's Basics section; NULL by
  default, so nothing changes for existing products until one is set.

## [0.3.7] - 2026-08-31

### Changed

- The first-run "Create the master admin account" form now suggests
  "admin" as a placeholder for the username field, instead of showing
  nothing. Purely a suggestion — nothing is pre-filled or forced, and an
  existing account's username can already be changed anytime from the
  Account page.

## [0.3.6] - 2026-08-31

### Added

- Migrations page has a new **Database user** panel (master admin only)
  that renames the MySQL/MariaDB account Key Vault connects as, prefixing
  it with `~kv_` — unambiguous which account belongs to Key Vault if the
  database server hosts other apps too, and guaranteed to sort last in any
  username listing (`~` is the highest normal printable ASCII character,
  and `mysql.user`'s `User` column uses a binary collation). Deliberately
  not a plain `sql/migrations/*.sql` file: `RENAME USER` alone would break
  every request immediately after, since `config.php`'s `db.username` also
  has to change to match — `src/DbUserPrefix.php` does both atomically,
  backs up `config.php` first, and verifies the new credentials with a
  fresh connection before reporting success. Needs the connected account
  to already have the `CREATE USER` privilege; fails cleanly (nothing
  changed) if it doesn't.

## [0.3.5] - 2026-08-31

### Added

- The Feedback page now has optional "Your name" / "Your email" fields in
  case we need to follow up. Deliberately never sent to the public Canny
  board (`CannyReport::submit()` never receives them) — only emailed
  straight to the vendor (`weber@secret-item-games.com`) via the install's
  own outgoing mail, same as password reset emails. A failed send doesn't
  fail the report, which has already posted to the public board by then.

## [0.3.4] - 2026-08-31

### Added

- Products can now be restricted to specific platforms instead of always
  showing every configured platform (including ones they never intend to
  support) as a "Sold out" tile. Each selected platform can also have its
  own claimable quota, independent of how many keys actually exist — e.g.
  500 keys imported for a product+platform, but only 200 claimable right
  now (`sql/migrations/008_product_platforms.sql`, `src/ProductPlatform.php`).
  Unrestricted by default; existing products and claim links keep working
  exactly as before until an admin opts a product in via its editor's new
  **Platforms** section. Enforced atomically at claim time the same way
  the existing per-claim-link limit already was (issue #8).

## [0.3.3] - 2026-08-31

### Fixed

- The Updates page's changelog boxes overflowed off the right edge of the
  screen on mobile instead of wrapping, cut off by a fixed `max-width` that
  was wider than the viewport plus a flexbox sizing quirk.

## [0.3.2] - 2026-08-31

### Changed

- The Updates page's changelog excerpts now show the full picture instead
  of just one version's notes: the current version's collapsible is the
  entire release history, and the available version's collapsible is every
  version strictly newer than what's installed, combined — since an update
  always jumps straight to the latest build rather than applying versions
  one at a time, this is what you'd actually receive by clicking Update,
  whether you're one version behind or updating from something much older.

## [0.3.1] - 2026-08-31

### Added

- The Dashboard now shows an "Update available" notice with a button
  straight to the Updates page, visible to master admins only (the same
  role that can actually install an update).

## [0.3.0] - 2026-08-31

### Added

- New **Feedback** page (Editor role and above) for submitting bug reports
  and feature requests, with optional screenshots. Each submission is
  posted automatically to a public Canny feedback board shared across every
  Key Vault install, so customers can see, upvote, and track the status of
  reports — including ones filed from other installs — instead of them
  disappearing into a private inbox. The board connection is baked into
  each release the same way as the update mechanism (`CANNY_API_KEY` /
  `CANNY_BOARD_ID` CI/CD variables, `src/canny-credentials.php` generated
  fresh by `.gitlab-ci.yml`, never committed) — nobody configures anything
  per install. A "View feedback board & check status" link on the page
  opens the public board directly. Screenshots aren't uploaded to Canny
  directly (its API only accepts hosted URLs) — they're saved under
  `public/uploads/feedback/` like other product images and linked in.

## [0.2.26] - 2026-08-31

### Added

- The Updates page now shows a collapsible "What's in this version" /
  "What's new in this version" changelog excerpt next to the current and
  available version numbers, so you can see what a pending update actually
  changes before installing it. The available version's changelog is
  fetched from the same "latest" package coordinates as the version check
  itself (`.gitlab-ci.yml` now also publishes `CHANGELOG.md` there).

## [0.2.25] - 2026-08-31

### Changed

- Further refined the generic platform placeholder icons: Steam's satellite
  ball now has the ringed-dot detail of the real icon, Xbox's "X" sits
  inside a circular outline (closer to the sphere-badge look), and the
  Switch icon distinguishes its two joycons (stick dot on the left,
  button-cluster dots on the right). Still deliberately generic shapes,
  not traced official logos — see 0.2.21 for why.

## [0.2.24] - 2026-08-31

### Changed

- Key import's "N duplicate(s) skipped" message now has a "Show skipped
  duplicate key(s)" toggle listing the actual key values that were
  skipped (whether duplicated within the pasted batch or already present
  for that product+platform), instead of just a count.

## [0.2.23] - 2026-08-31

### Changed

- Refined the generic per-platform placeholder icons (used when no icon has
  been uploaded) to read more clearly as their platform: an orbit/satellite
  shape for Steam, a bold "X" for Xbox, a controller silhouette for
  PlayStation, and a tablet-with-joycons shape for Switch. Still
  deliberately generic shapes, not traced official logos — see 0.2.21.

## [0.2.22] - 2026-08-31

### Fixed

- Uploading multiple large files at once (e.g. several product screenshots)
  could exceed the server's `post_max_size` limit, which makes PHP silently
  discard the entire request — including the CSRF token. That used to
  surface as a confusing dead-end "Invalid or expired form submission" page.
  It's now detected specifically and reported inline on the form you were
  on ("upload was too large for the server to accept in one request..."),
  the same way any other validation error is shown. A regular expired/
  invalid CSRF token now also redirects back with an inline message instead
  of a blank error page.

## [0.2.21] - 2026-08-31

### Added

- Platforms can now have an uploaded icon, shown on the claim page's
  platform tiles and in the Platforms admin list. Deliberately ships
  with **no official platform logos** — checked Steam, Xbox, PlayStation,
  and Nintendo's brand guidelines, and all four require a formal license
  or partnership for commercial use, which doesn't fit a product resold
  to other businesses. Without an upload, each platform falls back to a
  generic placeholder shaped loosely like the real one (matched by slug
  prefix, so every template region variant is covered) — never a real
  logo.

## [0.2.20] - 2026-08-31

### Added

- Claim Links now have an **Edit** action: creator tag, creator group,
  expiry, and the set of products (plus their per-product claim limits)
  can all be changed after creation. The campaign itself stays fixed
  (set at creation, same as before). A product that already has claims
  against it on that link can't be removed, and its limit can't be
  dropped below what's already been claimed — both rejected with a clear
  error instead of silently breaking the link.

## [0.2.19] - 2026-08-31

### Added

- Keys now have an **active/inactive pool** in addition to
  available/redeemed status. Only active-pool keys are claimable —
  imported keys default to inactive so they can be reviewed before being
  released, and an admin moves them to active (individually or in bulk)
  from the new **Keys/Codes → Open** detail page for a product+platform.
  Import Keys has a Pool selector (default Inactive) to import straight
  to active instead when that's not needed. Existing keys were
  migrated to active so already-live claim links keep working exactly
  as before (`sql/migrations/006_key_pool.sql`).
- That same detail page lists every individual key for a product+platform
  (value, status, pool, imported date) and can export just that list as
  CSV — `keys_export.php` now accepts optional `product_id`/`platform`
  filters and includes a Pool column.

## [0.2.18] - 2026-08-31

### Fixed

- **Real root cause of "screenshots don't show up" (and, since v0.2.13,
  anything after the cover image too): nested `<form>` elements in the
  product editor.** The cover image's "Remove" button and each existing
  screenshot's "Remove" button were each their own `<form>` nested inside
  the page's main editing form. Nested forms are invalid HTML; the
  browser closes the outer form early at the first nested `</form>` it
  hits, silently dropping every field that comes after it in the markup
  — Intro/Outro text, new screenshot uploads, YouTube links, and the
  Save button itself never made it into the actual submission whenever a
  product had a cover image or an existing screenshot. Fixed by moving
  both delete actions to small hidden forms placed outside the main one,
  triggered via JS instead of being nested inside it.

## [0.2.17] - 2026-08-31

### Changed

- Import Keys no longer requires picking a campaign — it's optional now.
  `keys.campaign_id` was already never read by key allocation
  (`ClaimService` hands out keys by product+platform only); it's used
  purely for the per-campaign count on Campaigns and the "Campaign"
  column in the keys export CSV, both of which handle no campaign
  correctly (`sql/migrations/005_nullable_key_campaign.sql`).

## [0.2.16] - 2026-08-31

### Fixed

- Release pipeline: cleaned up all stale "latest" packages instead of
  just the first one found, and surfaced the real GitLab error on a
  failed publish instead of a bare exit code. (Republished here to bring
  git history back in line with what installs already picked up — an
  earlier, no-longer-existing `v0.2.16` tag published this same fix
  first.)

## [0.2.15] - 2026-08-31

### Fixed

- Import Keys: a `.csv`/`.txt` file that failed to upload (too large,
  etc.) showed the misleading "Paste a list of keys or upload a file"
  message, as if no file had been chosen at all. Now reports the actual
  upload failure. Checked every other file upload in the app (favicon,
  cover image, screenshots, custom CSS) — this was the only one still
  silently/misleadingly swallowing a real upload error.

## [0.2.14] - 2026-08-31

### Fixed

- A screenshot that failed to upload (wrong format, too large, etc.) was
  silently dropped with no feedback — only the cover image surfaced its
  upload error. Screenshot failures now show the same kind of error,
  naming the file and the reason.

## [0.2.13] - 2026-08-31

### Changed

- Product editor's cover image no longer stays clickable to silently
  replace itself once one is set — it now shows the preview with an
  explicit "Remove" button, and the upload dropzone only reappears once
  the cover is removed. Matches the delete-then-add pattern screenshots
  already use.

## [0.2.12] - 2026-08-31

### Fixed

- The claim page's media showcase (and its preview) showed nothing at
  all for a product with a cover image but no screenshots or video —
  the cover image was only ever used as a list thumbnail. It's now
  shown as a fallback when there's no other media, on both
  `public/product.php` and `admin/product_preview.php`.

## [0.2.11] - 2026-08-31

### Added

- **Preview claim page** on the product editor: shows the real claim page
  for that product (cover/screenshots, intro/outro text, current
  platform availability) without needing an actual claim link — the
  platform tiles aren't clickable and nothing is claimed or revealed.
  `Platform::icon()` (moved out of `public/product.php`, which now calls
  it too) supplies the same tile icons as the real page.

## [0.2.10] - 2026-08-31

### Changed

- Replaced the plain colored dot next to "Key Vault"/the site name in
  every header, sidebar, and auth page with the actual favicon (custom
  upload or shipped default) via a new `Settings::brandIcon()` helper.

## [0.2.9] - 2026-08-31

### Added

- Two-factor authentication is now mandatory: logging in without it
  configured redirects to Settings until an authenticator app is set up,
  for every role except Demo (which can't edit its own account at all,
  so it's exempt rather than permanently locked out).

## [0.2.8] - 2026-08-31

### Changed

- Branding moved from the main nav into the sidebar's collapsible "Admin"
  group and is now restricted to Admin and Master admin (previously any
  logged-in admin could at least view it, Content manager and up could
  edit it). `admin/branding.php` now gates the whole page with
  `Auth::requireMinRole('admin')` instead of just the POST handler.

## [0.2.7] - 2026-08-31

### Changed

- Welcome page heading now always shows "Key Vault" instead of the
  site's custom branding name, matching the "Powered by" footer credit.

## [0.2.6] - 2026-08-31

### Changed

- Renamed the "Mail" admin nav entry (and its page title/heading) to
  "Mail Server".
- Welcome page: "Powered by" now always shows "Key Vault" regardless of
  the site's custom branding name, the business-inquiry note now points
  at "Secret Item Games GmbH" by name instead of "whoever shared this
  page with you", and that note plus the "got a link?" note are grouped
  into one consistently formatted block instead of being split between a
  standalone paragraph and the footer.

### Fixed

- The account-settings sidebar button had uneven padding (top-only),
  making its hover highlight hug the bottom/right edges. Now padded
  symmetrically.

## [0.2.5] - 2026-08-31

### Changed

- Admin sidebar: Settings and Logout now live in a menu that expands from
  clicking the account block (avatar/username) at the bottom, instead of
  always being visible in the main list. Master-admin/admin-only pages
  (Users, Migrations, Updates, Mail) are now grouped under a collapsible
  "Admin" entry in the nav instead of being flat top-level items.

### Fixed

- The "Preview mode" banner on `admin/preview.php` rendered as a tall
  black column on the left instead of a top bar, because it became a
  flex item of `body{display:flex}` (used to center the previewed page)
  and stretched to fill the sidebar's height. Fixed by making it
  `position:fixed` so it sits outside that layout entirely.

## [0.2.4] - 2026-08-31

### Fixed

- Clicking "Update" on the Updates page could report success without
  actually changing anything, if the web server user didn't own the
  install directory: file writes failed silently and were never checked.
  Now reported as a clear failure instead, naming the files it couldn't
  write.

## [0.2.3] - 2026-08-31

### Changed

- Moved the Logout link in the admin sidebar to sit directly above the
  account block (username/role) at the bottom, instead of at the end of
  the main nav list.

## [0.2.2] - 2026-08-31

### Fixed

- Updates page always reported "Couldn't reach the update server": the
  read-only Deploy Token every install uses can authenticate a direct
  package download, but GitLab rejects it on the general packages-list API
  that version checking relied on. Checks and updates now use a fixed
  "latest" package instead, which doesn't need listing.

## [0.2.1] - 2026-08-31

### Added

- Five admin roles instead of two: **Master admin** (everything, was
  "root"), **Admin** (everything a Content manager can, plus managing
  Content manager/Reviewer/Demo accounts), **Content manager** (full
  content access, no user management — what "Admin" always was),
  **Reviewer** (read-only everywhere, including never seeing real key
  values, but can still manage their own account), and **Demo** (same as
  Reviewer, but can't change even their own login details — a stable,
  shareable login for a prospective buyer). Users page (was Admins) now
  shows what each role can do and restricts an Admin to managing only
  Content manager/Reviewer/Demo accounts, enforced server-side.
- **Updates** page can now check for and download updates without the
  deployment directory being a git checkout: it fetches a release zip from
  a GitLab Package Registry "generic" package instead, authenticated with
  a Deploy Token scoped to `read_package_registry` only (never repository
  access). Works automatically with no setup — the connection is baked
  into every release, not something an admin configures.
- New installs no longer seed any platforms — an admin creates the ones
  they actually need from **Platforms** → New platform. A template picker
  offers common ones (Steam/GOG/Epic/Itch, Xbox, Nintendo Switch and
  Switch 2 per region, PlayStation 4 and 5 per region) or "Custom…" for a
  fully custom name.
- Admin file upload fields (favicon, product cover/screenshots, custom CSS,
  key import file) now accept drag-and-drop in addition to click-to-choose.

### Changed

- Renamed the "root" role to "master admin" throughout, including the
  database value (`sql/migrations/003_role_hierarchy.sql` for existing
  installs).
- Renamed the "Keys" nav tab to "Keys/Codes".

## [0.1.1] - 2026-08-30

### Added

- **Mail** admin page (root-only): read-only mail-transfer-agent status
  check, editable From address/name, a "send test email" button, and the
  Postfix install commands surfaced in-app. A `password_reset_enabled`
  setting (on by default) lets a root admin turn off "forgot password"
  entirely on installs without a working mail server, instead of offering a
  reset link that will never arrive.
- The setup wizard has an optional "Outgoing mail" step after the database
  is installed, with the same install commands and a skip-friendly
  "Continue to sign in" button.

### Fixed

- Admins, Campaigns, Creator Groups, and Platforms didn't stack to one
  column on narrow/mobile screens — an inline style was overriding the
  responsive rule that already existed for it.
- A wide table (e.g. Admins' email/role/status columns) could blow out the
  whole page's width on a narrow screen instead of just scrolling itself.
- The Design page's upload row could run a button off the right edge on a
  narrow screen instead of wrapping.

### Security

- Fixed an email header injection: the "From" name fell back to the site
  name, which any logged-in admin (not just root) can set via Branding —
  unsanitized, a CR/LF there could inject arbitrary extra mail headers
  (e.g. a `Bcc:`) into outgoing mail. `Mailer::send()` now strips CR/LF from
  every header-bound value.
- Fixed CSV formula injection in the unused-keys export: a product,
  platform, or campaign name starting with `=`, `+`, `-`, or `@` is now
  quoted, since those names are admin-set and the file can be opened by a
  different admin in a spreadsheet app.
- Removed dynamic admin-controlled text (a git tag name, migration
  filenames) from inline `confirm()` dialogs on the Updates and Migrations
  pages, for consistency with the same precaution already taken elsewhere —
  an embedded single quote can otherwise break out of the JS string once
  the browser HTML-decodes it.

## [0.1.0] - 2026-08-30

### Added

- `.gitignore` to keep `config/config.php` and real files under `public/uploads/`
  out of version control.
- Self-service "forgot password" flow for the admin login: request a reset
  link by username or email, get a one-hour, single-use link emailed to the
  address on file. Sent via PHP's built-in `mail()` (see `install/INSTALL.md`
  for setting up Postfix in send-only mode). New `admin_password_resets`
  table (`sql/migrations/001_admin_password_resets.sql` for existing
  installs) and `mail` config section.
- Admin login now also accepts the account's email address, not just its
  username.
- `public/welcome.php`, a generic landing page shown at the bare domain
  instead of redirecting to the admin login.
- Platforms are now admin-editable under **Platforms** in the sidebar
  (add/remove any platform) instead of a fixed Steam/Xbox/PlayStation/Switch
  list. Deleting a platform warns that its keys will be permanently deleted
  too. The default set is now Steam, Xbox, PlayStation 4, PlayStation 5,
  Switch, and Switch 2. New `platforms` table
  (`sql/migrations/002_dynamic_platforms.sql` for existing installs).
- CSV export of all currently unused keys, under **Keys → Export unused
  (CSV)**.
- A default favicon (`public/assets/favicon-default.png`), shown until an
  admin uploads a custom one under **Branding**.
- Root admins can now apply pending `sql/migrations/*.sql` files from
  **Migrations** in the sidebar instead of needing SSH/`mysql` access. New
  `schema_migrations` table tracks what's been applied; the setup wizard
  marks every existing migration as already applied on a fresh install,
  since `sql/schema.sql` already includes everything they'd add. Each
  pending file also has a "Mark as applied" option, for one that was
  already run by hand before this page existed.
- Root admins can check for and pull code updates from **Updates** in the
  sidebar (shows current vs. available `VERSION`, one-click update) —
  requires the deployment directory to be a git checkout. Database changes
  still go through **Migrations** separately, deliberately not run
  automatically by an update.
- 2FA setup now actually renders a scannable QR code (it previously only
  showed the manual-entry key, despite saying "scan this"), via a vendored,
  dependency-free `qrcode.js` (MIT) running entirely client-side.

### Fixed

- `/setup` (and other top-level pages) now work without the `.php` extension,
  including nested ones like `/admin/login`.
- A brief white background flash when navigating between admin pages.
- Several form fields (email, password, number, date) across the admin panel
  rendered as unstyled white boxes instead of the dark theme.
- The Design page's links were unreadable (browser-default blue) because it
  never loaded the stylesheet that styles them.
- Platform names longer than the original short ones (e.g. "PlayStation 4")
  wrapped onto two lines in the dashboard's "Keys by platform" bars.
- Number input fields' spinner buttons are now recolored to fit the dark
  theme instead of showing the browser's unthemed default.
- The native file-picker button on the Design and Import Keys pages didn't
  match the surrounding buttons in color or size.
- Password reset links could appear "invalid or expired" immediately after
  being requested, due to a PHP/MySQL timezone mismatch between where the
  expiry was computed and where it was checked.
- On the "wrong code, try again" step of 2FA setup, the redisplayed setup
  data was silently generated from an unrelated new secret rather than the
  actual pending one.

## [0.0.0] - 2026-08-30

### Added

- Initial source code: public claim flow, admin panel, setup wizard, and
  database schema.
