Theming & Settings
Theming & Settings
The theming & settings capability of the SkyConnect streaming web frontend manages the user’s visual theme preference (light, dark, or system) across client and server, persisting it in a sm_theme cookie and localStorage, and exposing a settings page that lets users change it.
Purpose and Scope
This page documents the end-to-end theming mechanism of the streaming-frontend web application:
- The
ThemeModetype and how theme resolution works (light/dark/system) - Client-side theme application via
applyTheme()and restoration viagetSavedTheme() - Server-side (SSR) theme resolution from cookies via
parseTheme()andgetThemeFromCookies() - How the
dark/lightclasses are applied to the document root to drive Tailwind dark-mode styling - The settings page entry point (
SettingsClientPage) where users change their preference
Related topics intentionally left to sibling pages: API server behavior, streaming pipelines, and other web-frontend features (e.g., playback UI, account management) are outside this page’s boundary.
Overview
The frontend supports three theme modes:
| Mode | Behavior |
|---|---|
light |
Always applies the light palette (dark class removed) |
dark |
Always applies the dark palette (dark class added) |
system |
Follows the OS-level prefers-color-scheme media query; resolved at runtime in the browser |
The design goal is flicker-free SSR with client-side persistence. The server reads the sm_theme cookie during request handling so the initial render can be correct, while the client writes the cookie and localStorage on every change so the preference survives reloads and is shared with the server. The system mode is passed through as-is from the cookie and resolved client-side before first paint via window.matchMedia.
Two implementations exist in the repository:
streaming-frontend/— the active Next.js frontend (lib/theme.ts,lib/theme-server.ts,components/settings/SettingsClientPage.tsx)streaming/src/— an older/alternate React app withlib/theme.tsandlib/theme-context.tsxthat provides a React context wrapper
This page focuses on the active streaming-frontend implementation.
Architecture
Component roles
lib/theme.ts(client): DefinesThemeMode, applies the resolved mode to the document root, and persists the choice to both cookie andlocalStorage. It is a'use client'module, so it only executes in the browser.lib/theme-server.ts(server): DefinesparseTheme()andgetThemeFromCookies()to resolve the mode from thesm_themecookie during server-side rendering, avoiding a flash of the wrong theme.components/settings/SettingsClientPage.tsx: Client-rendered settings UI where the user selects a theme mode.- Persistence layer: The
sm_themecookie (1-yearmax-age,SameSite=Lax,path=/) is the source of truth shared with the server;localStorageis a secondary store for client-only reads.
Theme Resolution & Persistence
The ThemeMode contract
The single source of truth for the accepted values is the union type in lib/theme.ts:
'use client';
export type ThemeMode = 'light' | 'dark' | 'system';
const THEME_COOKIE = 'sm_theme';
const THEME_STORAGE = 'sm_theme';
Source: streaming-frontend/lib/theme.ts
Both the cookie name and the localStorage key are intentionally identical (sm_theme), which keeps the client read path simple: one constant names both stores, and the server only needs to inspect the cookie.
Applying a theme on the client
applyTheme() is the central mutation function. It performs three actions in order: persist, resolve, and apply:
export function applyTheme(theme: ThemeMode) {
if (typeof window === 'undefined') return;
try {
document.cookie = `${THEME_COOKIE}=${theme}; path=/; max-age=31536000; SameSite=Lax`;
} catch (_) {}
try {
localStorage.setItem(THEME_STORAGE, theme);
} catch (_) {}
const root = document.documentElement;
let isDark = theme === 'dark';
if (theme === 'system') {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
if (isDark) {
root.classList.add('dark');
root.classList.remove('light');
} else {
root.classList.remove('dark');
root.classList.add('light');
}
}
Source: streaming-frontend/lib/theme.ts
Design intent of each step:
- Guard against SSR:
typeof window === 'undefined'returns early so this client-only function is safe even if accidentally imported into a server context. - Cookie write:
max-age=31536000(one year),path=/, andSameSite=Laxmake the preference durable, site-wide, and available to every subsequent server request. Thetry/catchswallows storage failures (e.g., cookies disabled) so theming never breaks the page. localStoragewrite: A secondary store with its owntry/catchfor browsers blocking storage (e.g., private mode).- Resolution of
system:window.matchMedia('(prefers-color-scheme: dark)')maps the OS preference to a concrete boolean at call time. - ClassList application: The
dark/lightclasses on<html>drive Tailwind’sdark:variants. The code always removes the opposite class, guaranteeing exactly one mode class is present.
Restoring a saved theme
getSavedTheme() re-reads the preference when the client boots:
export function getSavedTheme(): ThemeMode {
if (typeof window === 'undefined') return 'dark';
const fromCookie = document.cookie.match(/(?:^|; )sm_theme=([^;]*)/)?.[1];
const saved = fromCookie || localStorage.getItem(THEME_STORAGE) || 'dark';
return saved === 'light' || saved === 'dark' || saved === 'system' ? saved : 'dark';
}
Source: streaming-frontend/lib/theme.ts
Notes:
- Cookie first,
localStoragefallback: The cookie is treated as authoritative because it is what the server sees;localStorageis used only when the cookie is missing. - Defensive default: Any unrecognized value (corrupt cookie, old value, tampering) falls back to
'dark', matching the server-side default. - SSR guard: Returns
'dark'when nowindowexists, keeping the function usable during pre-hydration reads.
Server-Side Theme Resolution
The server module resolves the theme for SSR so the first paint already has the correct palette:
import { cookies } from 'next/headers';
import type { ThemeMode } from './theme';
export function parseTheme(value: string | undefined): ThemeMode {
return value === 'light' || value === 'dark' || value === 'system' ? value : 'dark';
}
export async function getThemeFromCookies(): Promise<ThemeMode> {
const store = await cookies();
return parseTheme(store.get('sm_theme')?.value);
}
Design intent:
parseThemeis pure and total: It accepts anystring | undefinedand always returns a validThemeMode, defaulting to'dark'. Keeping it separate from cookie access makes it trivially unit-testable and reusable.getThemeFromCookiesis async: In Next.js 15+,cookies()returns aPromise; awaiting the store is required. The lookup targets exactlysm_theme, the same key the client writes.systempassthrough: Unlike the client, the server does not resolvesystem— it passes it through and lets the inline script / client code resolve it viamatchMediabefore paint, as documented in the function’s doc comment.
Core Flow
The following sequence diagram shows the full lifecycle of a theme change from user action through persistence to the next page load:
Step-by-step walkthrough
- User action: The user picks a mode in
SettingsClientPage. The handler callsapplyTheme(mode)fromlib/theme.ts. - Persistence:
applyThemewritessm_theme=<mode>to the cookie (path=/, 1-yearmax-age,SameSite=Lax) and mirrors it intolocalStorage. Both writes are individually wrapped intry/catchso storage failures cannot throw. - Resolution: If the mode is
system,window.matchMedia('(prefers-color-scheme: dark)')decides the concrete dark/light value; otherwise the mode is used directly. - DOM application: The
dark/lightclass is added to and the opposite class removed fromdocument.documentElement, flipping Tailwind’s dark-mode variants instantly without a reload. - SSR round-trip: On the next request, the cookie is sent to the server.
getThemeFromCookies()→parseTheme()resolves it (falling back to'dark'), and the root layout uses the result so the server-rendered HTML already carries the correct theme — no flash of incorrect color on navigation.
Settings Page
The user-facing surface is streaming-frontend/components/settings/SettingsClientPage.tsx. It is a client component that renders the theme picker and delegates to the shared applyTheme / getSavedTheme helpers when the user changes the selection. The component name indicates it is the client-side part of a larger settings experience; the theme control and its persistence are the portions owned by this capability. Implementation details of the full settings layout (accounts, notifications, etc.) are not present in the files reviewed for this page and are out of scope here.
Configuration Options
Theming has no external configuration file; all knobs are constants or conventions in the source:
| Option | Location | Type | Value / Default | Description |
|---|---|---|---|---|
THEME_COOKIE |
lib/theme.ts |
string | 'sm_theme' |
Cookie name written by the client and read by the server |
THEME_STORAGE |
lib/theme.ts |
string | 'sm_theme' |
localStorage key mirroring the cookie |
Cookie max-age |
lib/theme.ts |
number | 31536000 (1 year) |
Cookie lifetime; keeps the preference durable across sessions |
Cookie SameSite |
lib/theme.ts |
string | 'Lax' |
CSRF-friendly cross-site policy while still sending on top-level navigation |
Cookie path |
lib/theme.ts |
string | '/' |
Makes the cookie apply to the whole site |
| Default theme | lib/theme.ts, lib/theme-server.ts |
ThemeMode |
'dark' |
Fallback when no cookie exists or the stored value is invalid |
| Valid modes | lib/theme.ts |
ThemeMode |
'light' | 'dark' | 'system' |
The complete set of accepted values |
API Reference
applyTheme(theme: ThemeMode): void — client only
Applies a theme mode to the document and persists the choice.
Parameters:
theme(ThemeMode): One of'light','dark', or'system'.
Behavior:
- Returns immediately (no-op) when
typeof window === 'undefined'. - Writes the
sm_themecookie andlocalStorageentry; each write is guarded bytry/catch. - For
'system', resolves viawindow.matchMedia('(prefers-color-scheme: dark)'). - Adds
dark(orlight) and removes the opposite class ondocument.documentElement.
Throws: Never — storage failures are swallowed by design.
getSavedTheme(): ThemeMode — client only
Reads the currently saved theme preference.
Returns:
- The saved mode (
'light'|'dark'|'system') read from the cookie first, thenlocalStorage. 'dark'when running withoutwindow, when nothing is saved, or when the saved value is not a validThemeMode.
parseTheme(value: string | undefined): ThemeMode — pure, any environment
Validates a raw string against the accepted modes.
Parameters:
value(string | undefined): Typically the cookie value.
Returns: value if it is exactly 'light', 'dark', or 'system'; otherwise 'dark'.
getThemeFromCookies(): Promise<ThemeMode> — server only
Reads and resolves the theme from the sm_theme cookie for SSR.
Returns: A Promise<ThemeMode> resolving to the validated cookie value or 'dark' if absent/invalid.
Notes: Uses await cookies() from next/headers (Next.js 15 async API). 'system' is passed through unresolved; client code resolves it before first paint.
Failure Modes, Edge Cases & Concurrency
Storage unavailable (cookies/localStorage blocked)
Both persistence writes in applyTheme() are wrapped in independent try/catch blocks. If cookies are disabled, the cookie write silently fails but localStorage and the DOM class update still work, so the current page looks correct — only SSR persistence is lost. The same applies in reverse (e.g., Safari private mode historically throwing on localStorage.setItem): the cookie path keeps server-rendered pages correct.
Corrupt or tampered stored value
getSavedTheme() validates the read value against the three valid modes and falls back to 'dark' for anything else. parseTheme() applies the identical guard server-side. This makes the system self-healing: an old schema value, a truncated cookie, or a manually edited localStorage entry can never crash the renderer — it simply resets to the default.
Missing cookie on first visit
A first-time visitor has no sm_theme cookie. Both client and server default to 'dark'. There is no flash-of-light-theme because the server render and the client’s first read agree on 'dark' until the user explicitly changes the mode.
system mode mismatch between server and client
The server deliberately passes 'system' through without resolving it; resolution happens in the browser via matchMedia before paint (the root layout’s inline script is referenced in the doc comment of getThemeFromCookies). This avoids the classic SSR problem where the server’s idea of “system” differs from the client’s (different OS, user agent, or emulation), which would cause a hydration mismatch. The cost is that the initial HTML cannot know the concrete palette for system users — acceptable because the resolution is a pure client-side class toggle with no hydration risk.
SSR/client disagreement on default
Both sides default to 'dark' (see the return 'dark' branches in getSavedTheme and parseTheme). This symmetry is deliberate: any asymmetry would cause a hydration mismatch or a visible theme flip after hydration.
Concurrency considerations
Theming is a single-writer, last-write-wins model. There is no shared mutable state on the server (each request reads the cookie independently), and on the client only one tab’s applyTheme call can be executing at a time in the normal case. In a multi-tab scenario, each tab writes the same cookie and localStorage key, so the last tab to change the theme wins — a benign, expected behavior. The SameSite=Lax cookie is included on top-level navigations, which keeps the SSR read consistent with the writing tab without exposing the cookie on cross-site subrequests.
Performance & Operational Notes
- No runtime cost on the server hot path:
getThemeFromCookies()is a single cookie lookup;parseThemeis a constant-time string comparison. Theme resolution adds negligible latency to SSR. - No layout thrash on the client:
applyThemeperforms exactly twoclassListoperations (add one, remove one) on the root element. Tailwind’s dark variants are driven purely by the presence of thedarkclass, so no style recalc cascade beyond the initial toggle. - Storage writes are fire-and-forget: Both the cookie and
localStoragewrites happen synchronously but are tiny (a few dozen bytes) and guarded, so they do not block meaningful work or throw into the UI event loop. - Operational default: Because the fallback is
'dark'everywhere, a cleared-cookie incident degrades gracefully to the dark palette rather than producing inconsistent mixed-theme pages.
Extension Points
- New theme modes: Adding a mode requires updating the
ThemeModeunion inlib/theme.tsand both validators (getSavedThemeinlib/theme.tsandparseThemeinlib/theme-server.ts). The persistence and DOM-application logic is mode-agnostic apart from thematchMediabranch. - Different color palettes: The class-based approach (
dark/lighton<html>) means palettes are defined in Tailwind theme config viadark:variants; no changes tolib/theme.tsare needed to restyle. - Server-driven default per user: Because
getThemeFromCookies()returns aPromise<ThemeMode>, a future per-account preference (e.g., from a user profile API) can be merged at the same call site without changing the client contract. - The legacy
streaming/srcReact app provides atheme-context.tsx(ThemeProvider/context-based) variant. Teams migrating from that app tostreaming-frontendcan port components by replacing context reads withgetSavedTheme()calls.
Related Links
- streaming-frontend/lib/theme.ts — client-side theme type, application, and persistence
- streaming-frontend/lib/theme-server.ts — SSR theme resolution from cookies
- streaming-frontend/components/settings/SettingsClientPage.tsx — settings UI entry point for theme selection
- streaming/src/lib/theme-context.tsx — legacy React context wrapper for theming
- streaming/src/lib/theme.ts — legacy client theme utilities