Ctrl K
ComunidadGuías…

CHANGELOG.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Changelog

All notable changes to LinkBreeze will be 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]

### Added

- **Add/Edit-link drafts: typed entries survive closing the sheet** — half-filling the link dialog and closing it no longer loses the work: the form's field state (type, title, URL, description, thumbnail, popup body, CTA label, icon mode + pick, card style, section, /go/ slug, advanced disclosure) is debounce-written (300ms) to `sessionStorage` under one key per dialog target (create-per-page, edit-per-link), and reopening restores it behind a "Draft restored" chip with a one-click Discard back to defaults. The draft's lifetime is exactly the browser session by design: the tab closing clears it automatically, sign-out clears it explicitly (both sign-out forms), and a successful save discards it — a second browser tab never sees the first tab's draft. Known limits: freshly picked icon files can't persist (browser `File` objects don't serialize — the stored custom icon URL round-trips fine), and the advanced UTM/schedule date inputs stay per-open. Title/URL/description/thumbnail/CTA fields became fully controlled inputs to make restore exact (URL still displays the UTM-stripped form for edited links). No schema or server changes; drafts are read back defensively (versioned JSON, silent drop on parse failure). Verified live end-to-end on a throwaway instance: restore, discard, save-clears, edit-draft and sign-out-clears, plus the full 1754-test suite, lint/tsc/i18n gates.

- **Markdown text cards: clickable `[text](url)` hyperlinks, numbered lists and `---` dividers (#93)** — popup card bodies now support inline links (http(s)-only hrefs — `javascript:`, `file:`, `data:` and any other scheme render as plain text; anchors carry `target="_blank"` + `rel="noopener noreferrer nofollow"`; markers like `**bold**` work inside link labels), numbered lists alongside bullet lists, and thematic-break dividers. The link dialog's popup-text field gained a live preview pane rendering the exact body the public popup will show, a markdown syntax hint bar, and a character counter; long popup bodies now scroll internally (max 60vh) instead of stretching the dialog. Same subset in the privacy-page renderer and the zero-JS public card renderer, with scheme-safety tests.

- **A/B testing for links (#81)** — Any URL-type link can split its traffic between two destinations: enable the test in the link dialog, set variant B's URL plus optional labels and a traffic weight (default 50/50), and optionally box the run with start/end schedule fields (outside the window the link serves variant A only, clicks stay unlabeled). /go/:id assigns visitors deterministically from the existing cookieless visitor hash — sticky per visitor across visits, works with zero client-side JavaScript, and requires no cookie or storage a visitor could tamper with. The link detail page shows per-variant clicks, share, lift percentage and an honest confidence hint that refuses to call a winner below 100 clicks (binomial goodness-of-fit at ~95%), with Pause/Resume and one-click Promote A/B behind a confirmation — promoting pins the winner as the permanent destination and archives the test with its full history (labels, weights, per-variant click rows all kept; the short URL and QR keep working since they point at /go/:id). A new Settings → Data toggle ("A/B test auto-promotion", off by default) lets the instance promote significant winners automatically at 200+ clicks — same math as the UI hint, never mid-window, never below the sample floor. The clicks CSV export gains a variant column while a test is or was active; variants survive backup/restore, and a restored backup can never resurrect a finished test into a live split. Walkthrough with worked example numbers: [docs/ab-testing.md](docs/ab-testing.md).

- **Native vCard "Save Contact" links with per-page Contact card settings** — the vCard link type no longer asks for a URL: it resolves to the page's own `/api/vcard/<slug>` route automatically, so "Add link → vCard → save" is all it takes. The generated card auto-fills from what the page already knows — title (FN/NICKNAME), bio (NOTE), avatar (embedded as PHOTO URI), and every social link (X-SOCIALPROFILE) — and a new **Settings → General → Contact card** section adds optional overrides (organization, job title, email, phone, address) that win over the auto values when set. The hardcoded `ORG:LinkBreeze` default is gone (ORG is emitted only when configured), the footer "Save contact" share button and the link type share the same builder, and validation accepts the URL-less form everywhere (server actions, REST API, migration wizard). Ships as migration `0035_vcard_settings` (five nullable columns, additive).

- **Website (globe) social platform** — the social link picker now offers a "Website" entry with a globe icon (feather-style stroke set, same family as Instagram/Email/RSS), so a page can lead its social row with a link to the owner's main site before the platform icons. Recognized in existing saved `social_links` JSON, rendered by both the server (zero-JS public page) and the admin picker.

- **Public REST API v1 (#4)** — LinkBreeze now has a programmatic surface: generate a single API key in Settings -> Security -> API access (plaintext shown once, sha256-hashed at rest, regenerate = instant rotate, revoke supported), then manage links (list/create/update/delete/reorder), the profile, and analytics through `/api/v1/*` via the `Authorization` header (HTTP Bearer scheme). Rate limited per key (120 req/min plus a per-IP pre-auth guard), demo-mode-blocked mutations, and the same URL-scheme validation as the admin UI. Every instance serves its own OpenAPI 3.1 document at `/api/v1/openapi.json` and an auth-gated HTML reference at `/api/v1/docs`. A router-drift test keeps the spec and the routes in exact agreement. The surface also covers single-link reads (`GET /links/:id`), sections CRUD + reorder, and bulk import (`POST /links/import`, 1-100 items with per-item results — one bad entry never aborts the batch). List endpoints support cursor pagination and sparse fieldsets; read endpoints send strong ETags, so pollers replaying `If-None-Match` get empty 304s until data changes. Rotation supports an opt-in migration grace (default 1 hour, configurable up to 24 — the previous key stays valid until the window closes or you revoke), and the admin card shows today's activity counts plus a live grace countdown. Response headers are machine-documented in the spec (X-RateLimit-*, Cache-Control, ETag, WWW-Authenticate, Retry-After), and the README cookbook (11 languages) includes conditional-GET and bulk-import examples.
- **Multi-user accounts with customizable roles (#124)** — per-account public pages; admin create/enable/disable/delete with last-admin guard; editable roles (page limits, visibility, capability checks now enforced on every instance-level action); role-scoped admin surface; single-use invitation links (SHA-256-hashed, 7-day expiry) accepted at `/invite/<token>`; append-only admin audit log at `/audit` with filters, cursor pagination and 90-day retention; per-role/per-user session lifetimes with action-based idle timeout and password re-entry for destructive admin actions.
- **Generic OIDC / OAuth2 single sign-on (#124)** — sign in through any standard identity provider (Authentik, Keycloak, Authelia, Pocket ID…) via discovery: authorization-code + PKCE (S256), single-use expiring state, JIT provisioning, account linking, optional SSO-only mode. Group → role mapping (first-match, configurable claim) with the last-admin invariant; configurable username/email/name claims; env-only client secret; https-only issuers with the `LINKBREEZE_ALLOW_HTTP_ISSUER` escape hatch for trusted-LAN providers (which now actually works). The profile page gains a **Single sign-on** card: linked identities are listed per account, "Connect SSO" binds the current account through the standard authorize flow, and unlinking is self-service (two-click confirm, lockout-safe copy) with an `sso.unlinked` audit entry.

### Changed

- **"Add link" dialog reworked into a left-docked sheet with a live card preview** — the centered modal becomes a mobile-first side sheet: below `md` it slides up as a bottom sheet (full width, rounded top, grabber handle), at `md+` it docks to the left at full height; the consumer passes the width (640px, 980px expanded). The form restructures into type-first chips (11 link types), full-width title/URL, paired fields (description + thumbnail, card style + section) and a single "Advanced" disclosure (slug, icon, UTM, A/B editor, schedule) with a sticky footer (Featured/Active toggles + Cancel/Save) under an aurora logo-mark header. An eye toggle expands the sheet to 980px with a live 1:1 card preview pane that renders through the same `buildLinkCardHtml`/`buildDividerHtml` the public page runs, with the page's real theme tokens resolved from the active theme; auto-icon mode resolves the preview's favicon from the DuckDuckGo icon service for the typed URL (same upstream as the server's favicon chain). On the links manager, section headers get a proper GripVertical drag handle and a violet folder badge, the page header stacks its three actions as icon-only buttons on phones, and mobile link rows collapse their inline pencil/trash/analytics/open icons into a ⋮ actions menu (Edit/Analytics/Open/Delete) so title, description and URL get the row's width back — descriptions now render on rows. The mobile bottom dock gains a Theme tab (Dashboard/Links on the left, raised Live-preview circle centered, Theme/Profile on the right), the new `advanced`, `preview` and `moreActions` strings land in all 11 locales, and the login form no longer sets `autoFocus` during SSR (a hydration attribute diff); focus moves through an effect instead. Failed saves keep every typed entry: the sheet's form opts out of React 19's automatic post-submit reset of uncontrolled inputs, so a server validation error (a missing CTA URL, say) shows inline while all fields keep their values — verified end-to-end against a throwaway instance replaying the exact text-popup repro.
- **Admin sidebar rework** — the admin sidebar and mobile navigation are rebuilt: active sections carry an aurora highlight, the instance pages (Domains, Accounts, Audit log) fold under a Settings group whose items are App (the settings page), Domains, Accounts and Audit log, and the desktop sidebar collapses to a Frappe-style icon rail with tooltips (state persists per browser). The mobile bottom bar becomes a balanced dock — Dashboard and Links on the left, a raised aurora Live-preview circle in the center, Theme and Profile on the right — replacing the nine-slot tab bar whose labels overlapped on phones; the mobile top bar keeps a Settings gear whose drop-down carries the same shared group (App/Domains/Accounts/Audit) plus sign-out, so the header stays two slots (pages switcher + settings). An update-available chip in the sidebar footer deep-links to the updates card.
- **Dependency updates (#131, #132, #133, #135)** — merged four Dependabot PRs: `systeminformation` 5.33.15 → 5.33.19 (fixes Dependabot alert #73, high), `@hono/node-server` 2.0.12 → 2.1.5 (fixes alert #72, medium), `actions/checkout` v6 → v7 across the workflows, and the #135 minor-and-patch group (`lucide-react` 1.61.0 → 1.65.0, `shadcn` 4.22.1 → 4.23.0, `@types/node` 26.6.5 → 26.6.6, `sql.js` 1.15.1 → 1.15.2). All CI build jobs green.
- **joi override raised to ^17.13.9 (fixes Dependabot alert #75, high)** — the existing override range already allowed the patched release; the lockfile just predated it. Refreshed to 17.13.9 (transitive via `passkit-generator`, no API changes). The remaining two alerts need no action today: minimatch (#74, medium) is a dev-only eslint/ts-morph chain where v11 is a major upstream hasn't adopted, and deepmerge (#71, low) has no patched release yet.
- **Dependency updates (#136, #137)** — merged two Dependabot PRs: the #136 minor-and-patch group with nine updates, headlined by `next` 16.4.3 → 16.4.5 — the November 2026 security release fixing seven moderate advisories, including cross-client response injection in self-hosted apps that forward fetched responses (the migration wizard's fetch path) and HttpOnly cookie exposure through internal response headers on edge routes; the group also carries `eslint-config-next` 16.4.3 → 16.4.5, `lucide-react` 1.65.0 → 1.70.0, `otpauth` 9.5.2 → 9.6.0, `vitest` 5.0.5 → 5.1.0, `@playwright/test` + `playwright-core` 1.64.1 → 1.65.0, and `tailwindcss` + `@tailwindcss/postcss` 4.3.3 → 4.3.4. #137 raises the directly declared `domhandler` 5.0.3 → 6.0.1 across its ESM-only major: the only in-repo usage is the type-only `AnyNode` import in the migration wizard, so runtime behavior is unchanged and cheerio keeps serving its own nested ^5 copy. All CI build and Lighthouse jobs green on both.

### Fixed

- **Failed form saves no longer erase typed entries (React 19 auto-reset)** — forms submitted through `<form action={fn}>` get every uncontrolled input reset by React 19 when the action resolves, success or failure, so a server-side validation error silently wiped whatever the operator had typed (reported on the new-page form and the login step, same root cause as the Add-link sheet fix folded into its entry above). Submission is now manual (`onSubmit` + `preventDefault`) on every affected surface — new-page creation, change-password, both 2FA code forms, the login and TOTP steps, all settings tabs plus the profile form (Save button and blur-autosave path alike), and the migration wizard's file picker — keeping native `required`/`pattern` bubbles and Enter-key submit while failed saves preserve typed values. Controlled-input forms (retention, import URL) and the public email capture (documented no-JS POST path) are intentionally unchanged. Verified by the full 1754-test suite plus a CI-equivalent empty-database run under Node 22, lint/tsc/i18n clean.
- **Single sign-on card aligned with the profile page** — the SSO identities card rendered outside the profile column's centered `max-w-2xl` container (full-bleed width, glued to the form with no spacing); it now shares the same centered column and gap as every other profile card. On phones, the unlink-confirm cluster (warning text + Unlink/Cancel) wraps to its own full-width row inside the identity card instead of squeezing off-card, and the connect/unlink rows are wrap-safe with no horizontal overflow at 390px.
- **Accounts listed once per account** — the accounts list joined every page a user owns, so an account with multiple public pages appeared once per page: tripled rows in the UI and duplicate React keys in the console ("Encountered two children with the same key"). The query now returns exactly one row per account, with the page slug resolved to the account's primary page (default page first, then alphabetical).
- **Admin live preview iframe console warning** — the preview iframe carried `sandbox="allow-scripts … allow-same-origin"`, a combination that makes the sandbox escapable on same-origin content (already flagged as such in the #124 hardening) and logged a Chrome console warning on every open. The attribute is gone — the preview loads the instance's own page, so the sandbox never bought anything.
- **Satoshi font glyph warnings cleaned** — the bundled Satoshi woff2 files carried stale glyph bounding boxes (an upstream Fontshare defect), so Chromium logged a "glyph bbox was incorrect; adjusting" console warning for every rendered weight. Bounds are recalculated in place with fontTools — identical outlines, corrected metadata — and the admin console is clean.
- **Members can no longer write analytics/custom-code page fields (#124 hardening)** — `analyticsScript` and `customCss` are raw code rendered into the public page, but `updatePageAction` accepted them from any signed-in account. Under multi-user, a member setting a script on their own page could run same-origin code inside an admin's PreviewPane iframe (whose sandbox combination is escapable) — a member-to-admin XSS path. Both fields are now instance-admin-only at the action boundary; the settings UI already hid them from members, and members editing their own pages are unaffected.
- **Repo hygiene: declared directly imported transitive dependencies in package.json** — `domhandler` (type-only, via `cheerio`), plus `axe-core` and `playwright-core` (dev, via the Playwright toolchain) are imported directly by source/e2e code but only existed as transitive deps; they are now declared explicitly so upstream reshuffles cannot break the imports. Also removed a legacy `(admin)/page.tsx` that resolved to `/` but was silently shadowed by the multi-page root redirect since #47, and a dead `dynamic` route-config export from the "use client" new-page form.

- **Lighthouse CI workflow repaired** — the scripts-privatization commit left a seed step whose `run:` line was commented out but whose step shell remained, and a step with neither `run:` nor `uses:` invalidates the entire workflow file: every push to `main` failed at startup with zero jobs and no logs. The dead step is gone and CI now seeds through the app itself: the bootstrap admin is created via the first-run `/api/setup` endpoint and the default public page gets title/bio content, so Lighthouse audits a real profile page again instead of the setup redirect.
- **Audit log action labels now resolve through i18n** — the audit table's action dictionary used flat keys containing dots (`account.created`, `role.assigned`, …), which next-intl rejects as invalid, spamming the browser console with `INVALID_KEY` errors and silently falling back to raw keys. The dictionary is now properly nested across all 11 locales (translations preserved), so `action.account.created` and friends resolve to their translated labels.

- **Stalled link saves on automatic icons** — the favicon fetcher cleared its timeout as soon as response headers arrived, so a destination that sent headers promptly but stalled the body mid-stream hung `readBodyWithCap` forever: the save server action never returned and the link was never written. The timeout now stays armed until the body read finishes (same for the OG-image download), the per-fetch timeout is bumped 6s/7s → 10s, and the whole icon phase (every strategy, candidate, and the retry) is capped at 12s so a save can never block longer than that.
- **SSO redirects behind Host-rewriting proxies (#124)** — `redirect_uri`, the post-login redirect, and error bounces were derived from request headers, so behind a proxy that rewrites Host (Pangolin, some nginx setups) sign-in bounced the browser to an internal address. `BASE_URL` now wins at all three sites (same rule as QR/vCard/OG).
- **SSO session cookie dropped by the callback redirect (#124)** — the cookie was attached in a way a self-constructed redirect response discarded, so the flow finished signed out. Caught by a live round-trip against a mock IdP (`e2e/mock-oidc-idp.mjs`).

## [1.5.1] - 2026-10-27

### Added

- **Admin UI and README in Italian, Russian, Turkish, and Japanese** — four new languages, bringing the admin to 11 and the README to 11 translations: the Settings language dropdown, /login, /setup, the 2FA step, and the global error screen all switch (Russian uses the formal register standard for Russian software; Japanese drops ICU plural forms by design, as Chinese already does). Public pages stay English by design. Translated by AI agents and verified by a terminology-review pass; as with all machine-drafted locales, a native-speaker read before relying on a locale in production is worth it (docs/TRANSLATIONS.md policy).
- **Catalog packaging for CasaOS, Runtipi, and Unraid (`packaging/`)** — LinkBreeze now ships first-party app-store packaging as canonical sources under `packaging/`: a CasaOS App Store app definition (`packaging/casaos/`, pinned image, icon, screenshot, en_US metadata), a Runtipi v4 app-store repository (`packaging/runtipi/`, to be published as `OmniRise/LinkBreeze-Runtipi-Store`), and an Unraid Community Applications template repository (`packaging/unraid/`, dockerMan-format XML plus repository profile and MIT license, to be published as `OmniRise/LinkBreeze-Unraid-Templates`). All three pull the official `docker.io/omnirise/linkbreeze` image with a pinned semver tag, mount the data volume, and need no environment configuration (the setup wizard handles first run). The packaging policy is documented in CONTRIBUTING.md: these three catalogs are maintained by the project; other platforms and hosting-provider deploy buttons stay community-PR territory. Per-release maintenance is one step: bump the pinned version in the three manifests during the release fold.
- **Update status card in Settings (Data tab) (#123)** — A new card shows the installed version next to the latest release from GitHub and from the website, with a status line ("You're on the latest version." or "You can update to vX.Y.Z." with release-notes link and the pull command), a Recheck button that bypasses the 24h cache, last-checked time, and a "last known value" note when a source is unreachable. The GitHub source is a new unauthenticated latest-release check (24h-cached like the website poll); the dashboard update banner is unchanged. This was announced in the v1.5.0 release notes but its merge landed just after the `v1.5.0` tag was cut — it actually ships in this release.

### Changed

- **Dependency updates (#126, #127)** — Merged two Dependabot minor-and-patch groups: #126 (`lucide-react` 1.59.0→1.61.0, `shadcn` 4.22.0→4.22.1, `sql.js` 1.15.0→1.15.1) and #127 (`next` 16.4.2→16.4.3, `eslint-config-next` 16.4.2→16.4.3, a bug-fix backport). Both merged with green CI and byte-exact lockfiles. Also bumped the `fast-uri` override ^3.1.7→^3.1.9 and added a `body-parser` ^2.4.0 override, resolving Dependabot alerts #66–70 (both packages are transitive through the shadcn CLI's express/MCP SDK chain, dev-tooling only). `node-forge` (#58) stays unchanged: no patched release exists, and `passkit-generator` uses it only for pass signing, never verification.

- **Locale parity gate in CI** — CI now runs the locale hash gate (`npm run i18n:check`) and a generalized `scripts/locale-audit.mjs` across all ten locales, checking key parity, ICU placeholder parity, and untranslated-string suspects. The audit previously covered only French and Spanish and ran manually, so a broken `{placeholder}` in another language could reach main; its ICU handling was also corrected (it stripped plural placeholders, which would falsely flag Japanese and Chinese — both correctly drop ICU plurals — as mismatches).

- **Language lists reordered: English first, then alphabetical** — one convention everywhere languages are listed: the admin language dropdown (picker order), the README translation rows in all eleven READMEs, `docs/TRANSLATIONS.md`, and the website locale lists (routing, sitemap, hreflang alternates) now read English · العربية · 中文 · Français · Deutsch · Italiano · 日本語 · Português (Brasil) · Русский · Español · Türkçe instead of historical add-order.

- **Canonical repo URLs point at OmniRise/LinkBreeze** — All living documentation (README ×7, CONTRIBUTING, SECURITY, TROUBLESHOOTING) now references `github.com/OmniRise/LinkBreeze` instead of the pre-transfer `Manak-hash/LinkBreeze` URLs. The old links redirect, so nothing was broken; this makes the canonical location explicit and consistent with the Docker Hub namespace, the website, and the app-store listings.

### Fixed

- **Unthrottled SSO endpoints (#124)** — the OIDC start action (10/min/IP — each call writes a state row and fetches discovery) and callback (30/min/IP) are now rate-limited like the local login; the state expiry check gained a 30s clock-drift leeway.

- **Fresh API installs booted without an admin (#124)** — `/api/setup` created the first account with the default Member role, so API-installed instances had no working administrator (the setup wizard already passed the admin role). Both bootstrap paths now agree, with a regression test.

- **New accounts could stay invisible in the admin list (#124)** — creating an account right after another save (e.g. a role) hit Next's client router cache and the list kept its stale payload even though the account existed; the create flow now reloads the page after the save settles.
- **The update checker's GitHub fallback still queried the pre-transfer repo** — the update-check module called `api.github.com/repos/Manak-hash/LinkBreeze/releases/latest`; the canonical-URL pass in this release covered documentation only, and this one code path kept the old name. GitHub's rename redirect kept it working, but the checker now queries `OmniRise/LinkBreeze` directly.

- **Local `npm run test` failed on the TOTP lifecycle test (#128)** — the enroll → activate → verify → disable lifecycle hashes 8 recovery codes with bcryptjs at cost 12 (single-threaded JavaScript: ~7.9s measured on modest dev hardware), well past vitest's 5-second default, so every local full-suite run died on `Test timed out in 5000ms` while CI's faster runner squeaked under and kept main green. The test now carries an explicit 20s budget with a comment; the hashing strength (cost 12) is untouched. Diagnosed as budget, not regression: the clean tree failed identically and the same run passed at a 30s timeout.

- **Importing a Linktree page pulled Linktree's own affiliate links instead of the account's** — the migration wizard's `__NEXT_DATA__` extractor recursively searched the entire Next.js payload for any `{url, title}` array and swallowed `props.pageProps.statsigInitValues`, a statsig feature-flag blob whose sponsorship list is Linktree's global affiliate catalog (an identical ~26-item array on every profile: Hulu, HelloFresh, Headspace, and more). Importing any Linktree URL returned those promo links and buried or dropped the account's real links, and client-rendered mega-accounts returned only the promo list. The extractor now reads Linktree's real link containers first (`pageProps.links` / `socialLinks` and their `account.*` mirrors), the recursive walk skips config subtrees (`statsigInitValues`, `googleAdConfig`, blog teasers), and the inline-script tier no longer re-parses `__NEXT_DATA__` (which was re-introducing the blob). Client-rendered accounts now correctly report "No links found on this page" (use the File tab) instead of the affiliate list; Bento, Hopp, and Beacons extraction is unchanged, and regression tests cover the blob, the empty-SSR case, and the non-Linktree fallback path.

- **Migration wizard's import button and social chip were hardcoded English** — the confirm button (Import N items) and the fallback label for an unrecognized social profile (Social) rendered in English regardless of locale; both now come from the locale dictionaries across all ten non-English languages, with correct per-language ICU plurals (Russian four-form, Arabic six-form, Japanese and Chinese plural-less by design).

### Security

- **Update-check server actions skipped the demo guard (#128)** — the three update-check actions (the settings Recheck, the update-notification toggle, and the dashboard banner's refresh) checked the session but never called `demoGuard()`, unlike every other mutating action. On a public `DEMO_MODE` instance — where `DEMO_AUTO_LOGIN` hands every visitor a session — any visitor could flip update checks for all demo users and bypass the 24-hour cache on demand, driving uncached outbound fetches to the GitHub and website endpoints from the host server. All three now guard first and throw the standard read-only-demo message before touching the session or the network, covered by a new action test.

## [1.5.0] - 2026-10-24

### Added

- **@ in page slugs (requested by @stuff-by-rene)** — Page slugs can now optionally start with an @ (`/@churchname`), the handle style people know from other link tools. Opt-in per page: slugs without @ behave exactly as before, `/go/` redirect slugs stay letters-and-hyphens, and an @-slug can never shadow an app route (it is always a different string from every reserved name). Percent-encoded forms (`%40`) resolve to the same page.
- **Language selector redesigned as a compact dropdown** — The row of seven pill buttons on /login, /setup, the 2FA step, and the Settings language card is replaced by one shared dropdown: a small pill showing only the current language that opens a panel listing every locale with its endonym, English name, and locale-code chip. The panel is keyboard-navigable (arrows/Enter/Escape), flips upward when the trigger sits low on the page, follows the trigger on scroll, and renders above cards and modals. No locale-file changes — new languages appear in it automatically.
- **Custom domains for pages (#85, reported by @einlichtvogel)** — Bind your own domain names to pages, one instance serving many: each page can own a primary domain plus aliases (e.g. `jane.com` + `jane.io` + `www.jane.com`), and visitors land on the bare domain to see the page — no `/slug`. The new **Domains** admin section manages bindings with per-alias behavior (serve / 301 to the primary domain / 301 to the page slug) and per-page **scope**: *exclusive* (the domain IS the page — other slugs 404) or *hub* (page at the root, every slug still resolves). An instance-wide routing policy card controls the primary host (also via the `LINKBREEZE_PRIMARY_HOST` env var), a **strict admin host lock** (404s admin/login/setup on non-primary hosts — anyone can point a domain at your server; your login page never serves there), unknown-host fallback (path routing / 404 / redirect to primary), implicit www handling, and the canonical strategy (301 slug→domain by default, canonical-tag-only, or off). Bindings flip from *pending* to *active* on their first real request — DNS propagation becomes self-verifying — with a manual activate-now toggle and a DNS-record checker for pre-flight testing. SEO is first-class: canonical/og URLs point at the domain root, sitemaps list every active identity, and slug URLs 301 to their domain. Zero behavior change for instances that never bind a domain (migration `0028_custom_domains` adds one empty table + one nullable column); full reverse-proxy recipes (Caddy on-demand TLS, nginx, Traefik) in `docs/custom-domains.md`. All new UI ships in all seven locales.

- **OG auto card redesigned as a "Poster Hero"** — The auto-generated social card no longer floats small text in an empty frame: big centered identity (170px avatar or theme-colored initial, 82px name, full-opacity bio) plus the page's top four link titles as themed pills with a "+N more" tail, so shares preview the actual content. The real LinkBreeze mark sits top-left and the two-tone wordmark top-right. Everything derives from the page theme (text/primary/secondary colors, all ten background types incl. photo backdrops with the theme overlay) via the new satori-safe `withAlpha` color helper and `selectOgLinks` picker — verified on dark, light, warm-gradient and aurora presets.

- **Automatic scheduled backups (#83)** — A "Scheduled backups" card in Settings → Data turns on app-internal backups with no cron required: daily/weekly/monthly frequency plus a preferred time of day (runs at the first activity on or after it), keep-last-N retention (default 7), and the exact same JSON format as the manual export so restore is the proven path — stored backups restore through the identical validated transactional flow, with date/size listed and one-click restore/delete behind the same confirmation dialogs. Backups write atomically to `./data/backups` (env-overridable via `BACKUP_DIR`), a disk-space guard (`BACKUP_MIN_FREE_MB`, default 50) skips writing and reports instead of failing, and concurrency rules hold: never two backups at once, never a backup mid-restore. The trigger is opportunistic on page traffic and admin loads, off by default, and a dismissible dashboard notice (re-arming only on the next failure) reports failed runs. No migration — configuration lives in the existing settings table.
- **Custom /go/ redirect slugs (#96)** — Links can now carry an optional custom redirect slug alongside their numeric ID, so a shared, printed, or embedded redirect URL survives delete-and-recreate: `/go/my-consulting` instead of `/go/42`. Slugs are lowercase `[a-z0-9-]`, unique, up to 64 characters, with a reserved-word blocklist (`login`, `setup`, `dashboard`, `links`, `settings`, `api`, `sitemap.xml`, `robots.txt`, and all pure numbers) so they can never shadow app routes. The link dialog gains a slug field with live availability checking, deleting a link releases its slug for reuse, and a deactivated link that has a slug redirects visitors to its public page instead of a bare 404. Numeric `/go/:id` URLs keep working unchanged, and backup restore sanitizes invalid or duplicate slugs rather than rejecting the file.
- **Official OmniRise Docker Hub image (`omnirise/linkbreeze`)** — LinkBreeze is moving under the OmniRise brand with an official Docker Hub presence as the default public pull path. The release workflow now publishes per-platform images to both Docker Hub and GHCR and assembles both manifest lists from the same content-addressed digests (one build, two registries, byte-identical images), and the dry-run mode still builds without pushing anything. First published tags: `latest` and `v1.4.1`, multi-arch `linux/amd64` + `linux/arm64`, anonymous pulls verified working.
- **One-shot update script (`update.sh`)** — Self-hosted instances on any install method can now update with `curl -fsSL https://linkbreeze.omnirise.dev/update.sh | sh`. The script finds the existing container (by name or by image), backs up the data volume to `~/.linkbreeze/backups/<timestamp>/`, pulls the current image, and recreates the container with the same port mapping, volumes, environment variables, and restart policy — then prints the new version, the backup path, and a ready-to-paste rollback command. Tested end-to-end against both a standard named-volume install and a bind-mount compose install: data, env vars, and ports all survive; the script warns and continues if the backup itself fails, never touching the data volume until the new image is pulled.
- **Update banner shows the exact update command** — The "new version available" banner previously linked to release notes only, leaving operators to figure out the update steps themselves. It now displays the copy-paste command (`docker pull omnirise/linkbreeze:latest && docker restart linkbreeze`) plus the one-shot `update.sh` URL right in the banner, next to the release-notes link.
- **Social preview (OG) images — theme-following or your own upload** — Every page now renders a 1200×630 share card at `/<slug>/opengraph-image`. Auto mode draws the card from the page's live theme through a pure resolver (`src/lib/og-theme.ts`): every background type gets a faithful static rendering (multi-stop gradients at the theme's angle, radial at the same focal point, mesh as layered radial stops, image/gif backdrops with the theme's overlay, aurora as base color + accent glows, video as the loading-state gradient), plus the page avatar and title in the theme's text/link-card styling — change the theme and the share card changes with it, no re-upload. Custom mode serves an uploaded image directly as `og:image`, with the shared fit/focus/zoom picker (adjustment-only, non-destructive) and a live in-card preview; crawlers' aggressive caching is called out in the UI with guidance to re-validate. The card is managed from its own autosaved card in Settings (mode toggle saves instantly, uploads pair with custom mode in one action). Migration `0027_og_image` adds six nullable `pages` columns (`og_image_mode/url/fit/pos_x/pos_y/zoom`) — existing installs boot unchanged and default to auto.

### Changed

- **Dependency updates (#117, #118, #119, #120, #121)** — CI: `actions/download-artifact` 7 → 8. npm minor-and-patch group (16 packages): `react`/`react-dom` 19.3.1, `lucide-react` 1.59.0 (#119: 1.58.0, #120: 1.59.0 — icon render-contract test green, full suite passing), `shadcn` 4.22.0 (#120, dev CLI), `hono` 4.13.13 (#121), `next-intl` 4.14.9, `passkit-generator` 3.7.0, `drizzle-orm` 0.45.4, `zod` 4.6.7, `@base-ui/react` 1.9.0, Playwright 1.64.1 + axe 4.14, `drizzle-kit` 0.31.11, `eslint-config-next` 16.4.2, `sql.js` 1.15.0, `vitest` 5.0.5 — no breaking changes.

- **Install script and update checks served from linkbreeze.omnirise.dev instead of raw.githubusercontent** — The one-line installer lives at `linkbreeze.omnirise.dev/install.sh` (marketing site, proper `text/x-shellscript` content type), and the update checker polls `linkbreeze.omnirise.dev/latest-version.json`. Both previously depended on `raw.githubusercontent.com`, which is unreachable during any GitHub account visibility issue — instances v1.2.0–v1.4.0 polled a 404 and silently never saw update banners; this change restores the notification chain for every install from this release forward (older installs pick it up when they next update). The website-served `latest-version.json` is the source of truth for update checks, and its `url` field points at the canonical GitHub release, so the update banner link stays live even if the mirror lags — the GitLab mirror remains a passive pull copy.
- **All documentation and examples reference the official registries** — Every README translation (7 languages), TROUBLESHOOTING, docker-compose.yml, the demo compose file, all seven example compose files, and kubernetes.yaml now use `docker.io/omnirise/linkbreeze:latest`. The README gains a "Repositories & Registry" section (GitHub, GitLab mirror, Docker Hub) with the Docker badge linking to the Hub page, and the Quick Start adds the direct `docker run` alternative alongside the install script. README translation hashes re-synced (`docs:check` green).
- **Source mirrored to GitLab (`gitlab.com/omnirise00/linkbreeze`)** — A public pull mirror (8h interval) of the canonical GitHub repository, giving LinkBreeze a second independent forge home. README's new registry section lists GitHub as canonical with the mirror second; all user-facing links (website CTAs, update banner, structured data, docs) point at GitHub.
- **Settings → Appearance reorganized around visuals** — The social preview (OG) card moves from General to Appearance (it is page branding, not SEO text). The tab now leads with a theme showcase card that shows ONLY the active theme — a token-true mini preview (same renderer as the /theme preset gallery), name, active badge, and color swatches — with a "Change theme" button that expands a grid of visual previews for the rest; picking one saves immediately and collapses the grid. The favicon uploader gets its own prominent branding card with a large live preview, and Custom CSS moves into its own container at the bottom of the tab (with its slug set on legacy single-profile installs, where the flush previously omitted it). All new UI strings ship in all seven locales.
- **Admin sidebar logo links to the website** — The LinkBreeze logo + wordmark at the top of the admin sidebar (and the mobile header) now open `linkbreeze.omnirise.dev` in a new tab, with a hover tooltip ("Visit linkbreeze.omnirise.dev") so the affordance is discoverable.

### Fixed

- **Test suite robustness: backup-store date bomb and timezone-fragile scheduling test** — `backup-store` "lists only backup artifacts" hardcoded a newest-fixture stamp (2026-10-19T03:00Z): every run after that instant — local and CI — sorted the freshly written timestamp-stamped backup ahead of the fixture and failed. The listing test now clears live-stamped artifacts before asserting, so the fixtures control ordering regardless of the wall clock. The `backup-schedule` "monthly uses 30-day steps" test built its before/after instants by millisecond arithmetic off the September slot; in timezones with a DST transition inside the 30-day window (Africa/Casablanca +01→+00 on Oct 4) that landed an hour off the local 03:00 slot and flipped the result. It now constructs the before/after instants from local calendar dates, and passes in UTC and +01 alike.

- **Page changes update the social preview immediately** — The Appearance tab's OG card preview cached the rendered share card: its cache-buster only tracked save counters, so changing the theme, display name, bio, or avatar kept showing the stale card until a manual reload. The preview URL now carries a server-computed hash of every page input the card renders (theme, title, bio, avatar, og fields) — any edit anywhere re-fetches the freshly rendered card through the `router.refresh()` that every autosave already triggers.
- **Settings tabs survive a refresh** — The active tab lived in component state only, so refreshing (or reopening) `/settings` always landed back on General. The tab id now rides in the URL hash (`/settings#security`), survives refreshes and shared links, and composes with the `?page=` page switcher.
- **Avatar, banner, and favicon uploads now persist** — Uploading through the profile avatar/banner buttons or the Settings favicon uploader only updated the on-screen preview: the new URL lived in a controlled input (or hidden field) whose value the autosave flush never read, so the upload silently reverted on the next page load unless an unrelated field was edited afterwards. Every uploader now flushes the same debounced autosave path one frame after the value commits — uploads behave exactly like typing. The legacy (no-pageId) custom-CSS flush additionally set the required `slug` field, which it previously omitted.
- **Setup wizard and settings slug validation actually validates** — The slug inputs' HTML `pattern` attributes (`[a-zA-Z0-9_-]+`) are invalid under the `v` flag browsers now compile patterns with, so Chrome silently dropped the pattern and logged a `SyntaxError` in the console on every setup/profile render: any slug passed client-side until the server rejected it. The dash is now escaped (`[a-zA-Z0-9_\-]+` — the only v-mode-valid form, verified against the regex engine), the wizard's full three-step flow was re-driven end-to-end in a real browser on a fresh install (invalid slug rejected client-side, zero console errors), and the same fix was audited across every `pattern=` attribute in the app.

### Security

- **Argon2id password hashing (#76)** — Admin passwords are now hashed with Argon2id (OWASP parameters: m=19456 KiB, t=2, p=1) instead of bcrypt (12 rounds). Existing bcrypt hashes keep verifying and are transparently re-hashed with Argon2id on the next successful login or password change. This also removes bcrypt's silent 72-byte input truncation, which dropped meaningful content from multi-byte passwords. New dependency: `@node-rs/argon2`, shipped in the multi-arch image for both amd64 and arm64.
- **Custom-domains hardening (breach-test findings)** — Host routing now trusts only the raw `Host` header; `X-Forwarded-Host` is honored solely behind the new `LINKBREEZE_TRUST_X_FORWARDED_HOST=true` env flag (for proxies that overwrite it, like nginx/Caddy). Previously a client-supplied `X-Forwarded-Host` — which header-forwarding proxies such as cloudflared pass through verbatim — could bypass the strict admin host lock (login/dashboard/setup reachable on any host), defeat the unknown-host 404 fallback, and flip a pending domain binding to active without its DNS ever resolving. The pending→active flip now additionally requires the connecting `Host` header to match the binding, so a spoofed forwarded host can never mint an "active" domain. With the unknown-host fallback set to *404*, the canonical 301 no longer fires on unknown hosts, so bound slugs stay hidden (the fallback wins). On exclusive domains, `/api/vcard/<slug>` and the Google/Apple wallet pass routes now serve only the bound page's slug — an exclusive domain IS one page, and no longer hands out other pages' artifacts. `/api/themes/export` and `/api/totp-qr` join the admin host-lock list. Behavior is unchanged for instances that never bind a domain and for the default fallback setting.
- **Server-action and public-route hardening (full-app breach test)** — A whole-repo audit closed the remaining gaps the custom-domains pass didn't cover: `toggleUpdateCheck`/`refreshUpdateCheck` and `dismissBackupNotice` were callable with no session at all (anyone could disable update checking or hide the "backups are failing" dashboard signal) and are now session-gated; `checkDomainDnsAction` is now demo-guarded, so the public demo no longer doubles as a DNS-resolution oracle; the QR, vCard and wallet routes build their encoded/canonical URLs from the raw `Host` header (same rule as the domain-routing proxy, same `LINKBREEZE_TRUST_X_FORWARDED_HOST` opt-in) instead of preferring a spoofable `X-Forwarded-Host` that could steer QR codes to an attacker's host; and CSV exports (analytics + subscribers) now neutralize formula injection — analytics referrers are attacker-controlled via the Referer header, and a `=HYPERLINK(...)` cell would execute when the admin opens the export in a spreadsheet.
- **Import-path, setup and 2FA hardening (whole-app audit)** — The migration wizard's confirm step re-runs the URL scheme allowlist over every imported link and social profile (and the structured `__NEXT_DATA__` extractor filters schemes at extraction): a crafted import file or hostile source page could previously store a `javascript:` URL that rendered as a live link on the public page. Theme-file import and backup restore now run every theme row through the same field schema as the save path (colors, media URLs with https/http/site-relative-only, enums, position formats) instead of accepting raw strings — theme fields are interpolated into the public page's CSS, so a crafted community theme could inject arbitrary CSS. Setup mints the first admin through an atomic insert-with-count-recheck (two concurrent POSTs on a fresh instance could previously create two admin rows), onboarding enforces the reserved-slug blocklist (slug `login` used to make the public page unreachable), the login/TOTP `?from=` parameter only follows site-relative paths, manual backup restore takes the backup lock (a scheduled backup could snapshot a half-restored database), favicon fetches enforce real response size caps, and `/api/themes/import` caps its parse at 2MB. `disableTotp` no longer fails open: when the TOTP secret is undecryptable (e.g. after a SECRET_KEY rotation) disabling now requires a recovery code — hashed independently of the TOTP secret — instead of accepting any well-formed string. TOTP confirm is rate-limited like the login step, the pending setup cookie is cleared on success and marked Secure, and stale `pv` sessions no longer trigger a cookie write during page render (which threw in Next 15/16 and could lock every admin page including the login screen after a password change on another device).

## [1.4.1] - 2026-09-23

### Added

- **Live preview follows every edit; autosave on the forms it watches** — The admin preview pane previously lagged one save behind: its iframe cached the page, so you had to save, then hit refresh, to see the last-but-one state. The pane now cache-busts every reload (`?v=<Date.now()>`, including the manual refresh button), and a single shared `useSavedAction` hook drives every admin save (profile, links and section dialogs, delete confirmations, drag-to-reorder, settings tabs, theme fork/customize, migration wizard, new-page flow) so a successful action always refreshes the router data AND reloads the preview in one uniform path. Forms the preview watches autosave as you type — debounced 600ms, deduped against the last-saved baseline so re-renders don't re-POST identical state: the theme editor (custom themes only; presets keep the explicit fork/duplicate dialog), the profile identity fields and social links (adding a chip, typing a URL, or removing one all schedules the same save), and the Settings General, Integration, and Appearance tabs (analytics, consent, footer, SEO text, custom CSS; the email-capture and share switches still save instantly on toggle, theme chips on click). A brief "Saving… / Saved" note appears where the old button sat while a flush is in flight, and the autosaved forms dropped their now-dead Save buttons and footer strips entirely — the profile/slug field stays manual on purpose (autosaving a half-typed slug would break the public URL), and the cards that keep an explicit button are exactly the ones autosave must never touch: the page settings dialog, link/section dialogs, migration confirm, security/2FA, and anything destructive. Security forms are exempt by design: typing in the password/2FA fields fires zero requests (verified in a live browser with the request log and the bcrypt hash asserted unchanged).
- **Non-destructive image positioning for avatar, banner, and background** — Every image surface gains fit/focus/zoom adjustments computed at render time, never baked into the uploaded file: repositioning is reversible and re-uploading is never needed to fix a crop. The three surfaces share one picker component that auto-opens right after an upload, shows the image in a frame with a live `X · Y · zoom` readout, supports drag-to-reposition, wheel-to-zoom over the image, fit Cover/Contain chips, and a reset — and persists via the same debounced autosave path as the rest of the profile form. Internals: migration `0025` adds twelve nullable `pages` columns (`avatar_/banner_/background_` × `fit/pos_x/pos_y/zoom`; the background trio is a per-page override of the theme values), a pure resolver (`src/lib/image-adjustments.ts`) turns NULLs into pixel-identical output so every existing page renders exactly as before, zoom scales `background-size` (the contain branch emulates it via a percentage height/width pair), video backgrounds take fit+position only (no zoom on a moving asset), and the picker's debounced saves survive re-render storms because `useSavedAction` returns a stable identity now. The focal-point/position metadata round-trips through backup/restore like the rest of the page row.
- **Live preview keeps its scroll position across reloads** — Every autosave reloads the preview iframe, and a fresh document always starts at the top, so editing a link near the bottom of a long page while scrolled down meant the preview jumped back up on every keystroke's flush. The pane now records the iframe's scroll offset continuously (a passive scroll listener on the frame's window, stored in a ref that survives the remount-the-iframe reload strategy) and re-applies it after each reload finishes — clamped to the new document's scrollable height so a change that shortens the page lands at the bottom, not in a void. The manual refresh button restores position too; only switching pages or closing the pane resets it.

- **Share & wallet exports: vCard, Google Wallet, Apple Wallet** — Every public page can now show a share block, **opt-in per page** via a new "Share block" toggle in Settings → Integration (off by default). "Save contact" is a universal vCard 4.0 download (no configuration): name, handle, page URL, bio and org fields, RFC 6350-compliant (CRLF line endings, 75-octet folding, full value escaping) and hardened against adversarial input — card-boundary injection via embedded newlines, control characters, and traversal-style filenames are all contained, covered by a dedicated security test suite. Beyond the vCard, operators can enable "Add to Google Wallet" and "Add to Apple Wallet" buttons by bringing their own credentials as environment variables (see the new `.env.example` section and the Configuration table in the README): Google needs a free issuer ID + service-account JSON, Apple needs the five `APPLE_WALLET_*` variables from a paid Developer account. Both wallets render stateless static passes — a business card carrying the page identity, a QR that always opens the live page, and a snapshot of the current links on the back ("Saved on" labeled; passes are frozen at install by design, no push infrastructure). Google passes are issued just-in-time as RS256-signed JWTs via `node:crypto` (no new JWT dependency); Apple passes are built with `passkit-generator` and served with the exact `application/vnd.apple.pkpass` MIME type iOS requires. The routes are read-only (the demo instance shows the feature), rate-limited like the QR route, and 503 when credentials are absent — the buttons themselves only render when the matching credentials exist, verified both ways in live testing. A new "Wallet & contact exports" card in the same Settings tab shows per-wallet credential status (never secret values) and warns before the Apple signing certificate expires — the silent-expiry failure mode being the classic "passes suddenly stopped working" support trap. Testing: 60 tests acros... [truncated]
- **Public page footer rhythm rework** — The bottom of every public page (email capture, share block, footer/Privacy) is now wrapped in one flex-column container (`.lb-footer-zone`) whose `gap` owns all inter-element spacing — per-element `mt-*` margins that collapsed unpredictably and changed shape as features toggled on/off are gone, and every spacing value derives from `calc(var(--lb-spacing) * N)` so each theme's density setting scales the whole rhythm. Optical symmetry pass: "Save contact" sits with matching space above and below (the divider pairs it with Privacy at an even 42px beat), Privacy's space below now matches its space above, and the page container uses asymmetric top/bottom padding (`pt-12 pb-6`) so the footer's padding no longer doubles into a dead zone under Privacy. Measured from rendered pixels (headless-Chromium screenshots + row-brightness scan), not just class names.

- **Admin UI in Brazilian Portuguese (#111, contributed by @WillxRv)** — Complete `pt-BR` dictionary ships with full key parity alongside en/fr/es/de/zh/ar, and Português (Brasil) joins the Settings language picker. `pt-BR` was a pre-registered T3 slot in the locale registry: the BCP-47 plumbing (the `localeTag` map, `LOCALE_NAMES` endonym, `LOCALE_HTML_LANG`, and both chart date-format switches) was already wired for exactly this entry, so the diff activates the reserved tier rather than growing the registry. The dependency-free global error boundary carries inline pt-BR copy alongside the other six locales, so the crash screen renders in-language even when the i18n layer itself caused the crash. The e2e i18n spec gains a pt-BR login case, `README.pt-BR.md` translates the full README with language-nav links added across all seven existing translations, and `docs/TRANSLATIONS.md` tracks it. ICU plural forms are used where counts appear; Latin digits per the i18n policy.

### Changed

- **Dependency updates** — Merged Dependabot's npm minor-and-patch group (#113): `lucide-react` 1.45→1.47, `next` 16.3.4→16.3.5, `next-intl` 4.14.3→4.14.5, `tailwind-merge` 3.6→3.7, `zod` 4.6.2→4.6.5, `@types/node` 26.5→26.6, `eslint-config-next` 16.3.5, `vitest` 5.0.1, plus the joi override below which clears both Dependabot security alerts.

### Fixed

- **Cleared text fields no longer revert on reload** — Deleting the page title, SEO title/description, footer text, privacy policy, custom CSS, analytics script, bio, or a profile URL and letting autosave fire looked like it saved — but a refresh brought the old text back. `updatePageAction` read every text field as `value || undefined`, so an empty submission was indistinguishable from an absent one and hit the "leave unchanged" path; the old value stayed in the database and the revalidated page re-rendered it. The action now uses true tri-state semantics (`formData.has()` = write the value, even empty; absent = leave unchanged), with nullable columns canonicalizing `""` to NULL. Verified in a live browser across all affected fields: fill → autosave → delete → autosave → reload still shows the cleared state.
- **Dependabot alerts #26/#27: transitive joi prototype pollution** — `passkit-generator` pins `joi` 17.13.4, vulnerable to two prototype-pollution advisories (`object().rename()` with a template target can set the validated object's prototype; a `__proto__` language key in custom messages pollutes `Object.prototype`). joi only validates pass.json shapes inside the library — never attacker-controlled input before validation — so exposure was low, but an npm `overrides` entry now forces `joi@^17.13.6` (resolves 17.13.8, API-compatible), and `npm audit` reports zero vulnerabilities.
- **Social platform picker offered the same X logo twice** — The platform grid listed both "X (Twitter)" and "X" as separate choices, but both rendered the identical X glyph, so the picker showed what looked like a duplicate button (and a saved "twitter" link plus a saved "x" link could both appear in the list). `twitter` is now a recognized alias rather than a listed platform: the picker offers a single X entry, URL detection maps twitter.com and x.com to it, old links saved with the legacy `twitter` platform id keep rendering and editing correctly, and the labels/`getSocialIconSvg` switch treat the two as one.
- **Code scanning: media URL scheme allowlist (alerts #20/#21) and test-file escape pattern (alert #19)** — The avatar, banner, favicon, and background image URLs ultimately land in `<img src>`, `<video src>`, and a CSS `url('…')`. They are now validated by a shared `safeMediaUrl` schema rule at the save boundary: only `https://`, `http://`, or site-relative `/path` URLs are accepted, so `javascript:`/`data:` URIs can never reach a src attribute (this also closes the CodeQL `js/xss-through-dom` alerts by construction). Separately, a vCard security test used a partial escape pattern (`replace(/;/g, "\\;")`) that CodeQL's `js/incomplete-sanitization` flags — the fixture now escapes via a character class covering backslash and semicolon together; production vCard escaping was already correct (backslash first) and is unchanged.
- **Settings saves cleared a page's social links (#112, reported and fixed by @WillxRv)** — Saving any settings tab other than Profile (General, Integration, Appearance, or the QR card) silently rewrote the page's social profiles to an empty list. `socialLinks` was the only page field with both a `.default("[]")` in the `updatePageSchema` and a `|| "[]"` fallback in the action, so every settings form — which never submits the field — ended up persisting an empty array over the real data. The field is now parsed only when the submitting form actually includes it (`formData.has("socialLinks")`, mirroring the pattern the `emailCapture` checkbox already used for the same class of problem), and `updatePage` writes only the keys that survive the undefined-strip, so an omitted `socialLinks` leaves the column genuinely untouched. The profile form still updates social links as before — including clearing them, since it explicitly submits `"[]"` when the list is emptied. No migration; two new unit tests cover both paths (omitted field preserved, explicit field persisted).
- **Links page drag-to-reorder dropped upward moves on the floor** — Dragging a link up onto the one above it silently snapped it back to its original position, and longer downward drags landed one slot short of the target. `handleDragEnd` measured the dragged link's origin in the full item array but the drop target's position in the array with the link already removed, so the before/after comparison was off by one in one direction. Both indices are now taken from the same coordinate space; verified live across all four drag directions with UI and database order asserted after each move.

## [1.4.0] - 2026-09-14

### Added

- **Two-factor authentication — TOTP (#5)** — The admin login can now require a second factor: a 6-digit code from any authenticator app (Google Authenticator, Authy, 1Password, …). Setup lives in Settings → Security: scan the QR (rendered server-side from an `otpauth://` URI stashed in a 10-minute cookie — the secret never appears in a URL), or paste the secret manually, then confirm with one live code. Recovery codes (8, bcrypt-hashed, shown exactly once, no lookalike characters, rejection-sampled against modulo bias) unlock a lost authenticator; each works once and is consumed on use. Disabling 2FA — or re-enrolling — requires a current code and invalidates every trusted device by construction. "Trust this device for 30 days" skips the code step on a per-browser basis (token bound to user + secret + User-Agent, statelessly verified against the stored secret); re-enrollment kills all trusts. The secret is stored **encrypted** (AES-256-GCM, key HKDF-derived from `SECRET_KEY`) and backups structurally exclude it — the backup exporter never reads the `users` table, so a restored backup de-facto disables 2FA (the safe direction; documented in SECURITY.md). Migration `0023` adds three nullable `users` columns; existing installs are untouched and a half-finished setup never locks anyone out (the secret stays inactive until a live code confirms it). TOTP attempts reuse the login rate limiter (5/min/IP + 15/min global).
- **arm64 container images (#105, reported by @ThisIsBenny)** — The GHCR image is now a multi-arch manifest list: `linux/amd64` and `linux/arm64`, so it runs natively on Raspberry Pi 3/4/5 (64-bit Raspberry Pi OS), Apple Silicon, and ARM VPS/instances. The release workflow builds each architecture on its own native GitHub runner (no QEMU emulation — `ubuntu-24.04-arm` for arm64), pushes per-platform images by digest, and a follow-up job merges the digests into one manifest list carrying the same `latest` and `<version>` tags as before — the `docker run`/compose instructions are unchanged for everyone, including existing amd64 deployments. The app code and Dockerfile needed no changes: `better-sqlite3@13` ships an arm64/musl prebuild, and the build stage already compiles the native module from source when one is missing. A `workflow_dispatch` trigger now allows building both architectures without publishing (push steps are event-gated), so CI changes can be exercised between releases. Non-goal, on purpose: 32-bit `arm/v7` boards — Node still publishes the base image, but the installed base is shrinking and 1GB-class boards sit below comfortable Node memory; revisit only if a user asks with hardware in hand.
- **Divider element (#87, reported by @welface)** — A new "Add divider" button next to Add Link inserts a themed horizontal separator between links in one click; drag it into place like any link, including inside sections. The line itself is styled per theme, not per link: a new Divider section in the customizer's Links tab controls line style (solid / dashed / dotted / a gradient fade through the theme's own accent), line color (empty = inherit the theme's card border, so dividers match every theme out of the box), thickness (1–8px) and width (20–100%), with a live line preview and a mock divider in the phone visualizer. Public pages stay zero-client-JS: the divider is a `role="separator"` div rendered entirely from `--lb-divider-*` tokens (same reveal-animation chain as cards), with no anchor, no tracking beacon and no URL — the schema allowlist for the `divider` type is empty by design, so a hand-edited backup can never smuggle a live URL in through one. Dividers hide the URL/description/thumbnail/icon fields in the link dialog (a hint points styling to Theme → Links). Migration `0022` adds four `themes.divider_*` columns additively (defaults reproduce the pre-divider look: 1px solid card-border line at full width); every existing theme renders exactly as before. Backup/restore and theme export/import round-trip all four fields, with old exports/backups defaulting to the inherited look.

- **Admin UI in German, Chinese (#87, reported by @welface), and Arabic** — The admin speaks three more languages: complete `de`, `zh` (Simplified, mainland conventions), and `ar` (Modern Standard) dictionaries ship with full key parity alongside en/fr/es, and Deutsch / 中文 / العربية join the Settings language picker. German uses the informal *du* register matching the Spanish dictionary's tone; Arabic carries proper six-form ICU plurals (zero/one/two/few/many/other) where counts appear; Chinese drops plural scaffolding the language doesn't have. Arabic flips the whole admin shell to `dir="rtl"` (the registry already knew `ar` → RTL — the layout honors it), with Latin digits locked everywhere per the i18n policy (`-u-nu-latn`). The dependency-free global error boundary carries inline copy for all three (fixing a French word — "conteneur" — that had leaked into the Spanish error copy since 1.3.1), the BCP-47 tag map gains `de-DE` (zh → zh-CN and ar → ar-MA already existed), and the dictionary loader now type-checks that every available locale has an entry. The e2e i18n spec gains de/zh/ar login cases, including an RTL assertion for Arabic. `de` is a new registry entry (T2); `zh` and `ar` were pre-registered T3 slots now promoted to available.

- **Search engine visibility toggle (#94, reported by @gdyys96b56-web)** — A new "Search engine visibility" card in Settings → General with one switch: visible (default, everything as before) or hidden. Hiding asks search engines to stop listing the public page using the strongest combo available without Search Console removal requests: the page keeps loading normally but now sends `noindex, follow` (it stays crawlable on purpose — a robots.txt Disallow would stop Google from ever reading the noindex, and the bare URL could still surface as a blocked entry; the issue thread's "not bulletproof" point is exactly why), the sitemap drops the page (empty urlset, and robots.txt drops its `Sitemap:` line so crawlers aren't pointed at a sitemap that lists nothing), and admin routes stay disallowed as always. Flipping back restores everything instantly — sitemap.xml and robots.txt are now explicitly `no-store` (the old `/:slug*` s-maxage=60 catch-all would have served a stale "visible" robots/sitemap for up to a minute after toggling). The footnote in the card is honest about the one limit: already-indexed pages can take weeks to disappear, and hiding relies on search engines honoring the request. Settings backup/restore carries the flag automatically (settings rows round-trip wholesale).

- **Resizable profile picture (ported by @einlichtvogel)** — Themes can now size the avatar like fonts: a new "Avatar size" slider in the customizer's Profile tab (48–180px, with an Auto chip that restores the default look). The resolver emits a `--lb-avatar-size` token consumed by the public header (both the image and the first-letter fallback scale; ring borders keep the outer box at the theme's diameter) and by the customizer's live visualizer. Defaults preserve the current look exactly: absent or `"auto"` renders the historical 94px box (90px image + 2px ring padding), values outside 48–180 clamp, and the Zod layer rejects non-numeric garbage on save. Migration `0021` adds `themes.avatar_size` additively (default `"auto"`); every existing theme renders exactly as before. Backup/restore and theme export/import round-trip the field, with old exports/backups defaulting to `"auto"`.

- **Popup cards: text and location (#93)** — Two new link types join the picker. A **text card** opens a native `<dialog>` with a markdown body (bold, italics, headings, lists, links, inline code — rendered by a new escaped-first string renderer) and an optional CTA button; a **location card** opens the same dialog with a keyless embedded Google map (full-bleed, ~16:10, lazy: the iframe's `src` only attaches when the visitor opens the dialog — no third-party contact until then) and a baked-in "Open in Google Maps" CTA that redirects through `/go/:id` so clicks count like any outbound link. Public pages stay zero-client-JS: opening is one inline `onclick` calling `showModal()`, the same inline-handler pattern as the click-tracking beacon. The operator can paste anything into the location field — a raw address, a full `google.com/maps/place/...` URL, or a mobile `maps.app.goo.gl` share link (short links are followed server-side at save time, 4s bounded) — and the card stores one canonical Maps URL that both the embed and the directions link use. Blank CTA labels on location cards bake the admin's locale default at save time. Migration `0019` adds `links.popup_text`/`cta_label` and `analytics_clicks.event_type` additively; every existing link renders exactly as before.

- **Separate card font (reported by @einlichtvogel)** — Themes can now give link cards their own typeface, distinct from the site font. The customizer's Typography section gains a "use a different font for cards" toggle: off (default) keeps cards on the site font exactly as before; on reveals the same bundled + uploaded font chips for cards only. The resolver emits a `--lb-card-font` token (`inherit` when off), every card variant applies it via `font-family: var(--lb-card-font, inherit)` (the popup-card `<button>`'s `font:inherit` shorthand was replaced with family/weight longhands so the card font survives), theme export embeds both fonts' bytes (once when they're the same file), import restores each ref (with a shared-ref cache so one uploaded font used by both doesn't restore twice), deleting an uploaded font resets card-font refs to inherit, and the live preview + public pages load both fonts' `@font-face` rules. Migration `0020` adds `themes.card_font_family` additively (default `""` = inherit); every existing theme renders exactly as before.

- **Card color pickers get an opacity slider (reported by @einlichtvogel)** — The card background and card border swatches were disabled outright because their values are rgba() strings the native color input can't represent; picking a hue now strips alpha for the picker and re-applies the existing alpha on the way out, and a compact opacity slider (0–100%) joins the two card color fields so translucency is finally adjustable without hand-editing CSS.

- **Popup analytics: opens without polluting CTR** — Opening a popup card beacons a new `open` event type through the same `/api/track` route. Every existing stats widget (total clicks, top links, clicks-per-day, previous-period deltas, per-link stats) filters `event_type = 'click'`, and the denormalized `clicks_count` only increments on real clicks — so opens are stored for a future breakdown view while CTR stays pure.

- **Popup CTA button = the subscribe button** — The CTA in both text and location popups is now dressed in the email-capture subscribe button's exact recipe: the same class stack (`lb-gel-btn` + `lb-pixel-clip lb-pixel-shadow`) and the same inline token styles (`--lb-accent` background, `--lb-btn-text` color, theme border/radius), with the pixel stepped-corner rule extended to cover anchors as well as buttons. The two buttons can't drift apart across the glass/neon/pixel/gel skins.

### Changed

- **Dependency updates** — Merged 2 Dependabot PRs: the npm minor-and-patch group #106 (@base-ui/react 1.7.0→1.8.0, lucide-react 1.37.0→1.41.0, next 16.3.3→16.3.4, next-intl 4.14.1→4.14.2, shadcn 4.19.0→4.21.0, zod 4.5.2→4.5.4, @types/node, @types/react-dom, eslint-config-next 16.3.3→16.3.4) and **vitest 4.1.11→5.0.0 (#107, first major bump)** — verified locally before merge on the full gate set: 771/771 tests pass on v5 with zero test-file changes (the repo's `maxWorkers: 1` threads config was already v5-clean; the default-mocks-cleared breaking change touches no existing suite), lint 0 warnings, tsc clean, 0 vulnerabilities. Then hono 4.13.0→4.13.7 (#108, lockfile-only), which closes all three open Dependabot security alerts (#21, #22, #23 — patched upstream in 4.13.5); none were exploitable in LinkBreeze, which never imports hono directly. Finally sharp 0.35.3→0.35.4 and js-yaml 4.3.1→4.3.2, closing Dependabot alerts #24 (js-yaml CPU exhaustion via empty merge sources) and #25 (sharp/libheif, high); sharp is used by the QR logo compositing path, js-yaml arrives transitively via cosmiconfig/eslint — full gate set re-run after the bump: 794/794 tests, lint 0 warnings, tsc clean, build clean, `npm audit` 0 vulnerabilities. The minor-and-patch group #110 (react + react-dom 19.2.8→19.3.0 with @types to match, zod 4.5.4→4.6.2, next-intl 4.14.2→4.14.3, @playwright/test 1.62.1→1.63.0, @types/node 26.4.1→26.5.1, lucide-react 1.41.0→1.45.0) rides on react 19.3 and needed one compat fix: lucide-react 1.45 moved the raw icon node the inline SVG serializer reads (`props.iconNode` → `props.icon.node`), which silently downgraded every picked lucide icon to the letter-avatar fallback — the serializer now reads both shapes. Full gate set re-run after the bump: 794/794 tests, lint 0 warnings, tsc clean, build clean.

- **Popups are responsive bottom sheets on phones** — Under 520px the popup dialog becomes a full-width bottom sheet (the pattern map and share sheets use natively): top-rounded only, safe-area-aware CTA margin, map capped at 40vh so it never eats the sheet, and a dynamic-viewport (`dvh`) height chain with a `vh` fallback so the mobile URL bar can't clip the close row. The slide-up animation respects `prefers-reduced-motion`.

### Fixed

- **Live preview covered the admin instead of docking beside it (reported by @einlichtvogel)** — Opening the preview pane anchored a fixed overlay to the right edge, so whatever sat under it — the right half of Settings forms, theme customizer fields, dashboard cards, link rows — was visible but unreachable. The admin shell now reserves the pane's exact width (360px at `lg`, 400px at `xl`) as padding while it's open, so every admin page (dashboard, links, theme, profile, settings) resizes to sit beside the preview instead of underneath it; the whole shell slides over with a short transition and slides back on close. The phone stays docked at the right edge as before, mobile keeps its full-screen sheet, and the fixed aurora backdrop is untouched (viewport-fixed, so no seam).

- **Imported links never resolved their favicons (#95, reported by @gdyys96b56-web)** — The migration wizard's confirm step called the raw `createLink` query (a plain DB insert) instead of the icon-resolution path manual saves use, so imported links were written with no `iconUrl` at all and rendered the letter placeholder until someone re-saved them — exactly the "import breaks icons, re-save fixes them" repro from #91. The import now resolves favicons like the link dialog does, with three guards: saves run in bounded batches of 3 instead of one unbounded `Promise.all` (with icons wired in, that burst would trip upstream rate limits and timeouts), the favicon chain retries once after a short backoff before falling back to the letter (covers transient failures on manual saves too), and the wizard's done screen reports how many links fell back to a letter icon so the operator knows which ones to re-save.

- **Typewriter display-name animation (reported by @einlichtvogel)** — Two defects made the animation look broken: the blink keyframe animated the opacity of the whole heading, so after typing finished the entire name pulsed on and off forever instead of a caret blinking, and the reveal width was sized in `ch` units (the width of the "0" glyph), which clipped or overshot the text with every proportional font and left the name off-center in centered layouts. The reveal now runs on `clip-path: inset()` percentages — exact for any typeface — with the caret as a separate absolutely-positioned element tracking the reveal edge, blinking alone; both honor `prefers-reduced-motion` (name shows fully, no caret).

- **Pasted Google Maps URLs didn't render a map (#93)** — `mapEmbedSrc` only understood the `?query=` parameter our own constructed URLs carry; real pasted Maps URLs (`/maps/place/Name/@lat,lng`, bare `/@lat,lng`, `/maps/search/...`, `maps.google.com` hosts) produced no iframe at all. The parser now handles every URL shape Google actually emits, with coordinates winning over names (exact pin, original zoom preserved) and `http` pastes upgraded to `https`. The location field now also accepts mobile share short links (`maps.app.goo.gl`, `goo.gl`, `g.co`) by following them server-side at save time.

- **The embedded map was blocked by the page CSP** — The Content-Security-Policy `frame-src` allowlist covered the embed hosts (YouTube, Spotify, Vimeo, Soundcloud, Bandcamp) but not Google Maps, so the iframe rendered as "This content is blocked. Contact the site owner to fix the issue." Both hosts the keyless embed needs (`maps.google.com`, which then serves tiles from `www.google.com`) are now allowed.

- **Popups sat in the top-left corner on desktop, laptop, and tablet** — Tailwind's preflight resets `margin` on every element, which silently killed the native `<dialog>` centering (modal dialogs center via `position: fixed` + `margin: auto`). The dialog now restores `margin: auto` explicitly and is pixel-centered on every viewport; phones were unaffected because the bottom-sheet styles set margins already.

## [1.3.2] - 2026-08-24

### Added

- **Spanish admin UI (es)** — The admin speaks a third language: a complete `src/locales/es.ts` dictionary (700 keys, tuteo register, terminology aligned with the Spanish README) ships alongside en/fr with full key parity, and Español joins the Settings language picker. `lb_locale=es` now flows end to end — proxy → request header → next-intl provider — with the critical error boundary carrying its own inline Spanish copy. The e2e i18n spec gains an es case, and `README.es.md` is registered in the docs translation track (`docs/TRANSLATIONS.md`) so CI flags it when the English README moves.

- **Set a default page and keep it first (#90, reported by @baptleduc)** — Every page switcher row now has a star: click it on any page to make that page the default, confirmed by a toast. The default page always sorts to the top of the switcher, the links page, the theme page, settings, and the dashboard, so it stops wandering as you add pages. The switcher's closed button shows a filled star next to the default page's name — the outline star stays an action, the filled one an indicator, same shape language as the rows. Flipping the default is a single transaction: the old default is cleared and the new one set together, and the star moves with you to the page you just promoted. The default page can't be deleted from the switcher (it's the fallback target when other pages are deleted with their links kept); its delete button is hidden rather than disabled-then-explained. The public root `/` redirects to the default page as before — the concept finally has a face in the admin.

- **Per-link icons: auto, picked, or uploaded (#91, reported by @gdyys96b56-web)** — Links are no longer stuck with whatever favicon a site serves (or none, for email, phone, WhatsApp, and vCard links). The link dialog has a compact new Icon section: a live preview chip (it shows the favicon sparkle, the picked icon, or the uploaded thumbnail) beside a one-line Auto / Pick / Upload segmented control, styled after the card style picker above it:
  - **Auto** keeps today's behavior: fetch and cache the site's favicon when the link is saved. The old separate "Auto icon" switch is gone — the segment is the toggle.
  - **Pick** opens the same categorized icon gallery sections use — search, categories, live highlight — and stores the choice as data, not an image: the public page renders the icon as an inline SVG that inherits the theme's text color, so it matches every theme and card style for free. Manual picks stop refetching favicons on every URL edit.
  - **Upload** takes a PNG, JPG, GIF, WebP, AVIF, ICO, or SVG up to 512 KB. Files are validated by magic bytes, not by their name — a renamed PNG is rejected — and SVGs pass through a sanitizer that strips scripts, event handlers, foreign objects, and external references before the file is ever stored. Uploads land in the same volume as custom fonts and are served same-origin with `nosniff` and a locked-down CSP.
  - Saving without picking a new file keeps the current icon; switching back to Auto returns to favicon behavior. The admin link list shows picked and uploaded icons too, and errors (file too large, unsupported content) now surface inline in the dialog instead of failing silently.
  - Migration `0018` adds the two new columns additively; every existing link keeps exactly the icon it renders today. The previously stored-but-never-rendered emoji field is repurposed to hold the picked icon's name.
- **A tighter Add Link dialog (#91)** — The dialog grew a new Icon section, so everything around it got denser without losing meaning: Card Style shrank from three-line tiles with radio dots to a one-line segmented control with mini glyphs (a compact icon row vs. a rich thumbnail block); the separate "Auto icon" switch is gone (the icon mode is the toggle); Description and Thumbnail share one row; Type and Section share one row; and Featured / Active / UTM / Schedule sit as a 2-column toggle strip instead of four stacked rows. The full dialog now fits in 697px — inside a 90%-height capped, scrollable dialog on any screen.

### Fixed

- **Locale dictionary drift** — A key-parity audit caught four keys referenced by components but missing from `en.ts` (`setup.allSet`, `settings.integration.emailCapture`, `settings.security.updated`, `settings.qr.styleLabel`) — English users saw raw key paths in the setup wizard, integration tab, password form, and QR card; French had drifted the other way with an extra `emailCapture` and no `consentText`. All four are restored in both languages, the raw `Enabled`/`Disabled` strings beside the email-capture switch are now translated, two broken English source strings are repaired (`Choose a settings` → `Choose a starting point`, `You call it later` → `You can change it later`), and 74 dead keys are pruned from en/fr. `npm run i18n:check` and `npm run docs:check` hashes re-stamped.

- **Upload failures were silent** — The link dialog discarded failed action results without a message. A rejected icon upload (wrong format, too large) looked like the button ignored you. The dialog now shows the action's error inline above the footer.

- **Uploads never reached the server** — The icon file input carried no field name, so the selected file was dropped from the submitted form data. The dialog showed the filename, the save succeeded, and the icon silently never arrived. Caught live against the running app; unit tests build their form data directly and never exercised the real input.

- **Long dialogs stranded their buttons** — Dialogs taller than the viewport (the link dialog with its new Icon section on short screens) pushed Save/Cancel off-screen with no way to scroll. The shared dialog container is now capped at 90% of the viewport height and scrolls its body.

- **Uploaded files were served under the page CSP** — The public page CSP rule also matched `/api/uploads/*`, overriding the stricter headers the uploads route sets for itself. Uploaded files now always ship with `default-src 'none'`, closing the gap between the route's intent and what actually hit the wire.

- **Settings → Integrations crashed with INVALID_MESSAGE** — The analytics hint embedded a literal `<script>` tag inside a rich-text translation message, which next-intl's message parser rejects (MALFORMED_ARGUMENT) whenever the Integrations tab renders. The hint now words it without the bracket literal and the tab opens cleanly.

- **docs-stale workflow lacked a permissions block (code scanning #18)** — The docs-stale workflow ran with the repository's default token permissions, wider than its read-only job needs. It now declares `permissions: contents: read`, same least-privilege block the other workflows already carry.

## [1.3.1] - 2026-08-20

### Added

- **Admin UI in English and French** — The whole admin panel (dashboard, links, theme customizer, settings, migration wizard, setup, onboarding, profile, dialogs, toasts, error screens) now renders fully in French. A language picker in Settings switches instantly; the choice persists per user (cookie + database) with no URL prefix and no page reload juggling. English remains the default; the public link pages stay English by design so shared URLs and SEO never shift with the admin language.
  - Messages live in typed TypeScript dictionaries (`src/locales/en.ts`, `fr.ts`) — a missing key or a renamed one fails at compile time, not in production. A hash gate (`npm run i18n:check`) refuses to build when the two files drift apart, and a nightly-style sweep script (`scripts/i18n-sweep.mjs`) catches untranslated literals before review.
  - Dates, number grouping, and plurals are locale-aware: charts label months in French ("19 août"), counters group the French way ("1 234"), and subscriber counts inflect correctly ("1 abonné", "3 abonnés").
  - Server-side errors translate too: failed actions (upload, import, save) return message keys that render in the active language instead of English strings from the wire.
  - French docs ship alongside: `README.fr.md` (linked from the README language switcher) and `docs/TRANSLATIONS.md` for contributors adding the next languages — Spanish is staged next, then Chinese, Hindi, Arabic (RTL), and Portuguese.
  - Accessibility and i18n e2e suites cover the switch: login renders French under the cookie, unknown locales fall back to English, and axe scans pass in both languages.

- **Custom theme fonts (#82)** — Upload your own woff/woff2 (max 2 MB) from the customizer's Typography tab and pick it for any theme, exactly like a bundled font. Four new bundled Google families join the picker the same release — **Montserrat** (geometric sans), **Caveat** (handwritten), **Pacifico** (retro script), and **Abril Fatface** (display serif), bringing the curated set to 15.
  - Uploads are validated by magic bytes (a renamed PNG never passes), stored in the uploads volume, tracked in a new `custom_fonts` table (migration `0017`), and served same-origin — no CSP changes needed.
  - Themes reference an uploaded font as `custom:<id>`; the public page injects the matching `@font-face` rule ahead of the theme tokens, and the customizer's visualizer renders it live. A deleted or missing font always falls back to Inter, never a broken font stack — malformed `custom:` values fall back too instead of leaking into CSS.
  - Deleting a font asks first and lists the themes that will fall back to Inter; affected public pages are revalidated immediately.
  - Theme exports embed the font's bytes (base64) so the file is fully portable; importing restores it as a new font row (ids never collide across instances) and rewrites the theme's reference. Imports without a payload fall back to Inter. Backups carry the `custom_fonts` rows; restore resets themes whose referenced font isn't in the backup.

- **Page and theme deletion (#84, reported by @einlichtvogel)** — Non-default pages can now be deleted from the page switcher, and user-created themes from the theme page (trash icon left of Duplicate theme). Both ask for confirmation first. In the page switcher, clicking a page's `/slug` opens its public page.
  - Page deletion offers a choice: keep the page's links by moving them to the default page as uncategorized, or delete everything including the links and their click history. Sections and per-page view analytics are removed either way. The default page has no delete option since it is the fallback target for kept links.
  - Theme deletion works on themes the user created, duplicated, or imported. The 11 built-in presets can't be deleted, and neither can the currently active theme. Pages still using a deleted theme automatically fall back to the active theme.
  - Page deletion runs as a single database transaction, so a failure midway can't leave a half-deleted page behind.

### Changed

- **Dependency updates** — Bumped the minor-and-patch group with 6 updates (#88): `next` 16.3.0 → 16.3.1, `lucide-react` 1.30.0 → 1.31.0, `shadcn` 4.16.2 → 4.18.0, `@axe-core/playwright` 4.12.1 → 4.13.0, `@types/node` 26.1.2 → 26.2.0, `eslint-config-next` 16.3.0 → 16.3.1 Bumped `@types/better-sqlite3` from 7.6.13 to 9.6.0 (#89), aligning the type declarations with the runtime `better-sqlite3` v13

### Fixed

- **Social icons editor: focus loss and dropped dataset while typing a URL (reported by @einlichtvogel)** — The social platform rows in the profile form were keyed by `` `${item.platform}-${item.url}` ``. The key changed on every keystroke in the URL field, so React unmounted and remounted the row mid-edit: the input lost focus after each character and the in-flight state could corrupt what gets saved. Rows are now keyed by platform plus index — stable while the URL is being typed — so editing stays smooth and the dataset survives the save.

- **Link creation failing with "FOREIGN KEY constraint failed" (#86, reported by @einlichtvogel)** — Any new link failed to save once the page had at least one section. The link dialog's "No section" option submits an empty value, and the form schema coerced that empty string to `0` before the "empty means no section" check could run (`Number("") === 0`). The insert then referenced section 0, which doesn't exist, and SQLite rejected it with the FOREIGN KEY error. The section field now normalizes empty values to "no section" before numeric parsing, so the coercion can't misfire regardless of how the validation branches are ordered. Both the create and edit flows shared the flawed schema, so both are fixed. Covered by regression tests at the form-parsing layer, and the test database now enforces foreign keys the same way production does, so this class of bug can't slip through tests silently again.

## [1.3.0] - 2026-08-16

### Added

- **Frutiger Aero theme preset** — A glossy mid-2000s "water + air" theme as the 11th built-in preset:
  - Gel bubble link style — the classic Web 2.0 glossy button as a first-class card style: rounded-pill shape, vertical aqua gradient, a bright specular highlight arc near the top, and a "gel press" hover/active interaction (the highlight slides and the pill squashes like pressed jelly). Applied to link cards, social icons, and the subscribe button alike. Disabled under `prefers-reduced-motion`.
  - Frosted glass everywhere — semi-transparent cloud-white card tint, `backdrop-filter` blur, thin light top border, soft sky glow.
  - Bubbles video background — the preset ships with a free-licensed (Mixkit, no attribution) blue fluid-and-bubbles video loaded from a URL, autoplay/muted/loop/playsinline. When the video can't load (offline, dead URL, slow-connection fallback), the page falls back to a static aqua gradient built from the theme's own background colors — video backgrounds now paint their fallback gradient in their container instead of a hardcoded dark purple, and the customizer exposes the colors/angle fields for video themes so the fallback is editable.
  - Nunito font — rounded humanist sans (Google Fonts, OFL) joins the font picker.
  - Animation stack: floating avatar, gradient-flow display name, and blur-in reveal — the full 1.3 profile-animation set.
  - Existing installs: new presets are backfilled by name on the next page view — no migration needed, user themes and edited presets are never touched.
- **backfillPresets()** — server helper that inserts built-in presets missing from an install (matched by name). Runs alongside the empty-table seeding so releases can add presets without a SQL migration. Covered by integration tests (old-install upgrade, idempotency, user-theme safety).

- **Theme customizer rework — accuracy + live theme visualizer** — The /theme customizer now tells the truth and shows results while you work:
  - **Theme visualizer**: a phone-shaped visualizer beside the fields renders with the production theme resolver and the real link-card builder, updating as you drag. Admin-side JS only — the public page stays zero-JS. (The true live preview is the Live Preview button in the admin sidebar, which opens the real public page in a phone frame.)
  - **Overlay opacity fixed**: the slider stored 0–100 but the renderer expected 0–1, so any value ≥ 2 broke the background CSS entirely (and the video overlay went fully opaque). Both paths now normalize slider-scale and legacy values; existing image/video themes with overlays render correctly for the first time.
  - **Noise texture implemented**: the Effects → Noise toggle previously did nothing (the token was never consumed). Themes now render a film-grain overlay on public pages and the privacy page.
  - **Aurora follows your colors**: the Aurora background's moving blobs and base were hardcoded; they're now driven by the theme's accent, secondary, and first background color.
  - **Preset save forks safely**: saving customizations to a preset no longer silently restyles every page using that preset — it prompts for a name and saves a copy, then points your page at it.
  - **Dead controls removed**: the Dark/Light mode field (never consumed), the Pattern background type (identical to Solid), and the "Radial" angle option (produced invalid CSS with the Gradient type) are gone. Existing themes using those values keep rendering unchanged.
  - **Font picker shows real fonts**: each font chip renders its sample in the actual typeface.
  - **Contextual fields**: overlay color/opacity, gradient angle, and glass blur now appear only for the background types and link styles that use them.
  - **Sliders replace free-text CSS**: corner radius (with an Auto chip that preserves style defaults), border width, glass blur, and container width are now bounded sliders instead of raw CSS text inputs — no more invalid values from typos.
  - **Profile tab**: avatar shape, border, float, and display-name animation moved from Effects to their own tab.
  - **Gallery accuracy**: preset cards render their background through the production resolver, so gallery, customizer preview, and public page all agree.
  - **Top-right header actions**: "Duplicate theme" and "Import & export" are now compact buttons in the page header instead of bottom-of-page cards. Each opens an animated dialog: duplicate asks for the copy's name (prefilled "<theme> (copy)") and creates an editable copy; import/export gives a scrollable list of themes with color swatches and one-click download plus JSON import. Success feedback via toast.
  - **Interactive background display controls**: choosing how a background image, GIF, or video fills the page is now visual instead of impossible — a Squarespace-style draggable focal point on a live thumbnail (keyboard accessible, arrow keys nudge, Shift = bigger steps) plus a Cover / Contain / Tile picker with glyphs. The thumbnail renders with the chosen fit, so what you drag on is what visitors see; cover/contain letterbox on the theme's own background color instead of raw white. Stored per-theme (migration `0016`), honored by the public page, visualizer, and preset gallery alike; included in theme export/import.
  - **Uploads fixed end-to-end**: background image/video uploads advertised "max 2 MB" / "max 5 MB" but Next.js caps Server Action bodies at 1 MB by default, so anything between 1 and 2 MB died with a bare 413 before the app's own validation ran. The action body limit is now 10 MB (covering every upload surface — background media, avatars, favicons), uploads show a clear client-side "File too large" message instead of a server error, and the visualizer plays background videos (it previously rendered them as a CSS background image, which browsers don't display).

- **QR customizer (Settings → Appearance)** — The QR card now lets you design the code instead of just downloading it: custom code and background colors (with one-click swatches from your active theme's accents), a center logo (your page avatar or favicon, circular-clipped), and PNG export at 256/512/1024 px. Error correction automatically rises to High when a logo is embedded so the code keeps scanning, and the quiet zone widens to match. The SVG download embeds the logo as a base64 data URI so the file stays self-contained wherever it travels. Per-page style, stored in a new `pages.qr_settings` column (migration `0015`); absent or malformed data falls back to the classic look. The public `/api/qr` endpoint accepts `fg`, `bg`, `logo`, and `size` overrides, so existing embeds keep working unchanged.

- **Link sections (#78, reported by @MPapus)** — Group links under titled headers on the public page. Sections have a title and an optional Lucide icon, and are managed alongside links in the Links tab.
  - Public page: links render grouped under their section headers, with uncategorized links (no section) kept above all sections to preserve the pre-1.3 layout for existing pages. Empty sections never render a header. Section headers continue the page's existing entrance stagger (60ms steps across headers and cards alike) and respect `prefers-reduced-motion` and the animation-off theme setting.
  - Links manager: grouped view with drag-and-drop within and across sections (dnd-kit multi-container pattern). Drag a link onto a section to move it; drag section headers to reorder sections. Empty sections stay visible in the manager as drop targets. "Add section" button in the header; per-section edit/delete.
  - Deleting a section never deletes its links — they move to Uncategorized. A delete confirmation dialog says exactly that.
  - Link editor: new Section selector (only shown when the page has at least one section).
  - Schema migration `0013_link_sections.sql` adds the `link_sections` table and a nullable `section_id` on links (`ON DELETE SET NULL` in SQL; the delete path also nulls explicitly since SQLite does not enforce FKs without `PRAGMA foreign_keys=ON`).
  - Backup/restore: exports include sections; restore accepts both new and pre-1.3 backup files (no sections array → all links uncategorized).
  - Demo seed: the LinkBreeze showcase page now demonstrates sections (Featured with the GitHub star link, Resources with OmniRise).
  - Tests: 16 new — grouping helper unit tests (order, empty-section dropping, unknown-section fallback, stagger delays) and DB integration tests (CRUD, page isolation, section delete nulls links, atomic reorder of links+sections, page-delete cleanup).

- **Lucide icon picker for section headers** — The section icon field is now a searchable, category-filtered picker over the full Lucide set (~1700 icons) instead of free-text emoji. Replaces the emoji input entirely ("hard remove": emoji input is gone; existing emoji values keep rendering as text on the public page so nothing breaks for current users, but the field only accepts Lucide names from now on).
  - Shared resolver (`src/lib/icon-registry.ts`) converts between Lucide's PascalCase export keys and the dashed names stored in the DB, with acronym-aware handling (`ArrowDownAZ` ↔ `arrow-down-az`) where naive regex conversion breaks.
  - Public page renders picked icons as inline SVG (stroke inherits the theme text color, size follows the theme font scale) — zero client JS, zero external requests, works across all 10 themes with no per-theme work.
  - Admin picker renders a windowed grid (120 icons at a time + "Show more") so 1700 icons never hit the DOM at once; search by name, filter by 7 categories.
- **Section header redesign** — Section headers on the public page got a real design treatment: small-caps label voice (uppercase, 600 weight, widened tracking, sized at `max(0.72 × theme base font, 11px)` so it stays legible on small-base themes like 8-Bit), an optional Lucide icon before the title, and a hairline rule in the theme accent color stretching from the title to the container edge (linear gradient fading to transparent). Headers keep their place in the entrance stagger and respect `prefers-reduced-motion`.

- **Theme customizer rework** — The theme page's customizer now targets the exact theme the selected page renders (page-specific theme when set, otherwise the globally active one) instead of always editing the global theme. Duplicating a theme clones the one you're looking at, not the global one. Customizer fields resync when switching themes (sections remount with a key). Theme edits revalidate the public paths of every page using that theme, so changes appear immediately instead of after the 60s ISR window.
  - Fixed the same class of bug in the theme gallery's customizer panel that the gallery highlight already avoided (`effectiveActiveId`); the customizer and duplicate actions now follow the same resolution.
- **New entrance animations** — Four new reveal animation types in the theme customizer (Effects → Reveal animation): Fade up, Slide in, Zoom in, and Blur in, joining Lift, Scale, and None. The display name, bio, and social icons now join the entrance stagger (avatar 0ms → name 80ms → bio 160ms → socials 240ms → sections and links continue the existing sequence). All animations are disabled under `prefers-reduced-motion` and respect the animation-off theme setting. The Aurora preset now ships with Fade up.
- **Avatar styling** — Three new theme tokens (Effects → Avatar): Shape (circle, squircle, rounded square, square), Border (solid accent, gradient from accent to secondary, glow, ring), and Floating (gentle 3s hover loop). Defaults preserve the current look exactly.
- **Profile layouts** — New theme token (Layout → Profile layout): Classic (current centered stack), Hero (banner image with the display name overlaid on a readability scrim), and Banner (wide image strip above the classic stack, X/Twitter style). The banner image is set per page on the Profile page (URL or upload). Hero and Banner fall back to Classic when no banner is set.
- **Video and animated GIF backgrounds** — Two new background types: Video (`.mp4`/`.webm`, muted autoplay loop, cover-fit, content z-indexed above) and Animated GIF (treated as an image background; the browser animates it). Both upload through the theme customizer's Background tab with size caps (5MB video, 2MB image/GIF). Visitors on slow connections (2g/slow-2g or data-saver) get the theme's static background instead of the video via a tiny inline check — the only inline script on public pages. Overlay color/opacity applies to video like it does to image backgrounds.
- **Display name animations** — New theme token (Typography → Display name animation): Typewriter (character-by-character with a blinking caret), Gradient flow (accent-to-secondary shimmer flowing through the letters), or None. Both respect `prefers-reduced-motion`.
- **Schema migration `0014_theme_visuals.sql`** — Adds `avatar_shape`, `avatar_border`, `avatar_float`, `profile_layout`, `text_animation` to themes and `banner_url` to pages. All columns default to the pre-1.3 look, so existing installs are unchanged.
- **Backup, restore, and theme export/import** — All five new theme fields and the page banner round-trip through backup/restore and the theme JSON export/import. Pre-1.3 backup files and theme exports import cleanly (new fields default).
- **Tests: 21 new** — Reveal animation resolver (all keyframe mappings, none, fallbacks, delays), avatar tokens (shape radii, gradient build), gif/video background resolution, and DB integration (migration 0014 columns and defaults, updateTheme persistence, duplicateTheme cloning all new fields, getPagesUsingTheme).

### Changed

- **Settings reorganized: new Integration tab** — Settings → General now holds only page identity and content: slug, page title, SEO description, footer text, and privacy policy. The analytics script, email subscription toggle, and email consent text moved to a new Integration tab (between General and Appearance). Both save paths are presence-aware now: saving one tab no longer rewrites (or blanks) fields owned by another tab — previously saving the Appearance or Profile form silently turned email subscription off, because those forms didn't include the checkbox.
- **Analytics retention defaults to 90 days (#77)** — Instances that never set a retention window now prune analytics older than 90 days instead of keeping them forever. An explicit value in Settings → Data — including 0 (keep everything) — is respected unchanged; only the unset case changes. All readers (pruner, settings UI, privacy policy text) resolve the value through one shared helper, so the policy page can no longer disagree with actual pruning. Clicks are now pruned on the same opportunistic schedule as pageviews (previously only pageviews triggered pruning). Upgrading instances with no setting stored will prune analytics older than 90 days on the first tracked event — export first (Settings → Data → Export CSV) if you need the full history. The dashboard and link detail pages name the selected range in their subtitles. The "All time" range is gone from the range picker: with a 90-day retention window it could never show more than 90 days, so the picker now offers 7d / 30d / 90d only (legacy `?range=all` URLs fall back to 7d).
- **Google S2 favicon fallback removed from admin pages** — The dashboard referrer list and the links manager previously fetched favicons from `google.com/s2/favicons` in the admin's browser, the same fallback the public pages dropped in #72. Admin-side icons now resolve from the locally cached favicon store (populated when links are created) and fall back to a first-letter avatar when nothing is cached, so the admin browser no longer contacts Google either.
- **Subscriber count on the dashboard** — When email capture is enabled for the active page and at least one subscriber exists, a quiet counter next to Export CSV shows the total and links to Settings → Data. It stays out of the way on purpose: small, muted, no card.

### Fixed

- **Video backgrounds silently blocked by CSP** — The Content-Security-Policy had no `media-src` directive, so `default-src 'self'` silently blocked cross-origin `<video>` sources (e.g. the Frutiger Aero stock clip). The browser fell back to the theme gradient with no console error, making hotlinked video backgrounds look broken. CSP now allows `media-src 'self' https: blob:` — the same trust model as images.
- **Dev-overlay crash on login redirect ("cannot have a negative time stamp")** — A Next.js 16 framework bug (present through 16.3.1): when a tab is backgrounded during load, throttled timers can make the hydration performance marks land out of order, and the dev overlay's `performance.measure` call throws `TypeError: Failed to execute 'measure' on 'Performance': 'LoginPage' cannot have a negative time stamp` on routes that redirect (login → dashboard). The error is cosmetic but surfaced as a red overlay that looked like a real crash. A tiny pre-hydration script now hardens `performance.measure` to clamp negative durations instead of throwing; remove it once a stable Next.js release carries the upstream fix.
- **Overshooting corner radius on media cards** — Pill-radius themes (Frutiger Aero, Pastel Soft) rendered rich-preview and thumbnailed link cards — and embed widgets — as circles when their height exceeded their width. Media-bearing cards now use a clamped radius token (`min(card radius, 24px)`), so tall cards render as properly rounded rectangles while compact links keep the full pill.
- **Migration bookkeeping repair (#79, reported by @LorenzoDP2)** — On databases upgraded from older LinkBreeze versions, migration records could end up out of chronological order, which made newer schema changes look already applied. The affected migrations were silently skipped and the first query touching a missing column crashed (reported on 1.2.7 Docker installs as `SqliteError: no such column: privacy_policy`). Fixed in three layers:
  - Migration journal timestamps are now strictly ascending, so the migrator never mistakes a pending migration for an applied one.
  - On startup, LinkBreeze cross-checks recorded migrations against the journal (matched by content hash) and repairs out-of-order records before migrating. Databases that already hit the bug heal themselves on the next start with no manual steps, and no already-applied migration is ever re-run.
  - A new test suite guards the journal's ordering invariants so future migrations cannot reintroduce the bug.

## [1.2.7] - 2026-08-14

### Added

- **Built-in privacy policy pages** — Every public page now has a privacy policy at `/{slug}/privacy`, linked from a footer that is always visible. The policy auto-generates from the page's actual configuration (analytics, email capture, embeds, external analytics scripts, retention window) so it stays accurate without manual maintenance. Operators can write a custom policy in Settings → General → Privacy policy to override the auto-generated text. The privacy page inherits the page's theme (colors, fonts, background, Aurora animation) and is `noindex`.
- **SECURITY.md GDPR/Data Protection section** — Comprehensive data controller clarification (operator = controller, not OmniRise), two data inventory tables (visitor data + operator data), cookie disclosure (one admin-only session cookie, zero on public pages), GDPR data subject rights mapping (access, erasure, portability), email capture compliance gap documented with #75 reference, third-party embed disclosure, breach notification guidance, and a 5-point operator hardening checklist.
- **Privacy policy template generator** (`src/lib/privacy-template.ts`) — `generatePrivacyPolicy()` builds a markdown privacy policy reflecting the page's real feature set: analytics section (cookieless, SHA-256 hashed IPs, daily salt, retention), email section (conditional on email capture), third-party analytics section (conditional on external script), embedded content section (conditional on embeddable links), cookies, data subject rights, data controller, children's privacy, and changes sections.
- **MarkdownLite renderer** (`src/components/public/MarkdownLite.tsx`) — Minimal zero-dependency markdown renderer for privacy policy text. Supports h1/h2, paragraphs, lists, bold, italic, inline code. Theme-aware via `--lb-*` CSS variables. No `dangerouslySetInnerHTML`.
- **Privacy policy tests** — 13 tests covering all section combinations (analytics only, email only, embeds, external analytics, all features, no features, custom contact email fallback).
- **Rich preview link cards** — Per-link card style toggle (compact vs. rich preview) in the link editor for URL-type links. When set to "rich" and a thumbnail/OG image is available, the card renders with a full-bleed image on top and content row below — same visual style as the existing thumbnail card layout. Falls back to compact when no image is available. The card style selector uses radio-style buttons with clear visual state (border-violet highlight + filled circle for the selected option). Schema migration `0012_link_card_style.sql` adds a `card_style` column to the links table.
- **Open Graph fetcher** (`src/lib/og-fetcher.ts`) — Fetches `og:title`, `og:description`, `og:image`, and `og:site_name` meta tags from URL targets. Falls back to Twitter Card meta tags, then standard `<title>` and meta description. Downloads and caches OG images locally (visitor browsers never contact third-party CDNs). Handles relative/protocol-relative URLs, HTML entity decoding, 7s timeout, 512KB HTML cap, 5MB image cap. 10 unit tests covering all fallback paths and edge cases.
- **Email subscribers panel in Data tab** — When email capture is enabled, Settings → Data tab now shows an "Email subscribers" card with a table of all subscribers (email, subscription date, consent status), Export CSV button, and Clear All with confirmation dialog. Includes a new `DELETE /api/subscribers/clear` endpoint. The card only appears when email capture is toggled on for the active page.
- **Email capture toggle in Settings UI** — The email capture feature existed in the database and public page renderer but had no UI to enable it. Settings → General now has an "Email subscription" Switch toggle. When enabled, the consent text field appears below it. Saves through both the page-specific and global settings paths.
- **Multi-strategy favicon fetcher** — Replaced the single-source Google S2 favicon fetcher with a 3-strategy fallback chain: (1) fetch the site's HTML `<head>` and parse `<link rel="icon">`, `<link rel="shortcut icon">`, and `<link rel="apple-touch-icon">` hrefs; (2) try common favicon paths (`/favicon.ico`, `/favicon.png`, `/favicon.svg`, `/favicon-32x32.png`, `/favicon-96x96.png`); (3) fall back to DuckDuckGo's icon service (`icons.duckduckgo.com`). Rejects files under 100 bytes (filters out 1x1 transparent placeholders). Detects format from content-type and magic bytes (PNG, JPEG, GIF, SVG, WebP, ICO). Solves the previous issue where S2 returned generic 16x16 globe placeholders for newer/less-indexed domains.

### Changed

- **Visitor hash k-anonymity documentation** — The `getVisitorHash()` function comment now explains that the 64-bit SHA-256 truncation is deliberate, providing k-anonymity within the daily salt window. The trade-off (potential undercounting of unique visitors behind the same NAT with identical user-agents) is documented inline.
- **"Import from Linktree" buttons reworded** — Changed to "Import your existing page" across setup wizard, dashboard empty state, and links manager empty state. The migration wizard supports Linktree, Bento, Lnk.bio, LittleLink, and more, so the CTA should not name a single competitor.
- **Default footer text for new pages** — New pages now default to "Powered by LinkBreeze" as their footer text instead of empty. Operators can change or remove it in Settings → General. Every new deployment is a billboard for the project. Existing pages keep whatever footer text they already have.
- **Onboarding checklist simplified to 3 steps** — Removed the "Share your page" step (it was never fully implemented and clicking it left the checklist visible). The checklist now auto-dismisses when all 3 steps (set name and bio, pick a theme, add your first link) are complete. Removed the unused `pageSlug` prop.
- **Button text contrast token** — New `--lb-btn-text` CSS variable computed from each theme's accent color using WCAG relative luminance. Returns `#0a0a0a` for bright accents (Terminal Mono's green, Pastel Soft's pink) and `#ffffff` for dark accents (Minimal Light's blue). Fixes invisible Subscribe button text on Glassmorphism (card-bg was `rgba(255,255,255,0.12)` — nearly transparent) and Terminal Mono (card-bg was `rgba(0,255,65,0.04)` — nearly invisible). Applied to both the Subscribe button and the first-letter avatar fallback.
- **Subscribe button border radius** — The Subscribe button was missing `borderRadius: var(--lb-card-radius)`, causing it to render as a square on 5 themes (Aurora, Glassmorphism, Pastel Soft, Retro Sunset, Minimal Light). Now inherits the theme's radius automatically, matching the email input and all other UI elements.

### Fixed

- **README accuracy** — Changed "Privacy-First Analytics — no cookies, no tracking" to "Privacy-Respecting Analytics — Views, clicks, referrers, device type. Cookieless by design. Visitor IPs are hashed with a daily-rotating salt, never stored." Changed "No tracking" to "No third-party trackers" in the self-hosted section.
- **Setup wizard theme picker empty on fresh database** — `getAllThemes()` was called before `getActiveTheme()` (which internally seeds theme presets via `seedThemesIfEmpty()`), so on a fresh install the theme grid was empty. Fixed by calling `seedThemesIfEmpty()` before `getAllThemes()` in the setup page.
- **Email capture toggle not persisting** — `updatePageAction` did not call `revalidatePath("/settings")`. After saving, Next.js served the stale cached version of the settings page, so the email subscription toggle appeared to flip back to off. Added the missing revalidation call.
- **Lighthouse CI build failure** — Turbopack (Next.js 16) intermittently gets 404s from `fonts.gstatic.com` during page data collection in CI. Fixed by using `npx next build --webpack` for the Lighthouse CI build only, with a 3-retry loop and `NODE_OPTIONS: "--max-old-space-size=4096"` for memory safety. Main CI continues to use Turbopack.

### Security

- **Email capture consent (#75)** — The subscriber form now requires a consent checkbox before submission. Consent timestamp (`consent_at`) and the exact consent text shown at signup (`consent_text`) are stored in the database for audit trail. The consent text is customizable in Settings → General → Email consent text, with a sensible default ("I agree to receive emails and understand I can unsubscribe at any time."). CSV export now includes consent columns. Server-side validation rejects submissions without the checkbox checked.
- **Removed Google S2 favicon fallback from public pages (#72)** — The link card builder previously fell back to `google.com/s2/favicons` when a link had no locally cached icon. This leaked visitor IPs, cookies, and browser data to Google on every page load for those links. The fallback is removed entirely; links without cached favicons now show a first-letter avatar in the accent color. New links continue to auto-fetch and cache favicons server-side, so no visitor ever touches a Google domain.
- **Referrer stripping in analytics (#73)** — Analytics recording (pageviews and clicks) now stores only the origin of the Referer header instead of the full raw value. `instagram.com/p/Cxyz123/?igshid=...` is reduced to `https://www.instagram.com`. A `stripReferrer()` utility in `src/lib/visitor.ts` handles this at all three recording paths. Existing stored referrers are unchanged.
- **CSP support for external analytics scripts (#74)** — The analytics script injection feature (Settings → General) was broken by CSP enforcement shipped in 1.2.5: external `<script src>` tags for Plausible, Umami, Matomo, etc. were silently blocked. Operators can now allowlist analytics domains via the `EXTRA_SCRIPT_SRC` environment variable (space-separated), which injects them into the CSP `script-src` directive at build time. Documented in `.env.example`, README configuration section, and the analytics script field hint in the UI.

## [1.2.6] - 2026-08-12

### Added

- **Featured links with distinct styling (#48)** — Links marked as "Featured" in the editor now render with visually distinct cards: star badge, accent-tinted background via `color-mix`, thicker accent border (+1px), bolder title (+100 font weight), larger title text (+2px font size), and taller card (extra vertical padding). Works across all link styles (glass, neon, pixel). The editor toggle has been renamed from "Highlight" to "Featured" to match the feature name.
- **Onboarding wizard** — Multi-step setup wizard replacing the single-page form: Step 1 credentials, Step 2 profile (name/bio/slug), Step 3 theme picker with live previews, Step 4 completion screen with dashboard/import CTAs. Uses fetch-based API routes (`/api/setup`, `/api/onboarding`) instead of server actions to avoid RSC revalidation between steps.
- **Onboarding checklist** — Dismissible dashboard widget showing 4 getting-started tasks (add first link, set name/bio, pick theme, share page) with a progress bar. Auto-hides when all tasks are complete. State persisted in localStorage.
- **Empty state enhancements** — Dashboard shows a welcome hero with page URL and CTAs when 0 links and 0 views. Links page shows icon, description, and dual CTAs (add link / import from Linktree) instead of plain text.
- **Examples directory (#42)** — New `examples/` directory at repo root containing battle-tested deployment configurations for common production scenarios. Each example is a single, self-contained file with a header comment explaining when to use it, required env vars, ports, and traffic flow. Includes:
  - `docker-compose.caddy.yml` — Behind Caddy reverse proxy with automatic TLS (Let's Encrypt handled automatically, no Certbot required)
  - `docker-compose.traefik.yml` — Behind Traefik with automatic TLS via Let's Encrypt, includes dashboard for monitoring
  - `docker-compose.cloudflare-tunnel.yml` — Exposed via Cloudflare Tunnel (no open ports, zero-trust, works behind NAT)
  - `docker-compose.nginx.yml` — Behind Nginx reverse proxy with Certbot TLS (assumes Nginx already running on host)
  - `docker-compose.https-portal.yml` — Using https-portal for one-line TLS (single certificate manager for multiple services)
  - `docker-compose.with-backup.yml` — Production setup with scheduled SQLite backup sidecar (dumps DB, compresses, uploads to S3 or local volume, retains N days)
  - `kubernetes.yaml` — Full K8s manifest (Deployment, Service, Ingress, ConfigMap, Secret, PVC, HPA)
- `examples/README.md` — Decision table matching examples to scenarios, file-by-file breakdown with traffic flow diagrams, common patterns across examples, and security considerations.
- README updates — "Making Your Page Public" section now includes quick reference table linking to examples, and each reverse proxy option (Caddy, Nginx, Cloudflare Tunnel) links to its complete compose file.

### Changed

- **Demo instance mode** — Added 4 new env vars for public demo deployments: `DEMO_MODE` (blocks mutations, shows banner), `DEMO_AUTO_LOGIN` (skips login wall, returns mock session), `DEMO_FRAME_ORIGIN` (relaxes CSP `frame-ancestors` to allow iframe embedding from marketing site), `NEXT_PUBLIC_DEMO_MODE` (exposes `DEMO_MODE` to browser for postMessage theme listener). All documented in `.env.example`.
- **CSP frame-ancestors conditional** — Content-Security-Policy `frame-ancestors` directive now dynamically includes `DEMO_FRAME_ORIGIN` when `DEMO_MODE=true`, allowing demo instance to be embedded via iframe while maintaining strict same-origin policy for normal deployments. `X-Frame-Options` header omitted entirely in demo mode to avoid contradictory specs.
- **Demo banner links** — Admin layout demo banner now includes "Visit LinkBreeze" (links to marketing site) and "Deploy your own instance" (links to GitHub), replacing the single "View on GitHub" link.
- **Theme preview listener** — New `ThemePreviewListener` component in public layout listens for `lb-theme-preview` and `lb-theme-reset` postMessage events from iframe parent, applies ephemeral CSS variable overrides to `document.documentElement`. Gated by `NEXT_PUBLIC_DEMO_MODE`. Enables live theme switching in marketing site iframe without persisting to database.
- **Auto-login middleware bypass** — `proxy.ts` middleware now skips cookie verification when `DEMO_AUTO_LOGIN=true`, allowing visitors to land directly on `/dashboard` without a session cookie. The admin layout's `getSession()` also returns a mock session in demo mode, so the page renders normally.
- **Docker build args for demo mode** — Dockerfile accepts `DEMO_MODE`, `DEMO_AUTO_LOGIN`, `DEMO_FRAME_ORIGIN`, and `NEXT_PUBLIC_DEMO_MODE` as build args, passing them through to the build environment so `next.config.ts` can evaluate CSP conditions at build time (Next.js bakes config into standalone output). `docker-compose.demo.yml` updated to use `build:` with these args instead of pre-built image.
- **TurbopackIgnore annotations** — `src/lib/uploads.ts` now includes `/*turbopackIgnore: true*/` comments on `path.resolve()` calls to prevent Turbopack from tracing the entire filesystem at build time (which caused build failures with the current Next.js 16 + Turbopack stack). Build output now succeeds without warnings.
- **SQLite busy_timeout** — Database connection now sets a 5-second `busy_timeout` (both via `Database` constructor option and PRAGMA) to prevent "database is locked" errors when multiple workers access the same SQLite file during `next build` page data collection. Previously a missing timeout caused intermittent Lighthouse CI build failures.
- **Lighthouse seed script cleanup** — `seed-lighthouse.ts` now calls `process.exit(0)` after seeding to immediately release the SQLite file handle, preventing WAL lock contention with the subsequent server start step.
- **Demo env vars build-time evaluation** — `next.config.ts` now reads `DEMO_MODE` and `DEMO_FRAME_ORIGIN` at build time via `process.env`, not runtime. This is required because Next.js bakes CSP headers into the standalone build output. For demo builds, pass build args via `docker-compose.demo.yml` or set env vars before running `npx next build`.

### Security

- **Demo mode security boundary** — All demo features (`DEMO_MODE`, `DEMO_AUTO_LOGIN`, `DEMO_FRAME_ORIGIN`, postMessage theme listener) are gated by env vars and explicitly documented as read-only for public demos. The demo instance cannot modify data (mutations are blocked by `DEMO_MODE=true`). PostMessage listener validates `event.source === window.parent` to ensure it only accepts events from the expected parent origin.
- **CSP frame-ancestors validation** — When `DEMO_FRAME_ORIGIN` is set, it's included in `frame-ancestors` but all other CSP directives remain strict. Only the marketing website can embed the demo, not arbitrary origins.

## [1.2.5] - 2026-08-11

### Added

- **Full error system** — Centralized `ActionResult` type with `ErrorCode` enum (validation, unauthorized, forbidden, demo, rate_limit, not_found, conflict, internal), structured logging via `logError()`, `withDemoGuard()` wrapper replacing ad-hoc `demoBlock()` pattern, and typed convenience constructors (`validationError()`, `unauthorizedError()`, etc.). All server actions now use the unified error contract.
- **Error boundaries** — Three React error boundaries: `global-error.tsx` (critical failures, dependency-free inline styles), `(admin)/error.tsx` (admin route recovery UI with retry button), and `(public)/error.tsx` (public page fallback). Each logs to console and provides a "Try again" button that resets the boundary.
- **Error UI components** — `ErrorBanner` (inline, color-coded by error code, auto-dismiss for non-critical) and `SuccessToast` (bottom-right, 3s auto-dismiss) components for consistent feedback across forms.
- **Accessibility regression tests** — axe-core Playwright E2E tests (`e2e/accessibility.spec.ts`) scanning public link page, login page, and 404 page for WCAG 2.2 AA violations. Catches a11y regressions before they ship.
- **Backup/restore integration tests** — Integration test suite (`backup-restore.integration.test.ts`) verifying full backup-then-restore round-trip: snapshot reflects seed data, restore reproduces exact data after mutation, rejects invalid JSON, rejects unsupported versions, rejects malformed rows without partial writes.

### Changed

- **CSP enforcement** — Promoted Content-Security-Policy from Report-Only (1.2.4) to enforced on all routes. Exceptions documented inline in `next.config.ts`: `script-src 'unsafe-inline'` (sendBeacon click tracker), `style-src 'unsafe-inline'` (theme CSS custom properties), `frame-src` (whitelisted embed providers only), `object-src 'none'`, `form-action 'self'`.
- **Dependency bumps** — next 16.3.0, better-sqlite3 13.0.3, lucide-react 1.30.0, @base-ui/react 1.7.0, shadcn 4.16.2, eslint-config-next 16.3.0.

### Fixed

- **Secure cookie over HTTP (#70, reported by @mrtomtech)** — Session cookies were always flagged `Secure` in Docker because the flag was tied to `NODE_ENV === "production"`. Browsers silently reject Secure cookies over plain HTTP, so self-hosters on LAN IPs (e.g. `http://192.168.1.50:3000`, TrueNAS, Synology) could never log in. The flag is now derived from the actual request transport via the `X-Forwarded-Proto` header: non-secure over HTTP, secure when behind a TLS-terminating proxy (Caddy, nginx, Cloudflare). Zero configuration required.
- **500 on login/setup without SECRET_KEY** — Bare `docker run` (without `docker-compose.yml`) doesn't set `SECRET_KEY`, causing `getSecret()` to throw FATAL in production mode and every login or setup attempt to 500. The app now auto-generates a 32-byte random key on first boot, persists it to `/app/data/.secret-key` (survives container restarts as long as the volume is mounted), and injects it into the process environment before any request is served. Existing `SECRET_KEY` env vars take priority and are never overridden.

### Security

- **npm overrides** — Added `nanoid >=3.3.17` and `js-yaml >=4.3.1` overrides to resolve two high-severity Dependabot alerts in transitive dependencies (postcss -> nanoid infinite loop, eslint/cosmiconfig -> js-yaml quadratic CPU).

## [1.2.4] - 2026-08-09

### Added

- **UTM Parameter Builder (#49)** — URL-type links in the link editor now have an optional UTM parameter section (toggled via a Switch). Five fields: source, medium, campaign, term (optional), content (optional). Values are appended to the URL client-side before the server action fires (no DB migration needed). When editing a link that already has UTM params embedded, the builder auto-detects them, strips them from the URL field display, and pre-fills the UTM inputs. The builder only appears for `type: "url"` links.
- **Content-Security-Policy (Report-Only)** — A CSP header was sent in Report-Only mode on all public and admin pages. Restricted script-src, style-src, img-src, font-src, connect-src, frame-src (whitelisted embed providers only), frame-ancestors, base-uri, form-action, and object-src. Shipped as Report-Only first to observe real-world violations before enforcing (enforcement shipped in 1.2.5).
- **Dynamic robots.txt route** — Replaced the static `public/robots.txt` with a Next.js route handler (`src/app/robots.ts`). The static file had a relative `Sitemap: /sitemap.xml` URL, which is invalid (Google requires absolute URLs). For a self-hosted product where every instance has a different domain, a static file cannot produce a valid absolute sitemap URL. The route resolves the origin from `BASE_URL` or request headers, matching the same pattern as the sitemap route.
- **Playwright E2E test suite** — Added `@playwright/test` and `playwright.config.ts` with browser E2E tests. New `npm run test:e2e` and `npm run test:all` scripts in package.json.
- **Expanded unit test coverage** — Added 9 new test files: `utm.test.ts` (20 tests), `analytics-range.test.ts`, `auth.test.ts`, `favicon.test.ts` (11 tests), `migration-wizard.test.ts`, `theme-presets.test.ts`, `theme-schema.test.ts`, `theme-tokens.test.ts`, `update-check.test.ts`, `utils.test.ts`. Plus new server action tests: `pages.test.ts`, `profile.test.ts`, `subscribers.test.ts`, `theme.test.ts`, `uploads.test.ts`, and queries tests. Total test count: 445 unit + 9 integration + 5 E2E = 459 tests.
- **Test infrastructure for server actions** — Added `sql.js` (WASM SQLite) and in-memory DB test setup so server action tests can exercise real database operations without file-based SQLite or the segfault-prone `better-sqlite3` native module. Added `server-only` mock alias to vitest config. Tests run single-threaded (`maxWorkers: 1`) with the threads pool for deterministic DB isolation.

### Changed

- **Static asset cache headers fixed** — Added an explicit `Cache-Control: public, max-age=31536000, immutable` header rule for `/_next/static/:path*` in `next.config.ts`. This was an active performance bug: the `/:slug*` catch-all rule was matching `/_next/static/*` and overriding Next.js's default immutable cache with `s-maxage=60`, causing every hashed JS/CSS chunk to be revalidated every 60 seconds instead of cached for a year. The new rule is ordered above the catch-all so it takes precedence.
- **Avatar LCP optimization** — Added the `priority` prop to the avatar `<Image>` in `ProfileHeader.tsx`. This adds `<link rel="preload">` in the `<head>` and `fetchpriority="high"` on the img tag, preloading the LCP element (~300-500ms saving on mobile 3G).
- **Public page favicon fallback** — `buildIcon()` in `build-link-card.ts` now falls back to Google's S2 favicon service (`google.com/s2/favicons?domain=X&sz=64`) for http(s) links when no cached `iconUrl` exists. Previously, links without a cached favicon (seeded demo data, links created before auto-favicon shipped, or initial fetch failures) showed a first-letter avatar fallback instead of a real favicon. The S2 fallback mirrors the same approach already used in the admin sortable-link component.
- **Thumbnail alt text** — Link card thumbnail images now use the link title as `alt` text instead of an empty string, improving accessibility and SEO. Added a test to verify.
- **Embed iframe sandbox attributes** — Changed `allow-popups` to `allow-same-origin` on both fixed-aspect (YouTube/Vimeo) and auto-height (Spotify/SoundCloud/Bandcamp) embed iframes. `allow-same-origin` is required for the embed players to function correctly (Spotify, SoundCloud, and Bandcamp players broke without it).
- **DnD context ID** — Added `id="links-dnd"` to the links drag-and-drop `DndContext` for proper instance identification when multiple DnD contexts coexist.

### Removed

- **Static `public/robots.txt`** — Replaced by the dynamic route handler `src/app/robots.ts` (see Added).

## [1.2.3] - 2026-08-05

### Added

- **8-Bit Retro theme (preset #10)** — New retro gaming theme matching the ReactBits 8-bit template. Press Start 2P pixel font at 75% scale, white background (#ffffff), near-black text (#0a0a0a), orange accent (#ea580c). All UI elements use clip-path-based stepped L-corner notches instead of border-radius. Large elements (link cards, embeds, avatar) use 8px outer / 6px inner clip-path steps at 3px gap. Small elements (social icons, email input, badge, subscribe button) use 6px outer / 4px inner. Link cards and subscribe button feature hard 0-blur shadow hover effects.
- **Pixel link style** — New `pixel` option added to the link style enum, theme constants, and theme customizer.
- **Theme preview mockups in preset gallery** — Replaced the flat color-swatch gallery cards with a `ThemePreview` component that renders a mini phone mockup per theme showing actual background, avatar circle, name bar, and two fake link cards with the theme's real colors, border style (including pixel clip-path), and font family.
- **Global scrollbar hiding** — All scrollbars hidden across browsers via `scrollbar-width: none` (Firefox), `-ms-overflow-style: none` (IE/Edge), and `::-webkit-scrollbar { display: none }` (Chrome/Safari/Opera). Pages still scroll normally.
- **`--lb-pixel` CSS var flag** — Theme tokens now emit `--lb-pixel: "1"` when linkStyle is pixel, used by global CSS to activate clip-path rules.
- **Demo data enrichment** — Seeded 11 countries with realistic US-heavy distribution across 560 pageviews (previously all null). Diversified referrers to 14 sources and adjusted device type weighting to mobile-heavy split.

### Changed

- **Avatar border simplified** — Changed from `linear-gradient(135deg, accent, secondary)` to a solid accent color ring for all themes.
- **Social icon border radius** — Non-pixel social icons now use `var(--lb-card-radius)` instead of hardcoded `9999px`, respecting each theme's radius setting.
- **Preset gallery cleanup** — Removed `backdrop-blur-xl` from gallery cards and dropped the unused `Palette` icon import and `swatchFor` helper.
- **Brutalist letter spacing** — Corrected from `-0.5` to `0.5`.
- **Dashboard and admin chart components** — Split Recharts into lazy-loaded inner components via `next/dynamic` so the heavy chart library only loads on the client when needed, not on initial page render.
- **Sequential server queries parallelized** — 10 locations across admin pages and server actions where independent `await` calls were sequential now use `Promise.all()`, cutting server response time.
- **Context provider values memoized** — `PreviewProvider` and `TabsContext` now wrap their context values in `useMemo()` to prevent unnecessary re-renders.
- **`transition-all` replaced** — 4 admin components switched from `transition-all` to specific transition properties (colors, opacity, transform) to avoid browser layout thrashing.
- **`map().filter(Boolean)` eliminated** — Public page JSON-LD now uses `flatMap()` for cleaner single-pass array processing.
- **Glassmorphism card opacity** — Bumped from `rgba(255,255,255,0.08)` to `0.12` for better card visibility over mesh gradients. Border opacity `0.15` → `0.18`.
- **Brutalist hover effect** — Changed from `lift` to `glow` with `glowColor: #000000` for a hard shadow punch matching the brutalist aesthetic. Added entrance animation (`animationType: lift`).
- **Glow alpha** — Theme glow shadow opacity increased from `40` (25%) to `66` (40%) hex for more visible glow effects on Neon Cyberpunk and Retro Sunset.

### Removed

- **5 dead UI scaffolding files** — `avatar.tsx`, `dropdown-menu.tsx`, `skeleton.tsx`, `sonner.tsx`, `theme-background.ts` — all unused, zero imports across the codebase.
- **`sonner` and `next-themes` npm dependencies** — Uninstalled after their component files were removed as dead code.
- **10 unused exports** — Cleaned up non-component exports (`badgeVariants`, `buttonVariants`) that broke Fast Refresh, plus 8 other unused function/const exports.

### Fixed

- **Dashboard edge-card hover clipping** — Hover effects (spotlight glow, ring shadow, scale) on dashboard cards along the grid's outer borders and corners were visually clipped. The dashboard root container had `lg:overflow-hidden` which cut off outward-extending box-shadows and transforms at the boundary. Removed the redundant overflow constraint (the chart section retains its own `overflow-hidden` for internal containment), allowing hover effects to render fully on all 8 cards.
- **Dashboard bottom-row card watermark icons** — Top Links, Referrers, Devices, and Countries cards had missing or mismatched watermark icons (no icon, wrong icon, or small circle style). All four now have semantically correct watermark icons (Trophy, Share2, MonitorSmartphone, Globe) matching the top-row cards' size, position, and styling exactly.
- **"All" range chart showing only 1-2 days** — The `rangeDayCount("all")` function assumed analytics timestamps were always in SQLite `datetime('now')` format (`YYYY-MM-DD HH:MM:SS`). When timestamps were stored in ISO format (e.g. from `.toISOString()`), the date parser produced `NaN`, causing the chart to show only 1 data point. Fixed the parser to handle both ISO and SQLite datetime formats, with an `isNaN` guard falling back to 30 days.
- **Demo seed timestamps not backdated** — The demo seed script's 7-day analytics loop intended to spread data across the past week, but `createdAt` was never explicitly set on inserts, causing all records to default to `now()`. This compressed all analytics into a single day, making the "All" range chart show only 1 data point. Each pageview and click now gets a backdated `createdAt` timestamp spread across the appropriate day.
- **Theme preset DB drift** — The demo seed script maintained its own hardcoded copy of all 9 theme definitions that had diverged significantly from the canonical `theme-presets.ts` (wrong colors, wrong fonts, wrong styles on every theme). Seed now imports directly from `PRESETS` so the DB always matches the source of truth.
- **Avatar border hardcoded to Aurora gradient** — The avatar ring used `var(--aurora-grad)` (purple gradient) on all 9 themes. Now uses a new `--lb-avatar-border` CSS var computed from each theme's accent and secondary colors (`linear-gradient(135deg, accent, secondary)`).
- **Badge used hardcoded `.glass` class** — The profile badge used the Aurora-specific `.glass` CSS class (dark purple) on all themes including light ones. Now uses `--lb-card-bg` and `--lb-card-border` for theme-aware styling.
- **Display name overflow on phones** — The `<h1>` used Tailwind `text-3xl sm:text-4xl` (30-36px) which overflowed on narrow screens for display fonts (Bebas Neue at 120%, Brutalist). Now uses `calc(var(--lb-font-size) * 1.6)` so the heading scales with the theme's font size, plus `word-break: break-word` and `max-width: 100%` to prevent clipping.
- **Double-dimmed text** — Bio, footer, and "no links" text used `opacity` classes on top of `--lb-text-muted`, causing double-dimming. Removed all `opacity` overrides; muted text now uses only the `--lb-text-muted` color.
- **Link description double-dimmed** — Card descriptions used `opacity:.7` on top of `--lb-text` color. Now uses `--lb-text-muted` directly for consistent dimming.
- **Social icons inline JS hover** — Social icon hover effects used `onmouseover`/`onmouseout` inline handlers, ignoring `prefers-reduced-motion`. Migrated to CSS-based `.lb-social-icon` class with the same reduced-motion gate as link cards.
- **WCAG contrast failures on 6 themes** — Fixed color values to meet WCAG AA (4.5:1) contrast requirements:
  - Aurora: primary `#533fd6` → `#7c5ff0` (2.75:1 → 4.5:1 on card)
  - Pastel Soft: muted `#94a3b8` → `#64748b` (2.42:1 → 4.5:1), primary `#ec4899` → `#db2777` (3.32:1 → 4.5:1)
  - Neon Cyberpunk: muted `#6b6b8d` → `#8585a3` (3.93:1 → 4.8:1)
  - Terminal Mono: muted `#4a8a55` → `#5da669` (4.45:1 → 4.8:1)
  - Minimal Light: primary `#3b82f6` → `#2563eb` (3.68:1 → 4.5:1)

### Security

- **iframe sandbox attributes** — All embed iframes (YouTube, Spotify, SoundCloud, Vimeo, Bandcamp) and the admin preview pane iframe now have `sandbox` attributes restricting their capabilities.
- **CI pipeline hardened** — Lighthouse CI workflow now uses `--ignore-scripts` on npm install to prevent package lifecycle scripts from running near CI secrets.
- **npm dependency overrides** — Bumped `fast-uri` to `^3.1.5` (ReDoS via backslash authority), `brace-expansion` to `^5.0.9` (DoS via unbounded arrays), and added `hono` override to `^4.12.34` (ReDoS in CORS middleware). `npm audit` now reports 0 vulnerabilities.

## [1.2.2] - 2026-08-02

### Added

- **Live preview pane** — A floating phone-frame overlay accessible from a "Preview" button in the admin sidebar (desktop) and mobile tab bar. The `PreviewProvider` wraps the entire admin shell so any edit — link reorder, toggle, theme change, profile update, settings save — instantly reflects in the preview iframe. The preview auto-reloads on active page changes and supports manual refresh + open-in-new-tab. The phone frame uses a 9:19.5 aspect ratio with a max height of 720px. The public page detects when it's inside the preview iframe (`window.self !== window.top`) and hides its scrollbar so the phone mockup stays clean.
- **Dashboard metric cards with deltas and sparklines** — The four KPI cards (Views, Clicks, CTR, Active Links) now show period-over-period change indicators (up/down arrows with percentage) comparing the selected range to the previous equivalent range via a new `getPreviousStats()` query. Views and Clicks cards include inline sparkline charts rendered from daily data. Each card has a watermark-style icon in the bottom-right corner.
- **Expandable dashboard breakdown cards** — Top Links, Top Referrers, Devices, and Countries cards are now clickable. Each shows a compact view (max 4 items) on the dashboard and opens a Dialog with the full dataset when clicked. A maximize icon appears on hover.
- **Magic Bento SpotlightCard component** — New reusable card component (`src/components/ui/spotlight-card.tsx`) with a mouse-following radial glow effect. Used for dashboard metric cards. Glow color matches the aurora violet (`#533fd6`).
- **Tabbed settings page** — Settings split from a single long form into 4 tabs: General (page title, slug, SEO, footer, CSS), Appearance (theme picker, favicon upload, custom CSS), Security (password change), Data (email capture toggle, subscribers, backup/restore, clear analytics). Each tab is an independent form with its own save button. New `SettingsTabs` shell + `settings-tab-forms.tsx` with `GeneralTab`, `AppearanceTab`, `SecurityTab`, `DataTab`.
- **Reusable tab UI component** — New lightweight `src/components/ui/tabs.tsx` for accessible keyboard-navigable tabs, used by settings and theme customizer.
- **11 missing brand icons** — Added proper brand SVG icons for 11 platforms that previously fell through to the generic circle placeholder: reddit, facebook, pinterest, substack, dribbble, vimeo, paypal, buymeacoffee, kofi, medium, and X (the `"x"` key, which now resolves to the X logo SVG alongside the existing `"twitter"` key).
- **Social platform icon-chip selector** — Profile page social links: the dropdown platform selector was replaced with a visual chip grid showing all 54 platforms as icon chips. Dimmed chips indicate already-added platforms. Clicking a chip adds that platform to the social links list.
- **PageSwitcher external links** — Each page entry in the PageSwitcher dropdown now has an always-visible external-link icon that opens the public page in a new tab. Previously this was hover-only and got swallowed by nested group CSS.

### Changed

- **Dashboard layout fills the viewport** — The dashboard now uses `lg:h-[calc(100dvh-3rem)]` with `lg:overflow-hidden` so all content fits within the viewport with no scrollbar and no empty space. The "Views over time" chart takes the full middle row (grows with `flex-1` to fill remaining vertical space), with the four breakdown cards (Top Links, Referrers, Devices, Countries) in a single `lg:grid-cols-4` row at the bottom. The chart's Y-axis margin was fixed from `-16` to `0` to stop axis labels from clipping off the left edge as faint ghost artifacts.
- **Theme customizer consolidated to 4 tabs** — Merged from 6 tabs to 4: Background (background type/value/angle/image/overlay/mode + colors), Typography (font family/scale/weight/spacing), Links (link style/hover/button size/radius/border/shadow/container width/alignment/density — former Card Style + Layout merged), Effects (glow/noise/color/blur/reveal animation).
- **Profile avatar aurora ring** — The profile avatar in the admin form now has a violet aurora gradient ring around it.
- **Admin link favicon system** — Admin link cards (`sortable-link.tsx`) now use the cached favicon from `src/lib/favicon.ts` (stored in the `iconUrl` field) as the primary icon, falling back to Google S2 (`sz=64`). Special protocol icons are used for non-HTTP link types: `mailto:` → Mail icon, `tel:` → Phone icon, embed/vcard → Code2 icon. Broken favicon images auto-hide via `onError` handler.
- **Admin shell full-width content** — The admin main content area no longer has a `max-width` cap; content uses full available width.

## [1.2.1] - 2026-07-29

### Changed

- **Migration wizard imports links in parallel instead of sequentially** — Significantly speeds up large imports.

### Removed

- **5 unused UI scaffolding files** — Dead code cleanup from earlier prototyping.

### Fixed

- **Settings save no longer overwrites Display Name or wipes social links (#57, reported by @jmbillard)** — The Settings form and Profile form both route through the same server action. The Settings form sent a title field that collided with the Display Name column, silently overwriting it. Social links were also wiped because the shared action defaulted the missing field to an empty array.
- **Settings tab now updates immediately when switching pages (#58, reported by @jmbillard)** — The Settings form used uncontrolled inputs that didn't react to page changes.
- **Auto Icon toggle now correctly hides the icon when disabled (#59, reported by @jmbillard)** — The public page renderer ignored the autoIcon field entirely.

### Security

- **Update-check server actions now require authentication** — Prevents unauthenticated users from triggering version-check API calls.
- **Embed iframes (YouTube, Spotify, SoundCloud, Vimeo, Bandcamp) now sandboxed** — All third-party embeds are wrapped in `sandbox` attributes to restrict their capabilities.

## [1.2.0] - 2026-07-28

### Added

- **Migration Wizard — import from URL (#45) and file upload (#46)** — New three-step wizard in Settings lets users import links and social profiles from any existing link-in-bio page. URL mode fetches and parses the page using a three-tier extraction engine: Tier 1 `__NEXT_DATA__` JSON (Linktree, Bento, and other Next.js platforms), Tier 2.5 inline `<script>` JSON (Hopp.bio, Beacons, and other Wix/doppe-based platforms), Tier 3 DOM `<a>` scraping (universal fallback). File mode accepts uploaded HTML or JSON exports (LittleLink, LinkStack, etc.). Extracted links and social profiles are previewed with thumbnails, auto-detected platform icons, and individual checkboxes before the user confirms import. SSRF protection: private-IP blocking, http/https only, max 3 redirects, 8s timeout, 5MB response cap. Social platforms auto-detected via the existing `detectPlatform()` helper. Closes #45, closes #46.
- **Favicon upload in settings UI (#52)** — Administrators can now upload a custom favicon directly from the Settings page. Supports .ico, .png, .svg, .gif, and .webp formats up to 1 MB. The upload is now per-page: each page can have its own favicon, and pages without one fall back to the default LinkBreeze branding. The admin dashboard always uses the default LinkBreeze favicon. Closes #52.
- **Auto-favicon for links (#10)** — URL-type links automatically fetch and display the target site's favicon next to the link title. Favicons are fetched via Google's S2 favicon service and cached locally as PNGs in `data/uploads/` (keyed by domain hash, so two links to the same domain share one cached file). Links without a favicon fall back to a first-letter avatar (accent-colored circle with the link's initial). Each link has a per-link "Auto icon" toggle (enabled by default) in the link editor — turning it off clears the favicon and reverts to the first-letter fallback. Non-HTTP link types (email, phone, WhatsApp) are excluded automatically. Closes #10.
- **Multi-page support (#47)** — Users can now create unlimited pages, each with its own slug, title, bio, avatar, social links, theme, links, analytics, SEO settings, favicon, QR code, footer text, custom CSS, analytics script, and email capture toggle. The `pages` table replaces the singleton `profile` table as the source of truth for page content. A page switcher in the admin sidebar (drop-up on desktop, compact in mobile header) lets users switch between pages; each admin section (links, profile, theme, dashboard, settings) is page-scoped via a `?page=N` query param that persists across tab navigation. New pages are created at `/pages/new`. The public route `/{slug}` resolves to the matching page with per-page metadata (title, description, favicon, OG image). Existing installs are migrated automatically: the current profile/settings data is seeded as the default page, and all existing links are assigned to it. Root `/` redirects to the default page's slug. Analytics (pageviews, breakdowns, dashboard stats) are tracked per-page. QR codes are generated per-page using the page's slug. Closes #47.
- **Social platform support expanded from 32 → 54 platforms** — Added 22 new platforms (Apple Music, Apple Podcasts, Tumblr, Flickr, RSS, Steam, Etsy, Goodreads, Wattpad, Unsplash, DeviantArt, Cash App, Venmo, LINE, WeChat, VK, Quora, GitLab, Stack Overflow, Product Hunt, Linktree, Bento). Replaced all 10 globe placeholder icons (Threads, Bluesky, Mastodon, Snapchat, Patreon, Gumroad, Behance, SoundCloud, Bandcamp, Signal) with real brand SVGs from simple-icons (CC0). Every platform now has its proper branded icon — zero placeholders remain. Added detection rules for all new platforms.
- **One-line install script (`scripts/install.sh`)** — New `curl | bash` installer detects Docker or Podman automatically, pulls the latest image, starts the container with correct port and volume mappings, waits for the health check endpoint to confirm startup, and optionally generates a systemd service for auto-start on boot. Shellcheck-clean. Usable via GitHub raw URL or locally after cloning.
- **README deployment guides for 8 platforms** — Quick Start section restructured with a method-selector table at the top. Added step-by-step guides for: one-line script, Docker, Docker Compose, Coolify, Synology NAS, Podman (with rootless notes), Portainer (Stack YAML), and Manual (from source). Each section is self-contained — users find their platform and follow steps without reading unrelated content.

### Changed

- **Root layout metadata resolves origin dynamically** — The `generateMetadata()` conversion initially hardcoded a `metadataBase` URL, which is wrong for an OSS project where every instance has its own domain. Fixed: `metadataBase` now resolves from `BASE_URL` env var first, then request headers (`x-forwarded-host` + `x-forwarded-proto`), with a localhost fallback for build time — same pattern the public page already uses for OG images and sitemap.
- **.ico and .svg content-type support** — Added `image/x-icon` and `image/svg+xml` to the uploads content-type map so the upload route serves favicon files with correct headers.
- **Dependency security overrides** — Bumped npm overrides to resolve all 4 dependabot high-severity advisories: `postcss` ^8.5.18 (path traversal via sourceMappingURL, GHSA), `sharp` ^0.35.0 (libvips CVEs), `fast-uri` ^3.1.4 (host confusion via backslash/IDN), `brace-expansion` ^5.0.8 + minimatch ^10.0.1 (DoS via unbounded expansion, GHSA-mh99-v99m-4gvg). `npm audit` now reports 0 vulnerabilities.
- **Dependency bumps** — next 16.2.11→16.2.12 (TypeScript 7 support backport), react/react-dom 19.2.7→19.2.8 (RSC decoding perf), eslint-config-next 16.2.11→16.2.12, better-sqlite3 12.11.1→13.0.1 (major: refactored to N-API/node-addon-api, removed prebuild-install dep, prebuilt binaries now cross-runtime compatible — all 269 tests pass). Supersedes dependabot PRs #55 and #56.
- **Lighthouse CI workflow triggers on push to main** — Previously only ran on PRs and daily cron, so the seed-database fix went unverified until a PR was opened. Added `push: [main]` and `workflow_dispatch` triggers.
- **Design system foundation: DESIGN.md + semantic tokens + FormField component** — Added `DESIGN.md` at repo root documenting the full visual language: color tokens (brand + semantic), 5-step spacing scale, typography, component conventions, motion, accessibility, and file structure. Created `--success` and `--warning` CSS tokens to split feedback colors from brand accent (previously `text-lavender` doubled as both brand accent and success, and raw Tailwind colors like `text-green-500` and `text-amber-400` were used ad-hoc). Created `FormField` component (`src/components/ui/form-field.tsx`) as the single source of truth for label + control + hint spacing. Migrated all 6 admin forms to FormField: `settings-form`, `change-password-form`, `profile-form`, `link-dialog`, `setup-form`, and `MigrationWizard` — 25 field instances total, each verified pixel-identical (8px label→input gap, 32px input height) against pre-migration baselines. Required fields now show asterisks automatically (accessibility improvement). Migrated all raw Tailwind color classes to semantic tokens (`text-green-500` → `text-success`, `text-amber-400` → `text-warning`, `text-red-400` → `text-destructive`, data-manager success messages `text-lavender` → `text-success`). Theme customizer (`field-controls.tsx`) intentionally NOT migrated — its denser 6px spacing is correct for a customizer panel context.
- **Update checker** — Dashboard now polls `latest-version.json` from the repo (served via GitHub raw) on a 24h cache to detect new releases. When a newer version is found, a dismissible banner appears at the top of the dashboard: "LinkBreeze vX.Y.Z is available — You're running vX.Y.Z" with a "View release notes" link, a manual "Check again" button, and a dismiss button. Settings > Data card has an "Update notifications" toggle (default ON) to disable the check entirely. No user data sent, no phone-home, no auto-update — just a plain GET to the public JSON file. Silently fails on offline, rate-limit, or timeout (no error shown to user).
- **`links-manager.tsx` split from 573 → 151 lines** — Extracted `SortableLink`, `LinkDialog`, and `DeleteDialog` into separate component files under `src/app/(admin)/links/components/`. Moved `LINK_TYPES`, label/placeholder maps, and URL-prefix logic into `link-helpers.ts` (where `prefixLinkUrl()` will be reused by the migration wizard). Fixed a stale-closure bug in `handleDragEnd` where `items.map()` read the pre-drag array instead of the post-drag order. The slim orchestrator is now structured to accept a `pageId` prop for multi-page support.
- **`theme-manager.tsx` split from 782 → 159 lines** — Extracted `ColorField`, `SelectField`, `ToggleField`, `SliderField` into `field-controls.tsx`; `PresetGallery`, `ThemeCustomizer` (with 6 section sub-components), and `DuplicateTheme` into separate component files. Moved all constant arrays and the `swatchFor` helper into `theme-constants.ts`. The orchestrator is ready for theme-per-page work.
- **`build-link-card.ts` signature modernized** — Changed from 5 positional params to an options object `buildLinkCardHtml({ link, theme, index, staggerMs? })`. Removed unused `_profile` parameter. Extracted `resolveLinkUrl()` and `buildContentRow()` helpers, eliminating the duplicated content-row HTML between the image and no-image code paths. Updated `LinkCard.tsx` caller to drop the now-unused `profile` prop.

### Fixed

- **Dashboard referrers showed raw URLs with duplicates** — Referrer entries like `https://example.com`, `http://example.com`, and `https://example.com/` appeared as separate rows instead of being grouped. Fixed: referrers are now normalized to their hostname (stripping protocol, path, `www.` prefix) and counts are merged. Applies to both dashboard and per-link analytics.
- **Login page had no way back to the public page** — The login form was a dead end with no navigation. Added a "← Back to page" link below the sign-in button.

- **Owner views counted in analytics (#40)** — The public page had `revalidate = 60` (ISR), which made Next.js statically cache the page. During static generation, `cookies()` is not available, so `getSession()` couldn't detect the owner's session cookie — the owner detection check silently failed and every owner pageview was counted as a regular visitor. Fixed: added `export const dynamic = "force-dynamic"` to the public page so cookies and headers are read at request time. ISR cache headers for CDN-edge caching are preserved via the `headers()` config in `next.config.ts`.
- **Public page crashed with `require is not defined`** — The root layout's `generateMetadata()` used `await import("@/server/queries")` which Turbopack bundled as CJS, crashing in the ESM runtime. Fixed: converted to a static import.
- **`/favicon.ico` returned HTML instead of an image** — Without a route handler, Next.js fell through to the catch-all route and returned the page HTML with a 200 status. Browsers cached that response aggressively and never re-requested, so the custom favicon never appeared in the browser tab. Fixed: added a `/favicon.ico` route handler that 302-redirects to the custom favicon (or default fallback), with host-aware URLs derived from request headers and `no-cache` headers so changes are picked up immediately.
- **Custom favicon ignored by browsers despite correct HTML** — When a custom favicon was set, the default `favicon-32.png` and `favicon-16.png` link tags remained in the HTML alongside it. Chrome's favicon selection algorithm picks the icon with the best `sizes` match, so it preferred the explicitly sized defaults over the custom icon. Fixed: when a custom favicon is set, it becomes the ONLY `<link rel="icon">` in the document, with `sizes="16x16 32x32 48x48 96x96 150x150 192x192"` and the correct `type` attribute. SVG favicons use `sizes="any"`.
- **Slug pattern regex invalid in Chrome `/v` mode** — The `[a-zA-Z0-9_-]+` pattern on the slug input had an unescaped hyphen that Chromium's new regex engine rejected. Fixed: escaped the hyphen.
- **Setup page showed duplicate logo and title** — Both `page.tsx` and `setup-form.tsx` rendered their own logo + heading, and both had nested `min-h-screen` centering creating a massive gap. Fixed: removed the duplicate header from `page.tsx`; `setup-form.tsx` now owns the entire layout.
- **allowedDevOrigins didn't strip protocol/port** — The `DEV_ORIGINS` env var contains full URLs (`http://192.168.x.x:3000`) but Next.js expects bare hostnames. Fixed: the config now strips protocol and port before passing to `allowedDevOrigins`.
- **Lighthouse CI "Seed database" step crashed with `no such table: users`** — The seed script (`src/scripts/seed-lighthouse.ts`) ran before the Next.js server started, but the DB tables are only created when the server boots (via `instrumentation.ts` → `migrate()`). The seed hit a completely empty SQLite file and crashed on its first query. Fixed: the seed script now runs migrations itself before inserting data, mirroring the same `migrate()` call `instrumentation.ts` uses.
- **Migration Wizard vulnerable to case-variant dangerous URL schemes (code-scanning #14)** — The `javascript:` scheme filter was case-sensitive, so `JavaScript:` or `JAVASCRIPT:` bypassed it. Fixed: replaced with a case-insensitive regex that also blocks `data:` and `vbscript:` schemes.
- **Link dialog schedule inputs overflowed on small screens** — "Show from" and "Hide after" sat side-by-side on `sm+` breakpoints, pushing the dialog past the viewport height on mobile. Fixed: inputs now always stack vertically (Show from on top, Hide after below).

## [1.1.5] - 2026-07-25

### Added

- **Lighthouse CI workflow (#39)** — Added `.github/workflows/lighthouse.yml` with `lighthouserc.json` config. Runs Lighthouse audits on PRs targeting main and nightly on main. Includes a seed script (`src/scripts/seed-lighthouse.ts`) that creates a minimal profile + slug so Lighthouse audits a real public page instead of a /setup redirect. Reports uploaded as artifacts.
- **Bot detection tests (37 tests)** — `src/lib/__tests__/bot-detect.test.ts` covers 21 known bot UAs (all match), 10 real browser UAs (none match), and edge cases (empty UA, whitespace, word-boundary false positives). Ensures the analytics bot filter doesn't over-filter legitimate traffic.

### Changed

- **Dependency bumps** — `lucide-react` 1.24→1.25, `shadcn` 4.13→4.13.1 (patch-level), `next` 16.2.10→16.2.11 (patch — resolves multiple Next.js security advisories), `eslint-config-next` 16.2.10→16.2.11. Supersedes Dependabot PR #43.
- **Lighthouse thresholds tuned** — Performance set to warn at 0.7 (not 0.8) because third-party embeds (YouTube, Spotify) on user pages can load 1.4MB of third-party JS that the app doesn't control. Accessibility remains error-gated at 0.9. SEO set to warn at 0.7 for pages like /login that legitimately lack meta tags.
- **Code cleanup from Herald audit** — 9 unused `cn` imports, 4 commented-out code blocks, and 4 flagged unused vars (`chartData`, `avatarSrc`, `jsonLd`, `anchorHtml`) were verified as already addressed during v1.1.4. No changes needed.

### Fixed

- **17 unused `React` imports removed** — Next.js 16 uses the automatic JSX runtime and does not require `import * as React` in component files. Removed from all 17 files where React was imported but never referenced. No behavior change.
- **4 redundant boolean comparisons simplified** — `theme-tokens.ts` (glow/noise enabled checks) and `links.ts` (isHighlighted/isActive Zod transforms) used verbose `=== true || === "true" || === 1 || === "1"` chains. Extracted into a shared `truthy()` helper in `src/lib/utils.ts`. Same behavior, cleaner code.
- **Nested ternaries in `links-manager.tsx` replaced with lookup maps** — The link type label and placeholder chains (6 nested ternaries each) were converted to `Record<string, string>` lookup maps with fallback defaults. Improves readability and makes adding new link types trivial.
- **`confirm()` in theme-manager replaced with Dialog** — The native `confirm()` dialog for theme deletion was replaced with a styled shadcn/ui Dialog component, matching the rest of the admin UI. Better mobile UX, no main-thread blocking.
- **`confirm()` in data-manager replaced with Dialog** — The restore-backup and clear-analytics confirmation dialogs also used `window.confirm()`. Both replaced with styled shadcn/ui Dialog components for consistency with the theme-manager fix.
- **Phone placeholder genericized** — The Morocco-specific phone placeholder `+212****0000` in the link editor was replaced with the international `+1 (555) 000-0000`. LinkBreeze targets international users.
- **Inter font now loaded (default theme font was missing)** — The DB schema defaults `fontFamily` to "inter" and the admin UI lists it as the first option, but `layout.tsx` never loaded it via `next/font/google`. Users selecting "Inter" got a system-ui fallback. Added `Inter` with `--lb-font-inter` variable and `preload: false`.
- **`getSession()` performance regression on public pageviews** — The owner-exclusion analytics feature called `getSession()` (which does a DB query) on every public page render, even for anonymous visitors. Fixed: all three tracking paths now check cookie existence first and only call `getSession()` when the `lb_session` cookie is present.
- **Analytics contaminated by dev traffic and ghost tracking path** — Two bugs inflated analytics counts. (1) The old client-side `/api/track` endpoint still accepted `type: "view"` events even though server-side tracking in `[slug]/page.tsx` had replaced it — duplicate and orphaned pageviews (page_id=NULL) were recorded whenever the endpoint was hit. Fixed: `/api/track` is now click-only; the view handler is removed entirely. (2) All three tracking paths (pageview server-side, `/api/track`, `/go/:id` redirect) recorded analytics during development, meaning every `npm run dev` visit inflated production-bound metrics. Fixed: every path now checks `process.env.NODE_ENV === "production"` before writing to the analytics tables. Dev/preview traffic is zero from now on.

### Security

- **Bot/crawler filtering in analytics (#41)** — Known bots and crawlers (Googlebot, Bingbot, social preview crawlers, SEO tools, etc.) are now filtered out before recording pageviews and clicks. Prevents inflated metrics from automated traffic. Conservative pattern matching avoids over-filtering legitimate visitors.
- **Owner view/click exclusion from analytics (#40)** — Authenticated admin sessions are no longer counted in analytics. When the owner views their own page or clicks their own links, the event is skipped. Prevents self-inflated metrics.
- **Dependabot alert #3 patched (brace-expansion DoS)** — Added npm override forcing `brace-expansion >= 5.0.7` to resolve the high-severity ReDoS vulnerability in the transitive dependency.
- **Dependabot alert #4 patched (@hono/node-server path traversal)** — Added npm override forcing `@hono/node-server >= 2.0.5` to resolve the medium-severity path traversal vulnerability in the transitive dependency.

## [1.1.4] - 2026-07-22

### Added

- **Theme system regression matrix (69 tests)** — `src/lib/__tests__/theme-matrix.test.ts` locks the contract between the 4 layers that must agree: DB schema defaults, Zod validator (`customSchema`), token resolver (`resolveThemeTokens`), and the 9 preset definitions. Every past theme bug (v1.1.2 density enum, v1.1.3 `soft` shadow missing, v1.1.3 `fontScale` numeric fallthrough) was a drift between these layers that shipped to production undetected. The matrix covers 4 dimensions: (1) enum conformance — every schema default and documented enum value is a valid Zod enum member; (2) preset coverage — every preset passes Zod, produces non-default CSS vars where it declares overrides, and resolves `fontScale`/`shadowStrength` to their intended values (not the default fallthrough); (3) resolver edge cases — `resolveFontSize`, `resolveSpacing`, `resolveShadow` each handle every input shape including null/garbage without `NaN`; (4) schema↔Zod↔resolver drift guard — the full schema-default theme resolves to a complete CSS-var token set with no empty/undefined values.
- **Image overlay regression test (4 tests)** — `src/lib/__tests__/theme-image-overlay.test.ts` pins the fixed overlay render behavior across 50%/100%/0% opacity and the no-image-URL fallback path.
- **PWA manifest upgrade for public pages (closes #18)** — The manifest existed but was missing the fields browsers actually check before showing the install / "Add to Home Screen" prompt. Without `start_url` and `scope`, Android and Desktop Chrome treat the page as non-installable; without a maskable icon, Android adaptive-icon launchers letterbox the icon. Added: `start_url` (`/?source=pwa`, also lets us measure PWA launches in analytics), `scope: "/"`, `orientation: "portrait"`, `categories`, `lang`, `dir`, a maskable variant of the 192px and 512px icons, and a `screenshots` entry (Android shows this in the install sheet). `theme_color`/`background_color` kept as the existing Aurora dark navy.
- **Manifest contract test** — `src/lib/__tests__/manifest.test.ts` (6 tests) parses `public/site.webmanifest` and asserts the install-prompt-critical fields. Prevents silent regressions where someone edits the JSON and breaks installation.

### Changed

- **Extracted theme presets and Zod schema into importable modules** — `src/lib/theme-presets.ts` (the 9 presets + shared `base` defaults) and `src/lib/theme-schema.ts` (the `customSchema` Zod validator + `cssColor` helper) are now pure data modules importable by tests without dragging in the `"use server"` / `"server-only"` runtime. `src/server/queries/index.ts` and `src/server/actions/theme.ts` import from these modules instead of defining the data inline. Zero behavior change — the refactor exists to make the regression matrix possible.
- **Dependency bumps** — Merged #36 (minor-and-patch group) and #33 (vitest 3.2.6 → 4.1.10). Closed #37 (typescript 7 — blocked by `@typescript-eslint` peer range), #26 (eslint 10 — blocked by `eslint-plugin-react` incompatibility), and #22 (node 26-alpine — non-LTS, conflicting).
- **Disabled theme font preloading (perf)** — All 9 Google Fonts (21 woff2 files, 483KB) were preloaded on every page via `next/font`'s default `preload: true`, even though the public page only uses ONE font (the active theme's `fontFamily`). This was 62% of total page transfer. With `preload: false`, the `@font-face` rules remain in the CSS (so the admin theme picker still works), but the browser only downloads a font when an element actually renders with that font-family. Net effect on the public page: ~3 woff2 requests (~20KB) instead of 21 (483KB) — ~460KB saved per page view (~60% transfer reduction). No behavior change, no schema change. Admin theme picker: fonts swap in on-demand (~100ms FOUT when switching themes, no layout shift).

### Fixed

- **CI badge showed failing on default branch** — The CI workflow only triggered on `pull_request`, so no runs ever executed on `main`. Shields.io checks the default branch, causing the README badge to always show "failing". Added `push: branches: [main]` to the workflow triggers.
- **Theme import card didn't state the expected file format (#44)** — The import/export card description was ambiguous about which file type to use, leaving users unsure whether to upload a zip or JSON. The system is and has always been JSON-only (export produces `.json`, import parses JSON). The card description and a new helper line under the import button now explicitly say `.json`. No behavior change — the input's `accept` attribute was already correct.
- **Type error in manifest test** — `manifest.test.ts` called `.startsWith()` on `start_url` typed as `unknown`, causing a `tsc --noEmit` failure. Added the same `as string` cast already used for `short_name` on the line above.
- **Image background overlay rendered as a dark gradient instead of a translucent tint** — `resolveBackground()` for `backgroundType: "image"` built the overlay layer as `linear-gradient(#00000080, #000000)` — alpha encoded on the first stop only, second stop dropped the alpha entirely. The result was a near-opaque dark layer over the image instead of the intended uniform translucent tint. Additionally, `overlayOpacity: "0"` still rendered the broken gradient instead of skipping the overlay. Fixed: the overlay is now a uniform translucent layer (`#color+alpha` at both gradient stops), and opacity 0 or missing skips the overlay entirely. No preset used the image background type, so no existing theme was visually affected — the bug only hit user-created image-background themes.
- **`mode` column comment claimed `auto` was supported** — The schema comment on `themes.mode` documented `dark, light, auto`, but the Zod validator, the admin UI select, and the resolver only handle `dark` and `light`. A value of `auto` would have triggered the same silent `safeParse` reject as the v1.1.2 density bug (entire save payload rejected, error swallowed client-side). Comment aligned to reality; no behavior change, no migration.
- **Click-inflation attack on `/go/:id` (security)** — The JS-free click redirect endpoint had no rate limit, while the JS fallback (`/api/track`) was throttled at 60 req/min/IP. A bot could hammer `/go/:id` in a loop to inflate any link's `clicksCount` and raw `analyticsClicks` rows indefinitely — polluting both the admin dashboard and the export. Fixed: `/go/:id` now applies the same `rateLimit("go:<ip>", 60, 60_000)` check as `/api/track`. Rate-limited requests still redirect (real users never hit the limit), but the click is not recorded. This closes the integrity gap between the two click-tracking paths.
- **"Zero client JavaScript" claim was inaccurate** — The README, CONTRIBUTING, ADR-0001, and CHANGELOG all claimed the public page ships "zero client JS", but `build-link-card.ts` emits an inline `onclick="navigator.sendBeacon(...)"` for non-http links (mailto, tel, sms). While no JS *bundle* ships (no React runtime), inline handlers are technically client-side JS. Claims softened to "zero client-side JS bundles" / "no React runtime" across all docs. The architecture is unchanged; only the marketing/technical claims were corrected to match reality.

## [1.1.3] - 2026-07-16

### Changed

- **Theme export schema density default** — The `exportableThemeSchema` in backup/restore defaulted `density` to `"comfortable"` (the old invalid value that v1.1.2 migration 0006 fixed). Importing a theme file with a missing density field would create a theme with an invalid value. Default is now `"normal"`, matching the schema and Zod enum.

### Fixed

- **Fresh Docker deploy crash — migration 0005 fails (crash on startup)** — Migrations 0005 and 0006 were hand-written (not generated by `drizzle-kit`) and missing the `--> statement-breakpoint` markers that Drizzle's migrator uses to split statements. `better-sqlite3` only accepts one statement per `prepare()` call, so the multi-statement files threw `"You can only execute one statement at a time"`, crashing the server before it could serve a single request. Every new deployment was affected. Added the missing breakpoints. Fixes #38.
- **Preset themes ignored numeric `fontScale` values** — Presets like Editorial Paper (`"110"`), Retro Sunset (`"120"`), and others stored `fontScale` as a numeric percentage string, but `resolveFontSize()` only handled `"sm"`, `"md"`, `"lg"`. Numeric values silently fell through to the default `15px`, losing the intended font sizing. The resolver now treats numeric strings as percentages (100 = 15px, 110 = 16.5px, etc.).
- **`"Pastel Soft"` preset rendered with wrong shadow** — The theme action enum and the preset both use `shadowStrength: "soft"`, but `resolveShadow()` had no case for it — it fell through to the medium shadow. Added the missing `"soft"` case.
- **`resolveSpacing()` density values mismatched the enum** — The density enum allows `"compact"`, `"normal"`, `"relaxed"`, but the resolver checked for `"compact"`, `"spacious"`, `"comfortable"`. The `"relaxed"` value (more spacing) was dead — it fell to the default alongside `"normal"`. Fixed: `"relaxed"` now correctly returns `16px` (was `12px`), `"compact"` stays `8px`, `"normal"` stays `12px`.
- **Theme name conflicts on duplicate and import** — Duplicating a theme or importing a theme file did not check whether a theme with the same name already existed, creating silent duplicates (e.g. 5 themes all named "Aurora copy"). Both operations now reject names that are already in use with a clear error.

### Security

- **OG image and sitemap routes ignored `BASE_URL` env var** — The public page and QR route both gained `BASE_URL` support in v1.1.2 to prevent host-header injection behind reverse proxies. Two routes were missed: the OpenGraph image generator (`/[slug]/opengraph-image`) and `sitemap.xml`. Both fully trusted `x-forwarded-host`/`x-forwarded-proto`, allowing an attacker to make the instance generate OG images or sitemap URLs pointing at an attacker-controlled domain. Both now check `BASE_URL` first.
- **Password complexity requirements (setup + change password)** — Password creation only enforced `min(8)` characters with no complexity checks. A password like `aaaaaaaa` was accepted. Both setup and change-password now require at least one lowercase letter, one uppercase letter, and one number. Closes #7.
- **Open-redirect hardening on `/go/:id`** — The JS-free click tracking redirect (`/go/:id`) did `NextResponse.redirect(link.url)` without a runtime protocol check. The create/update validators already block non-http schemes, but a stale or tampered DB row could bypass that. The route now validates the URL protocol at redirect time — only `http:` and `https:` are allowed.

## [1.1.2] - 2026-07-11

### Added

- **Unique visitor count in dashboard** — The `visitorHash` was collected on every pageview since v1.0.0 but the dashboard only displayed total views (`COUNT(*)`). The Views card now shows the unique visitor count alongside total views, computed via `COUNT(DISTINCT visitorHash)`.
- **JS-free click tracking via `/go/:id` redirect** — Click tracking previously relied entirely on client-side `navigator.sendBeacon`, which fails for JS-disabled browsers, crawlers, and in-app browsers that block it. Public-page http(s) links now use `/go/:id` as their href — the server records the click (same visitor-hash logic as `/api/track`) then 302-redirects to the real URL. Non-http links (mailto, tel, etc.) still use the sendBeacon fallback. Closes #19.
- **`vCard` and `File` link types in the admin UI** — The schema and URL validator supported 8 link types, but the admin dropdown only exposed 6 (`vcard` and `file` were missing). Both are now selectable when creating or editing a link.
- **`npm run seed` script** — The demo seed script (`src/scripts/seed-demo.ts`) existed but had no `package.json` entry. Added `"seed": "tsx src/scripts/seed-demo.ts"` so developers can populate a fresh database with one command. Closes #8.
- **Skip-to-content link on public pages** — Keyboard and screen-reader users previously had to tab through the entire header before reaching link content. A visually-hidden "Skip to content" link now appears on focus, jumping directly to the main content area.
- **`prefers-reduced-motion` support for link card hover** — Hover effects on public-page link cards (lift, scale, glow transforms) were applied via inline JS and ignored the user's OS-level reduced-motion preference. Hover transforms are now disabled for users who request reduced motion, matching the existing aurora-background behavior.

### Changed

- **Dependency updates** — Merged 3 safe Dependabot PRs: recharts 3.9.1→3.9.2 + shadcn/ui 4.12→4.13 (minor), `@types/bcryptjs` 2.4.6→3.0.0 (dev types), `@types/node` 20.19.43→26.1.0 (dev types).
- **`next.config.ts` optimizations** — Disabled `poweredByHeader` (no longer leaks `X-Powered-By: Next.js`). Enabled `experimental.optimizePackageImports` for `lucide-react`, `recharts`, and `@dnd-kit/*` to tree-shake barrel exports and reduce client bundle size.
- **CSS-based link card hover** — Replaced inline `onmouseover`/`onmouseout` JS handlers on public-page link cards with CSS `:hover` rules using class + data attributes. This produces cleaner HTML output (2 fewer inline JS attributes per card) and enables proper `prefers-reduced-motion` gating.

### Fixed

- **Themes not seeding on fresh deploy (only Aurora appears)** — `getActiveTheme()` had an inline fallback that seeded a single Aurora theme when the database was empty. Because the public page calls `getActiveTheme()` before the admin theme page is ever visited, this single Aurora prevented `seedThemesIfEmpty()` from running (it saw count > 0 and bailed). The other 8 presets never appeared. Fixed: `getActiveTheme()` now delegates to `seedThemesIfEmpty()`, making it the single source of truth. Migration 0006 also cleans up orphan fallback themes on existing broken deployments. Fixes #35.
- **Theme changes silently not saved (Zod validation failure)** — The `density` column defaulted to `"comfortable"` in the schema, but the Zod validator only accepts `"compact"`, `"normal"`, `"relaxed"`. Themes seeded by the fallback path inherited this bad value. When the user saved, the Select submitted `"comfortable"`, Zod's `safeParse` rejected the entire payload, and the error was silently swallowed by the client. Fixed: schema default changed to `"normal"`, migration 0006 repairs existing data, and the save handler now surfaces errors instead of hiding them. Fixes #35.
- **Floating promise in subscriber clear** — `clearAllSubscribers()` called the async `clearSubscribers()` without `await`, returning success before the DB delete completed and silently swallowing errors. The delete is now awaited.
- **CSV export link lint error** — The subscriber export `<a>` tag was flagged by `@next/next/no-html-link-for-pages`. Added the `download` attribute (semantically correct for file downloads — the linter recognizes `download` as an explicit non-navigation marker).
- **Unused eslint-disable in OG image route** — Removed a stale `// eslint-disable-next-line @next/next/no-img-element` directive that was no longer needed (the rule doesn't trigger inside `ImageResponse`).
- **Missing lint step in CI** — The CI pipeline ran `tsc`, tests, and build but never `npm run lint`, allowing lint errors to slip into `main`. Lint now runs on every PR.

### Security

- **Forgeable admin sessions when `SECRET_KEY` unset (HIGH)** — In production, `getSecret()` silently fell back to `DEFAULT_SECRET`, a constant in the public source code. Anyone who read the repo could forge a valid admin session cookie and bypass authentication entirely. Production now throws on first use instead of degrading to a known secret. Dev mode is unaffected.
- **XFF-spoofable login brute-force protection (HIGH)** — The per-IP login rate limit (`5/min`) could be bypassed by rotating the `X-Forwarded-For` header on bare-IP deploys (the recommended Docker path). Added a global login throttle (`15/min` regardless of IP) that closes this vector for LinkBreeze's single-admin model.
- **Subscriber table-flooding DoS (HIGH)** — The public `subscribe()` endpoint had no rate limit, and `subscribers.email` had no unique constraint — the code's duplicate-catch never fired. Added a per-IP rate limit (`10/min`) and a `UNIQUE INDEX` on `email` (migration 0005). Existing duplicates are deduplicated on migrate.
- **Stored XSS via backup restore (MEDIUM)** — `restoreBackup()` validated row shapes but not link URL schemes. A shared backup file could carry `javascript:` URLs that render as clickable links on the public page, executing script in every visitor's browser. Link URLs in backups are now re-validated through `isAllowedLinkUrl()` and offending rows are dropped.
- **Host-header injection → QR phishing / OG spoofing (MEDIUM)** — Both `getOrigin()` functions (QR API + public page metadata) fully trusted `X-Forwarded-Host`/`X-Forwarded-Proto`. An attacker could make the instance generate a QR code pointing to `evil.com`, or emit `og:url`/canonical/JSON-LD pointing at an attacker domain. Added an optional `BASE_URL` env var — when set, forwarded host headers are ignored entirely.

## [1.1.1] - 2026-07-07

### Changed

- **Migration files explicitly bundled** — Added `outputFileTracingIncludes` to `next.config.ts` to guarantee Drizzle migration `.sql` files and `meta/_journal.json` are included in the standalone build output (previously worked by accident; now explicit).
- **`db:migrate` script** — Added `npm run db:migrate` (`drizzle-kit migrate`) to `package.json` for manual migration in dev/CI. Docker deployments auto-migrate on boot and don't need this.

### Fixed

- **Fresh-deploy crash (Next.js error 1654975601)** — On a fresh `docker compose` deploy, the SQLite database was created empty with no tables because migrations never ran. The first query threw `no such table: settings`, surfacing as a generic "server error" page with code 1654975601 on every reload. Migrations now run automatically on server startup via a Next.js instrumentation hook (`src/instrumentation.ts`). The setup wizard appears on first launch as documented. Fixes #34.
- **Healthcheck false-positive** — The `/api/health` endpoint returned 200 without touching the database, so a broken instance (missing tables, corrupt DB) still reported "healthy". The health route now probes the DB with `SELECT 1` and returns 503 on failure, so the Docker healthcheck reflects real readiness.

## [1.1.0] - 2026-07-06

### Added

- **Full theme system rework** — Complete redesign of the theming engine with a CSS custom property (`--lb-*`) token system. Every visual property is now a token consumed by public page components — no hardcoded colors, radii, or shadows anywhere.
  - **9 preset themes** (was 5): Aurora (animated flagship), Glassmorphism, Neon Cyberpunk, Editorial Paper, Terminal Mono, Pastel Soft, Brutalist, Retro Sunset, Minimal Light
  - **8 background types** (was 4): solid, gradient, radial, mesh, aurora, animated gradient, image, pattern — with angle, overlay color, and overlay opacity controls
  - **6 link/card styles** (was 3): pill, rounded, sharp, glass, outline, neon
  - **10 curated Google Fonts** (was 1): Inter, Poppins, Playfair Display, JetBrains Mono, Space Grotesk, DM Sans, Lora, Bebas Neue, Sora, Outfit — loaded server-side via `next/font/google` (zero client JS, no layout shift)
  - **Full color palette**: accent, secondary, text, muted text, card background, card border — all accept hex or rgba
  - **Typography controls**: font scale (80-150%), weight (300-700), letter spacing (-2 to 5)
  - **Card controls**: button size (sm/md/lg), corner radius, border width, shadow strength (none/subtle/soft/medium/strong), hover effect (lift/scale/glow/none)
  - **Layout controls**: container width, alignment (left/center/right), density (compact/normal/relaxed)
  - **Effects**: glow toggle with custom color, glass blur, noise texture, reveal animation
  - **Theme duplication**: clone any theme (preset or custom) as a new editable copy — presets are protected from deletion
  - **Theme deletion**: custom (non-preset) themes can be deleted from the gallery
- **External analytics injection** — Paste a Plausible, Umami, Matomo, or Google Analytics `<script>` snippet into Settings; it's injected onto your public page. Self-hosters no longer need to choose between LinkBreeze's built-in analytics and their existing stack.
- **Custom CSS injection** — Raw CSS textarea in Settings, injected as a `<style>` tag on the public page. Power users can fine-tune anything the theme customizer can't reach.
- **20 new social icons** — Threads, Bluesky, Mastodon, Reddit, Facebook, Pinterest, Snapchat, Patreon, Substack, Gumroad, Behance, Dribbble, SoundCloud, Bandcamp, Vimeo, Signal, PayPal, Buy Me a Coffee, Ko-fi, Medium. Total platform count: 32 (was 12).
- **Link thumbnails** — Optional image URL per link. Renders as a card with the image on top, text below — like a Discord/Slack link preview.
- **Embed widgets** — New link type `embed`. Paste a YouTube, Spotify, SoundCloud, Vimeo, or Bandcamp URL and it renders as an inline iframe on your public page instead of a link.
- **Email capture** — Toggle in Settings adds an email signup form to the public page. Subscribers stored in SQLite, exportable to CSV from Settings. Free alternative to Linktree's $9/mo email feature.
- **Theme import/export** — Export any theme as JSON from the Theme page, import on another instance. Share presets without manual recreation. API endpoints at `/api/themes/export` and `/api/themes/import`.
- **Link scheduling UI** — The link scheduler existed in the database and query layer since v1.0.0 but had no admin UI. Added a Schedule toggle with datetime pickers (Show from / Hide after) in the link dialog, plus a "Scheduled" badge with clock icon on the links list.
- **DEV_ORIGINS env var** — For dev servers accessed via Tailscale or LAN IPs. Set `DEV_ORIGINS` in `.env` to allow Server Actions from external origins (see `.env.example`).

### Changed

- **Dependency updates** — Bumped `actions/checkout` from v4 to v7 (#21) Bumped `actions/setup-node` from v4 to v6 (#20)
- **Schema expanded** — Themes table rebuilt with ~25 new columns. Migration `0004_theme-rework.sql` handles the upgrade safely (copies existing data, new columns get sensible defaults). Old themes continue to work.
- **Token resolver** — New `src/lib/theme-tokens.ts` (466 lines) provides `resolveThemeTokens()` → CSS vars + keyframes, `buildThemeStyleBlock()`, `resolveBackground()` (handles all 8 types), `resolveFont()`, and animated background detection.
- **Public components refactored** — `build-link-card.ts`, `LinkCard.tsx`, `ProfileHeader.tsx`, `SocialIcons.tsx`, `EmailCapture.tsx`, and `page.tsx` now consume `var(--lb-*)` tokens instead of hardcoded values.
- **Customizer UI overhauled** — `theme-manager.tsx` rebuilt with 6 organized sections (Background, Colors, Typography, Card Style, Layout, Effects). Includes color pickers, font radio picker with previews, sliders, toggles, and selects for all enum fields.
- **Server actions expanded** — `customizeActiveTheme` now accepts all new fields with Zod validation. New `duplicateActiveTheme` and `deleteCustomTheme` actions added.
- **Export/import updated** — Theme JSON now carries all new fields. Backward compatible with old exports.
- **Seed demo** — Now seeds the Aurora preset instead of the old 5-theme set.
- **Removed dead `metadata` column** — The `links.metadata` column (default `"{}"`) was defined in the schema but never read or written anywhere in the codebase. Removed from the schema, backup Zod validation, and a new migration (`0001_remove_link_metadata.sql`) drops it from existing databases.
- **allowedDevOrigins via env** — `next.config.ts` now reads `DEV_ORIGINS` from the environment instead of hardcoding IPs. Safe to commit — no private IPs in the repo.
- **README features + comparison** — Features list expanded from 8 to 14 items. Comparison table expanded from 12 to 17 rows, now includes External Analytics, Email Capture, Embed Widgets, Link Thumbnails, Custom CSS, and Themes Import/Export.
- **CONTRIBUTING.md** — Updated theme submission instructions to reference the new dedicated theme export feature and full token-based theme properties table.
- **Migrations** — Four new migrations: `0001_remove_link_metadata`, `0002_add_image_url_to_links`, `0003_add_subscribers_table`, `0004_theme-rework`. All run automatically on next startup.

### Fixed

- **Atomic click tracking** — `recordClick()` now wraps the analytics insert and the `clicksCount` increment in a single `db.transaction()`. Previously these were two separate statements that could drift out of sync if the second failed, leaving the denormalized count permanently wrong.
- **JSON-LD XSS hardening** — The structured data `<script>` tag now escapes `<` characters (`\u003c`) in the `JSON.stringify` output, preventing profile text fields (displayName, bio) from breaking out of the script context.
- **Embed widget rendering** — Spotify embeds now use a fixed 152px height (matching Spotify's native player) instead of a bloated 16:9 aspect ratio container that left empty space. YouTube embeds use `youtube-nocookie.com` with `rel=0` and `modestbranding=1` for a cleaner, privacy-respecting player. Redundant title captions removed for YouTube/Spotify (they show their own title). Fixed Spotify double `/embed/` path bug that caused 404s.
- **Link thumbnail layout** — Cards with thumbnail images now use block layout (image on top, content row below) instead of `flex-wrap`, which was shrinking the image and crushing the title text between the image and the card's right border.

## [1.0.2] - 2026-07-04

### Added

- **Comprehensive test suite** — 134 tests across 16 files covering pure functions, server actions, and security validation. Includes tests for: rate limiting, visitor hashing, device detection, geo/country lookup, QR code generation, session tokens, social icon detection/normalization, link URL scheme validation, demo mode guards, upload content types, version reading, theme backgrounds, link card rendering, auth (login + setup), settings updates, link CRUD, and backup validation. (Closes #6, #17)

### Changed

- **Atomic transactions** — `reorderLinks()`, `clearAnalytics()`, and `setActiveTheme()` now wrap their multi-statement operations in `db.transaction()`. Crashes mid-operation no longer leave the database in an inconsistent state.
- **Proactive rate-limit cleanup** — The rate-limit bucket map now sweeps expired entries every 30 seconds (when map exceeds 100 entries) instead of only on overflow at 10,000 keys. Prevents slow memory growth under sustained traffic.
- **Shared analytics-range module** — Extracted duplicated `sinceExpr`/`parseRange` logic from `queries/index.ts` and `api/analytics/export/route.ts` into `src/lib/analytics-range.ts`. Single source of truth for range types and SQL window expressions.
- **Code cleanup** — Moved `updateUserPassword` from server actions to queries layer. Removed unused `inArray` import and dead `void inArray` statement. Documented `getActiveProfile` as an intentional extension point.
- **Dependency overrides** — Added npm overrides for `postcss >= 8.5.10` and `esbuild >= 0.25.0`. `npm audit` now reports 0 vulnerabilities (was 6 moderate).
- **Proxy documentation** — Documented the defense-in-depth session validation split: middleware checks signature + expiry (fast first gate), `getSession()` checks password version (authoritative second gate).
- **CI workflow** — Now runs `npm run test` (Vitest) in addition to tsc + build. Closes #2.
- **Docker release workflow** — Added `.github/workflows/release.yml` that builds and pushes Docker images to GHCR automatically on tag push (`v*`). Tags both `latest` and the version number.

### Fixed

- **Login sidebar bug** — When already logged in, visiting `/login` showed the login form inside the admin sidebar layout. Now `/login` is a server component that redirects authenticated users to `/dashboard`. The page split into `page.tsx` (server, session check) + `login-form.tsx` (client, interactive form).
- **Health endpoint version** — `/api/health` now reads the version dynamically from `package.json` instead of returning a hardcoded `1.0.0`.
- **Backup version constant** — `exportBackup()` now uses `SUPPORTED_BACKUP_VERSION` instead of a hardcoded `1` literal.

### Security

- **SVG upload XSS eliminated** — Removed `.svg` from the upload allowlist entirely. Added `Content-Security-Policy: default-src 'none'` and `X-Content-Type-Options: nosniff` headers to the uploads serving route as defense-in-depth. Closes #15.
- **Login rate limiting** — The login form is now rate-limited to 5 attempts/min per IP, preventing brute-force password attacks. Closes #1.
- **Production secret key warning** — When `SECRET_KEY` is unset in production, both `session-token.ts` and `visitor.ts` now log a loud console warning. The `/api/health` endpoint exposes `secretKeySet: boolean` so monitoring tools can detect misconfiguration. Closes #9.
- **Backup row validation** — `restoreBackup()` now validates every row (profiles, links, settings, themes) with Zod schemas before the database transaction. Malformed backup files are rejected instead of corrupting the DB.
- **Analytics foreign key** — `analytics_clicks.link_id` now has a foreign key reference to `links.id` with `ON DELETE CASCADE`. `deleteLink()` also explicitly cleans up orphaned analytics rows for databases created before this constraint existed.

## [1.0.1] - 2026-07-03

### Added

- **robots.txt** — Dynamic route that blocks admin and API routes, allows the public slug page.
- **sitemap.xml** — Dynamic route that reads the public slug from the database and derives the origin from request headers.
- **LICENSE** — MIT license file (was referenced in README but missing from repo).
- **TROUBLESHOOTING.md** — Dedicated troubleshooting guide: Docker Desktop issues, PowerShell syntax errors, password recovery (3 methods), port conflicts, database corruption, analytics tracking, cache issues.
- **Upgrade guide** — TROUBLESHOOTING.md now documents the upgrade process (Docker pull + auto-migrations, non-Docker steps).
- **Slug management docs** — TROUBLESHOOTING.md now explains how slug changes work (no redirects, reserved words, QR code behavior).
- **Accessibility docs** — SECURITY.md now documents the accessibility posture (Radix/shadcn WAI-ARIA, keyboard nav, WCAG AA contrast, screen-reader support).
- **GitHub Discussions** — Enabled on the repository.
- **GitHub social preview** — Uploaded 1280x640 banner image for link unfurls on Reddit, Twitter, Discord.

### Changed

- **README.md** — Split docker-compose instructions into Option A (pre-built image) and Option B (build from source). Added "Make sure Docker is running" note. Added CHANGELOG and TROUBLESHOOTING links to documentation section. Added CI badge.
- **PR template** — Now requires `npm run test` to pass.
- **Dependencies** — Bumped via Dependabot: next 16.2.9→16.2.10, react 19.2.4→19.2.7, react-dom 19.2.4→19.2.7, lucide-react 1.22→1.23, recharts 3.9.0→3.9.1, tailwindcss 4.3.1→4.3.2, eslint-config-next 16.2.9→16.2.10.
- **Dependabot** — Added `.github/dependabot.yml` for automated weekly npm/Docker and monthly GitHub Actions updates.

### Removed

- **47 MB of unused screenshots** — Deleted uncompressed PNG variants that were never referenced by the README.

### Fixed

- **Middleware session validation** — Middleware now verifies the HMAC signature and expiry of the `lb_session` cookie instead of only checking for its existence. Forged or expired cookies are redirected to `/login`. Token logic extracted into `src/lib/session-token.ts` (shared between `proxy.ts` and `auth.ts`).
- **README Docker commands** — Fixed PowerShell compatibility: `docker run` command now works on Windows (backslash continuation broke PowerShell). Added single-line variant.
- **docker-compose.yml** — Now pulls pre-built image from GHCR by default instead of building from source.
- **SECURITY.md** — Corrected 4 false claims: session expiry is 30 days (not 7), X-Frame-Options is `SAMEORIGIN` (not `DENY`), removed non-existent login rate-limiting claim, removed false Docker read-only filesystem claim. Added honest "Known Limitations" section.
- **CONTRIBUTING.md** — Removed references to non-existent `public/themes/` directory (themes are DB-stored, managed via admin panel). Added test step to PR checklist.
- **package.json** — Version aligned with release: `0.1.0` → `1.0.0`.
- **Theme submission template** — Updated to reflect actual admin-panel-based theme workflow.

### Security

- **Session invalidation on password change** — Changing your password now invalidates all existing sessions (stolen or old cookies become instantly invalid). Implemented via a `sessionVersion` counter in the settings table: tokens include the version at issue time, `getSession()` rejects mismatches.
- **QR download endpoint rate limiting** — The `/api/qr` endpoint is now rate-limited to 30 requests/min per IP. Prevents CPU abuse from repeated QR generation.
- **CSRF protection documented** — SECURITY.md now documents that Next.js 16 Server Actions verify `Origin`/`Host` headers on every non-GET submission (built-in framework protection, no manual token needed).
- **SECURITY.md vulnerability reporting** — Added response timeline (48h ack, 7-day status, 30/90-day patch targets) and safe harbor clause for security researchers.
- **Social icon URL detection hardened** — Platform detection in `social-icons.ts` now uses proper hostname matching (`isHost()`) instead of `.includes()`. Prevents `evil.com/github.com` from matching as GitHub. Resolves 11 CodeQL alerts.
- **CI workflow permissions** — Added explicit `permissions: contents: read` to CI workflow. Follows least-privilege principle. Resolves 1 CodeQL alert.
- **Link URL scheme validation** — Link URLs are now validated by type: only `http:`/`https:` for regular links, `mailto:` for email, `tel:` for phone, `wa.me` for WhatsApp, etc. Blocks `javascript:` and `data:` URI XSS. (Contributed by @MFA-G, PR #29, closes #14)
- **Backup version validation** — Restoring a backup from an incompatible future version is now rejected before any database transaction runs. (Contributed by @vku2018, PR #30, closes #16)

## [1.0.0] - 2026-07-01

### Added

- **Link management** — Add, reorder, toggle, and customize unlimited links with drag-and-drop (dnd-kit)
- **Privacy-first analytics** — Page views, click tracking, referrers, device type, country detection. No cookies, no raw IP storage (SHA-256 with daily-rotating salt)
- **5 built-in themes** — Midnight, Sunset, Ocean, Mono, Forest presets
- **Full theme customizer** — Colors, fonts, backgrounds (solid/gradient/pattern), link styles (rounded/sharp/glass), animations (lift/scale/none)
- **QR codes** — Auto-generated for public pages, downloadable as SVG or PNG
- **Link scheduling** — Schedule links to appear/disappear automatically
- **Admin dashboard** — Overview stats, views chart with date range picker, per-link click analytics
- **Profile editor** — Avatar upload, display name, bio, badge text, social links
- **Setup wizard** — First-run setup creates the admin account in under 30 seconds
- **Settings page** — Page slug, SEO title/description, footer text, password change
- **Backup system** — Export/import full configuration (profile, links, settings, themes) as JSON. Transactional restore with rollback.
- **Analytics export** — CSV export of pageview and click data
- **Analytics retention setting** — Configurable retention window
- **Security headers** — HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy
- **CSP on SVG images** — Strips scripts/sandbox from served SVG to neutralize XSS
- **Health endpoint** — `/api/health` for Docker HEALTHCHECK and load balancer probes
- **OpenGraph images** — Auto-generated OG images per public page for social sharing
- **JSON-LD structured data** — ProfilePage schema for SEO
- **Docker image** — Multi-stage build, non-root user, HEALTHCHECK directive. Published to `ghcr.io/manak-hash/linkbreeze:latest`
- **CI pipeline** — TypeScript type-check + Next.js build on every PR
- **Issue templates** — Bug report, feature request, theme submission
- **4 ADR documents** — Technology decisions documented (Next.js, Drizzle, SQLite, abstraction layer)
- **Framework**: Next.js 16 (App Router, Server Components, ISR)
- **Database**: SQLite via better-sqlite3 (WAL mode)
- **ORM**: Drizzle ORM (type-safe, zero-overhead)
- **UI**: shadcn/ui + Tailwind CSS 4
- **Public page**: No client-side JS bundles (no React runtime) — pure Server Components. http/https links use a JS-free `/go/:id` redirect for click tracking; mailto/tel links use a tiny inline `onclick` sendBeacon beacon.
- **Performance**: <300ms FCP target, ISR with 60s revalidation

### Security

- bcrypt password hashing (12 rounds)
- HMAC-signed session cookies with constant-time comparison (`crypto.timingSafeEqual`)
- httpOnly, SameSite cookie attributes
- Zod input validation on every server action and API route boundary
- Path-traversal-safe file upload resolution
- File upload restrictions: images only, 2 MB max, sanitized random filenames
- Rate limiting on analytics tracking endpoint (60 req/min per IP)