Skip to content
Secure Mesh Docs
Esc
navigateopen⌘Jpreview
On this page

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 ThemeMode type and how theme resolution works (light / dark / system)
  • Client-side theme application via applyTheme() and restoration via getSavedTheme()
  • Server-side (SSR) theme resolution from cookies via parseTheme() and getThemeFromCookies()
  • How the dark / light classes 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 with lib/theme.ts and lib/theme-context.tsx that provides a React context wrapper

This page focuses on the active streaming-frontend implementation.

Architecture

Component roles

  • lib/theme.ts (client): Defines ThemeMode, applies the resolved mode to the document root, and persists the choice to both cookie and localStorage. It is a 'use client' module, so it only executes in the browser.
  • lib/theme-server.ts (server): Defines parseTheme() and getThemeFromCookies() to resolve the mode from the sm_theme cookie 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_theme cookie (1-year max-age, SameSite=Lax, path=/) is the source of truth shared with the server; localStorage is 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:

  1. Guard against SSR: typeof window === 'undefined' returns early so this client-only function is safe even if accidentally imported into a server context.
  2. Cookie write: max-age=31536000 (one year), path=/, and SameSite=Lax make the preference durable, site-wide, and available to every subsequent server request. The try/catch swallows storage failures (e.g., cookies disabled) so theming never breaks the page.
  3. localStorage write: A secondary store with its own try/catch for browsers blocking storage (e.g., private mode).
  4. Resolution of system: window.matchMedia('(prefers-color-scheme: dark)') maps the OS preference to a concrete boolean at call time.
  5. ClassList application: The dark / light classes on <html> drive Tailwind’s dark: 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, localStorage fallback: The cookie is treated as authoritative because it is what the server sees; localStorage is 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 no window exists, 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);
}

Source: streaming-frontend/lib/theme-server.ts

Design intent:

  • parseTheme is pure and total: It accepts any string | undefined and always returns a valid ThemeMode, defaulting to 'dark'. Keeping it separate from cookie access makes it trivially unit-testable and reusable.
  • getThemeFromCookies is async: In Next.js 15+, cookies() returns a Promise; awaiting the store is required. The lookup targets exactly sm_theme, the same key the client writes.
  • system passthrough: Unlike the client, the server does not resolve system — it passes it through and lets the inline script / client code resolve it via matchMedia before 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

  1. User action: The user picks a mode in SettingsClientPage. The handler calls applyTheme(mode) from lib/theme.ts.
  2. Persistence: applyTheme writes sm_theme=<mode> to the cookie (path=/, 1-year max-age, SameSite=Lax) and mirrors it into localStorage. Both writes are individually wrapped in try/catch so storage failures cannot throw.
  3. Resolution: If the mode is system, window.matchMedia('(prefers-color-scheme: dark)') decides the concrete dark/light value; otherwise the mode is used directly.
  4. DOM application: The dark/light class is added to and the opposite class removed from document.documentElement, flipping Tailwind’s dark-mode variants instantly without a reload.
  5. 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_theme cookie and localStorage entry; each write is guarded by try/catch.
  • For 'system', resolves via window.matchMedia('(prefers-color-scheme: dark)').
  • Adds dark (or light) and removes the opposite class on document.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, then localStorage.
  • 'dark' when running without window, when nothing is saved, or when the saved value is not a valid ThemeMode.

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.

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; parseTheme is a constant-time string comparison. Theme resolution adds negligible latency to SSR.
  • No layout thrash on the client: applyTheme performs exactly two classList operations (add one, remove one) on the root element. Tailwind’s dark variants are driven purely by the presence of the dark class, so no style recalc cascade beyond the initial toggle.
  • Storage writes are fire-and-forget: Both the cookie and localStorage writes 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 ThemeMode union in lib/theme.ts and both validators (getSavedTheme in lib/theme.ts and parseTheme in lib/theme-server.ts). The persistence and DOM-application logic is mode-agnostic apart from the matchMedia branch.
  • Different color palettes: The class-based approach (dark/light on <html>) means palettes are defined in Tailwind theme config via dark: variants; no changes to lib/theme.ts are needed to restyle.
  • Server-driven default per user: Because getThemeFromCookies() returns a Promise<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/src React app provides a theme-context.tsx (ThemeProvider/context-based) variant. Teams migrating from that app to streaming-frontend can port components by replacing context reads with getSavedTheme() calls.

Was this page helpful?