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.
The cookie¶
| 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 itslocalStoragepreference only. The domain must domain-match the page's own host, and outsideruntimeEnvironment === Productionit must not besyrf.org.uk. Otherwise the app writes a host-only cookie. - Identity (
Services/ThemePreference.cs,ThemePreference.Resolve): the same rules at start-up, against bothAppSettingsConfig:UiUrland theOpenIddict:Issuerhost, with production read fromRuntimeEnvironment. 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.ukhost. The production app lives on the apex, so its cookie must useDomain=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 writesDomain=syrf.org.uk(the validation above), so a tester's production preference is never overwritten. - Auth0's custom domain.
signin.syrf.org.ukis Auth0-hosted (a third party) and also receives the production cookie. It is one display-only word (light,darkorsystem) 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.ukis a separate registrable domain.
Read and write paths¶
Web app¶
- Pre-paint (
src/index.html,#syrf-theme-prepaint): before Angular boots, apply the cookie, thenlocalStorage(syrf-theme-mode), then the system preference, keeping thesyrf-theme-pendingno-flash mechanism. The script runs before the runtime configuration loads, soThemeServicestores the validated cookie name inlocalStorage(syrf-theme-cookie-name) for it. On the very first load after this change the script does not yet know the name and useslocalStorage;ThemeServicethen applies the cookie as soon as the configuration loads. ThemeService: the same precedence for its initial mode.setModewrites bothlocalStorageand the cookie. On start-up, iflocalStorageholds 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
themeToggleoff 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.systemor 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|systemquery value on an/Account/*GET is applied and written to the cookie (ThemePreferenceMiddleware). The OpenIddict endpoints are never touched, so theprompt=noneauthorization response and its framing exemption are unchanged. The app sends it through the API hand-off:AccountPortalServiceappendstheme=light|darkfor an explicit choice withthemeToggleon, andGET /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), whichThemeServicewrites once the configuration has loaded. Until a browser has loaded the app once, the pre-paint script useslocalStoragealone;ThemeServicestill 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.