Skip to content

ADR-057 — Narrow exception to "no client storage": one functional cookie for explicit user-set locale override ​

Status · Accepted Date · 2026-05-10 Supersedes (in part) · TA.md "no cookies for user preferences" line (lines 159 and 281); narrows the absolute prohibition to a scoped exception Related ADRs · ADR-017 (i18n architecture), ADR-024 (URL-as-state for missions), ADR-029 (PWA + visit-counter in runtime memory), ADR-044 (URL-as-state for locale), ADR-055 (Science Lens — runtime-only) Closes · Issue #73 Gap 2 (cross-session persistence for explicit locale override)

Context ​

Issue #73 surfaced a friction point in Orrery's "no client storage" stance: a user who explicitly picks a non-default locale via the LocalePicker loses that preference on a fresh visit. Auto-detection from navigator.language works on first paint (already shipped in src/lib/locale.ts:resolveLocale), and bookmarks carry ?lang= cleanly, but a user who overrides their browser default and later types the bare URL again falls back to navigator.language.

Five existing ADRs and TA.md treat client storage as a hard non-negotiable:

  • TA.md:159 — "No user data. No accounts. No login. No localStorage. No sessionStorage. No cookies for user preferences. State is session-only and resets on reload."
  • ADR-017 — i18n architecture: locale lives in ?lang= URL param.
  • ADR-024 — mission URL contract: /fly?mission=id is the only mission state; rejected "last visited mission" because it would need localStorage.
  • ADR-029 — PWA visit counter held in runtime memory only "per CLAUDE.md 'do not use localStorage'."
  • ADR-044 — "Keep ?lang= as locale source-of-truth; no storage-based locale state."
  • ADR-055 — Science Lens deliberately ephemeral; lens-on resets on every page load. Considered a cookie, rejected as "fights 'no localStorage' spirit."

The rule has paid dividends: the codebase has stayed disciplined, no GDPR cookie banner is needed, the app has zero compliance surface, multi-device sharing works because URL = canonical state. Removing the rule wholesale would invite scope creep — once one cookie exists, "just one more for X?" becomes a recurring conversation that erodes the simplicity.

Decision ​

Allow exactly one cookie, scoped narrowly to the case the absolute rule was actively hurting.

The exception ​

