# 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 - **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 - **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. - **Dependency updates** — Overrides bumped for `fast-uri` ^3.1.7→^3.1.9 and a new `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. - **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 - **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). ## [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//`, 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 `//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/` 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=`, 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 ``, `