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).

Orrery — architecture documentation · MIT · No tracking