FieldValue
Nameorrery_locale
ValueA supported locale code from SUPPORTED_LOCALES (e.g. nl, de, sr-Cyrl) — never anything else
SameSiteLax
Securetrue (production builds; omitted on http://localhost for dev)
HttpOnlyfalse (must be readable from document.cookie — there is no server)
Max-Age31536000 (365 days)
Path/
Set whenThe user explicitly clicks a locale in LocalePicker.svelte
Never set whenAuto-detection runs (URL canonicalisation per #73 Gap 1 stays cookie-free); page load; SW install; any non-explicit interaction

Resolution precedence (updates src/lib/locale.ts:resolveLocale) ​

  1. ?lang= in URL — wins always (preserves URL-as-truth + share-link semantics).
  2. orrery_locale cookie — new, sits between URL and browser.
  3. navigator.language (via normaliseBrowserLocale) — unchanged.
  4. DEFAULT_LOCALE (en-US) — unchanged.

Constraints on what this exception unlocks ​

This ADR is explicitly scoped to locale overrides only. It does not open the door to:

  • Cookies for Science Lens state (ADR-055 still binding).
  • Cookies for last-visited mission (ADR-024 still binding).
  • Cookies for filter state on /missions or /library.
  • Cookies for PWA install-prompt visit counter (ADR-029 still binding).
  • Any analytics / tracking cookie of any kind.
  • Any cookie that stores anything other than a single locale code.

Future expansion to cover any of the above requires its own ADR with explicit reasoning. The default answer for any new "should we cookie X?" question stays no.

Compliance ​

The cookie is a strictly necessary / functional preference cookie under ePrivacy Directive (EU 2002/58/EC, as amended) and GDPR Recital 30. It stores only a UI preference the user explicitly set, contains no personal data, no identifier, no tracking. No consent banner is required. Documented in /credits (or a new /privacy blurb if the landing-page work in #74 adds one).

Implementation surface ​

  • src/lib/locale.ts — add readLocaleCookie() and writeLocaleCookie() helpers; extend resolveLocale() to consult the cookie between URL and navigator.language.
  • src/lib/components/LocalePicker.svelte — call writeLocaleCookie(code) on click, alongside the existing URL update.
  • src/lib/locale.test.ts — unit tests for the resolution precedence and the cookie helpers.
  • tests/e2e/i18n-cookie-persistence.spec.ts — new e2e: pick Italian → fresh navigation to bare URL → still Italian.
  • Update CLAUDE.md "Do not use localStorage or sessionStorage" line to add a parenthetical: "and no cookies for user preferences except as narrowly permitted by ADR-057 (explicit locale override only)."
  • Update docs/adr/TA.md line 159 with the same parenthetical + a link to this ADR.
  • Update docs/adr/index.md to add this ADR.

Estimate: ~80 LOC across helpers + LocalePicker; 3 unit tests; 1 e2e test; 4 doc updates.

Rationale ​

The 2026 norm for locale persistence is functional cookie + URL precedence + navigator.language fallback (GitHub, Google, Wikipedia, MDN, Cloudflare-fronted sites all do this). localStorage itself has fallen out of fashion industry-wide for state of this kind — synchronous API, 5 MB string-only limit, replaced by IndexedDB or just-not-stored for most modern apps. The principled path is therefore not "relax the rule to allow localStorage" but "narrowly allow the modern, tightly-scoped pattern."

Keeping the cookie scope to a single key with a single allowed value class is the difference between "Orrery has a preferences subsystem" (slippery slope) and "Orrery remembers one explicit user choice" (bounded, defensible, easy to remove).

Alternatives considered ​

  • Status quo (URL-only). Simplest. Costs one user friction point (override loss on fresh URL) that 95%+ of users will never hit because auto-detect already serves them correctly. Revisit if user feedback shows the friction matters.
  • localStorage instead of cookie. Slightly cleaner API but: (1) blocks main thread, (2) industry has moved away, (3) no advantage over a cookie for a single-string preference, (4) feels heavier given the rule history.
  • IndexedDB. Massively over-engineered for one string. Reject.
  • Service Worker cache as state store. Misuses the SW. Reject.
  • Server-side (e.g. Cloudflare Worker setting Set-Cookie). Requires a backend. Violates TA.md "browser-only" non-negotiable. Reject.
  • Open the rule wholesale ("allow client storage where useful"). Loses the discipline that has kept the codebase clean. Reject — preserve the "default is no" stance.

Consequences ​

Positive:

  • Closes the only legitimate friction the absolute no-storage rule was creating.
  • Keeps Orrery's "share by URL" promise intact (URL still wins).
  • Preserves zero-tracking / no-banner stance.
  • Establishes a clear precedent for narrow exceptions: one cookie, one purpose, one ADR each.

Negative:

  • Introduces a storage primitive that the project had been disciplined about avoiding. Future "just one more cookie?" requests must be refused absent their own ADR — the maintainer must enforce this.
  • Adds ~80 LOC + a doc to maintain.
  • Adds a test surface (the e2e cookie-persistence spec) that needs Playwright cookie context.
  • Slightly complicates the "I cleared my cookies" debugging story — users who clear cookies will revert to navigator.language. Acceptable; clearly documented in /credits.

Decision status ​

Accepted on 2026-05-10. Implementation lands in the same PR — see src/lib/locale.ts (readLocaleCookie / writeLocaleCookie / updated resolveLocale precedence), src/lib/components/LocalePicker.svelte (cookie write on pick()), src/lib/locale.test.ts (unit tests), and tests/e2e/i18n-cookie-persistence.spec.ts (e2e coverage of the persistence + sharing-semantics scenarios).

Found during the ADR-092 pre-push review: none of this ADR's implementation existed. Not just the cookie name — the "Implementation surface" this ADR lists (readLocaleCookie / writeLocaleCookie in locale.ts, and an i18n-cookie-persistence e2e spec) was never written; locale.ts contains only a comment mentioning a cookie, and that spec file is absent. Locale persistence has always been Paraglide's cookie strategy doing it generically. The locale cookie is written by Paraglide's cookie strategy, whose name is a compiler option defaulting to PARAGLIDE_LOCALE — and no override was ever set. So for the life of this ADR the browser carried PARAGLIDE_LOCALE, which no disclosure mentioned, while /credits, README.md and this table described orrery_locale, which nothing wrote. Corrected by setting cookieName: 'orrery_locale' in both compile paths (vite.config.ts for the bundled build and the new scripts/i18n-compile.mjs for the CLI step — the CLI has no flag for it, and a name that differed between dev and prod would just move the inaccuracy rather than fix it).

Max-Age was likewise Paraglide's 400-day default, not the 365 days specified above; now pinned to 31536000 in both paths.

Two fields in the table above remain aspirational — Paraglide's writer does not set them and exposes no option to:

FieldSpecifiedActual
SameSiteLaxunset — browsers default to Lax, so behaviour matches, but the attribute is absent
Securetrue in productionunset

Migration: src/lib/locale-cookie-migration.ts runs once on mount — it copies a pre-rename PARAGLIDE_LOCALE to orrery_locale (validated against SUPPORTED_LOCALES) and deletes the stale cookie, so existing visitors keep their language and stop carrying an undisclosed cookie. Removable after ~2027-10, once every 400-day cookie written before the rename has expired.

cookieName, cookieMaxAge and cookieDomain are the only cookie options the compiler accepts (compiler-options.d.ts). Honouring SameSite/Secure literally would mean replacing the cookie strategy with a custom one that writes the cookie itself — a larger change than this review warranted, and the risk is low: the value is a locale code from a fixed set, it is not a session token, and the site is HTTPS-only. Recorded here rather than silently left false. Revisit if the cookie ever carries anything but a locale.

Orrery — architecture documentation · MIT · No tracking