// User-preference cookies — the canonical names the framework's pre-paint // theme script + auto-wired i18n resolver agree on (innovation/00). The kit's // ProfileMenu / ThemeToggle write them; the server parses them at SSR time. // // Why cookies (and not localStorage)? Cookies travel on every HTTP request, // so the SERVER can read them and bake the correct HTML (dark class, // translated strings) BEFORE first paint — no theme/locale flash. // localStorage is browser-only and would force a post-hydration flicker. // // Every helper is isomorphic: pass the request `Cookie` header on the server, // omit it in the browser (falls back to `document.cookie`). export const THEME_COOKIE = 'voltro:theme' export const LOCALE_COOKIE = 'voltro:locale' export type ThemePreference = 'system' | 'light' | 'dark' const isThemePreference = (v: string): v is ThemePreference => v === 'system' || v === 'light' || v === 'dark' // 400 days — Chrome caps cookie lifetime at 400d (RFC 6265bis). Picking the // max so the preference survives long absences without being silently // re-defaulted. const DEFAULT_MAX_AGE_SEC = 60 * 60 * 24 * 400 /** Read a cookie by name from a header string (SSR: the request `Cookie` * header) or, when omitted, `document.cookie` (browser). Returns undefined * when absent or off-document. */ export const getCookie = (name: string, cookieHeader?: string): string | undefined => { const header = cookieHeader ?? (typeof document !== 'undefined' ? document.cookie : '') if (!header) return undefined for (const part of header.split(';')) { const eq = part.indexOf('=') if (eq === -1) continue if (part.slice(0, eq).trim() === name) { try { return decodeURIComponent(part.slice(eq + 1).trim()) } catch { return part.slice(eq + 1).trim() } } } return undefined } export interface SetCookieOptions { /** Default: 400 days (Chrome's cap). Pass 0 to delete the cookie. */ readonly maxAgeSeconds?: number /** Default: '/' — preference applies app-wide. */ readonly path?: string /** Default: 'lax' — allows top-level navigation but blocks cross-site * XHR, which is what preference cookies want. 'none' implies `Secure` * (modern browsers reject a SameSite=None cookie without it). */ readonly sameSite?: 'lax' | 'strict' | 'none' } /** Build a `Set-Cookie`-style string AND, in the browser, write it to * `document.cookie`. Returns the string so SSR callers can emit a header. */ export const setCookie = (name: string, value: string, options: SetCookieOptions = {}): string => { const { maxAgeSeconds = DEFAULT_MAX_AGE_SEC, path = '/', sameSite = 'lax' } = options const parts = [ `${name}=${encodeURIComponent(value)}`, `Path=${path}`, `Max-Age=${maxAgeSeconds}`, `SameSite=${sameSite}`, ] if (sameSite === 'none') parts.push('Secure') const cookie = parts.join('; ') if (typeof document !== 'undefined') document.cookie = cookie return cookie } /** Expire a cookie (Max-Age=0). Same isomorphic contract as `setCookie`: * writes `document.cookie` in the browser, returns the header string. */ export const deleteCookie = (name: string, path = '/'): string => setCookie(name, '', { maxAgeSeconds: 0, path }) export interface PreferenceCookies { /** undefined → server should fall back to its default (typically the * app config's theme). 'system' means the client's pre-paint script * decides against `prefers-color-scheme`. */ readonly theme?: ThemePreference /** Locale tag as written by the client (lowercase IETF: `en`, `de`, * `fr-CA`). Consumer decides which tags are valid + how to fall back if * the value is unknown. */ readonly locale?: string } /** Extract both preference cookies (theme + locale) from a cookie header (or * `document.cookie` when omitted). Tolerates `null`/`undefined` headers. * An invalid theme value is dropped (not coerced); an empty locale is * dropped. */ export const parsePreferenceCookies = (cookieHeader?: string | null): PreferenceCookies => { const header = cookieHeader ?? undefined const themeRaw = getCookie(THEME_COOKIE, header) const locale = getCookie(LOCALE_COOKIE, header) return { ...(themeRaw !== undefined && isThemePreference(themeRaw) ? { theme: themeRaw } : {}), ...(locale !== undefined && locale !== '' ? { locale } : {}), } } /** Apply a theme preference to `` (browser only) — adds/removes the * `dark` class, resolving `system` against `prefers-color-scheme`. The kit's * ProfileMenu / ThemeToggle call this after writing the cookie so the change * is immediate (the pre-paint `themeBootScript` handles the first load). */ export const applyTheme = (theme: ThemePreference): void => { if (typeof document === 'undefined') return const dark = theme === 'dark' || (theme === 'system' && typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches === true) document.documentElement.classList.toggle('dark', dark) }