Skip to content

Shared theme preference (light/dark across the app and Identity)

What this is: the contract that lets a person's deliberate light or dark choice on the SyRF app apply on the Identity service's pages too, and the reverse (account portal slice 3, decision D11; plan). The app keeps light | dark | system in its own localStorage, which Identity, on another host, cannot read. Both sites therefore also read and write one first-party cookie on the environment's parent domain.

Value
Name syrf-theme in production. Elsewhere an environment-suffixed name: syrf-theme-staging, syrf-theme-pr-<n>, syrf-theme-local. Any configured name must match ^syrf-theme(-[a-z0-9]+)*$, which cannot collide with syrf-session, syrf-pkce.*, syrf-signed-out or syrf-silent-auth.
Value Exactly light, dark or system. Both sites ignore anything else on read and refuse it on write.
Attributes Domain=<configured parent>; Path=/; Max-Age=31536000; SameSite=Lax; Secure. Not HttpOnly: both sites' scripts write it.
Content One word, no identifier, no PII. Written only on an explicit choice (the toggle, the theme menu, the theme hint, or the one-off copy of an existing app choice described below).
Effect Display only. It never influences authentication, authorization or any server decision beyond which colours a page uses.

Configuration

Both services receive the same pair from the same Helm values (src/charts/syrf-common/env-mapping.yaml, section themeCookie):

Service Settings
Web runtime config themeCookieName, themeCookieDomain (env SYRF__ThemeCookie__Name, SYRF__ThemeCookie__Domain)
Identity ThemeCookie:Name, ThemeCookie:Domain (the same env vars)

Values never come from the request. When themeCookie.name / themeCookie.domain are not set explicitly, the syrf-common.themeCookie.* helpers derive them from deployment values. A preview always takes its PR host (pr-<n>.syrf.org.uk) ahead of any environment.domain, so it can never inherit a domain that does not cover its own hosts:

Environment App host Identity host Name Domain
Production syrf.org.uk (app.syrf.org.uk redirects) identity.syrf.org.uk (assumed until M005 S10) syrf-theme syrf.org.uk (environment.domain)
Staging staging.syrf.org.uk identity.staging.syrf.org.uk syrf-theme-staging staging.syrf.org.uk (environment.domain)
Preview pr-<n>.syrf.org.uk identity.pr-<n>.syrf.org.uk syrf-theme-pr-<n> pr-<n>.syrf.org.uk (from syrf.prNumber)
Local local-syrf.org.uk or localhost local Identity syrf-theme-local empty (host-only) unless set

Validation and fallback

Each site validates the pair before using it:

  • Web (core/services/theme/theme-cookie.ts, resolveThemeCookieConfig): the name must match the pattern, otherwise the cookie is disabled and the app keeps its localStorage preference only. The domain must domain-match the page's own host, and outside runtimeEnvironment === Production it must not be syrf.org.uk. Otherwise the app writes a host-only cookie.
  • Identity (Services/ThemePreference.cs, ThemePreference.Resolve): the same rules at start-up, against both AppSettingsConfig:UiUrl and the OpenIddict:Issuer host, with production read from RuntimeEnvironment. A rejected domain is logged as a warning when the flag is on.

A misconfiguration therefore degrades to "the preference is not shared" and can never overwrite another environment's preference.

Leakage

  • Production reaches every *.syrf.org.uk host. The production app lives on the apex, so its cookie must use Domain=syrf.org.uk. Browsers send it to staging, previews, docs, help, the API and Identity. Two rules contain it: non-production uses suffixed names, so it never reads the production value; and non-production never writes Domain=syrf.org.uk (the validation above), so a tester's production preference is never overwritten.
  • Auth0's custom domain. signin.syrf.org.uk is Auth0-hosted (a third party) and also receives the production cookie. It is one display-only word (light, dark or system) with no identifier, so this is harmless; it is recorded here so that nobody later puts anything else in this cookie.
  • Staging and preview cookies are scoped to their own subdomain and never reach production. local-syrf.org.uk is a separate registrable domain.

Read and write paths

Web app

  • Pre-paint (src/index.html, #syrf-theme-prepaint): before Angular boots, apply the cookie, then localStorage (syrf-theme-mode), then the system preference, keeping the syrf-theme-pending no-flash mechanism. The script runs before the runtime configuration loads, so ThemeService stores the validated cookie name in localStorage (syrf-theme-cookie-name) for it. On the very first load after this change the script does not yet know the name and uses localStorage; ThemeService then applies the cookie as soon as the configuration loads.
  • ThemeService: the same precedence for its initial mode. setMode writes both localStorage and the cookie. On start-up, if localStorage holds a choice and there is no cookie, it copies the choice to the cookie, so existing users keep their mode on Identity.
  • Flag off: with themeToggle off the app is always light (as before) and never writes the cookie.

Identity

  • Reads the cookie on each request (_Layout.cshtml, ThemePreference.RequestedMode) and renders <html data-theme="light|dark"> server-side, so there is no flash and no render-blocking script. system or no cookie renders no attribute, and the system preference decides.
  • The toggle on every page (the app's sun/moon control) writes the cookie and flips data-theme.
  • Fallback hint: an allowlisted ?theme=light|dark|system query value on an /Account/* GET is applied and written to the cookie (ThemePreferenceMiddleware). The OpenIddict endpoints are never touched, so the prompt=none authorization response and its framing exemption are unchanged. The app sends it through the API hand-off: AccountPortalService appends theme=light|dark for an explicit choice with themeToggle on, and GET /api/auth/account/{page} forwards only those three allowlisted values (BffAccountPortal.NormalizeTheme).
  • Flag off: no toggle; the cookie and hint are ignored; the system preference decides, exactly as before (decision D16).

Flag decision

No new flag. The existing themeToggle gates the toggle and the cookie on both sites. The web app already reads it (runtime-manageable through the API's override store). Identity receives the same Helm value through the narrow identityThemeFlags section, which carries only themeToggle, so Identity does not receive the rest of the web flag catalogue. Identity reads it once at start-up and never reads the runtime override store, so a non-production runtime override changes the app only. A separate flag would add a second switch for one display preference without adding protection.

Known limitations

  • First app load after this change. The app's pre-paint script learns the cookie name from localStorage (syrf-theme-cookie-name), which ThemeService writes once the configuration has loaded. Until a browser has loaded the app once, the pre-paint script uses localStorage alone; ThemeService still applies the cookie as soon as the configuration loads.
  • No live sync. An open app tab does not notice a choice made on Identity until its next load.

Privacy

A user-requested interface-customisation preference, set only on an explicit choice, with no identifier. Under PECR this needs no consent banner. It is listed in the app's cookie table (about/cookies) as syrf-theme.