VOL. I  ·  NO. 
SUB/WAVE
ON AIR
No skips · No shuffle · Just radio

MANUAL · 09

Themes.

SUB/WAVE renders the player and the admin console through one shared palette. You pick the station's theme; everyone listening sees the same look. Built-ins ship with the controller, and you can drop your own JSONs in to extend the menu.

PICKING A THEME

One picker, every surface.

The station's theme lives in admin → Settings Theme. Each entry is a card with a four-swatch row (paper, ink, accent, overlay) so you can read the palette without leaving Settings. Click a card and the change applies immediately for you, then propagates to every open player within about thirty seconds: no controller restart, no listener reload.

Five palettes ship with the box:

  • Classic Light — newsprint cream with hot vermilion ink. The default.
  • Classic Dark — deep charcoal newsprint with the same vermilion accent.
  • Sunset — warm dusk: plum paper, peach ink, vermilion-magenta accent.
  • Vinyl — sepia "warm record sleeve" with mustard accent.
  • Cyberpunk — near-black paper, cyan ink, hot pink accent.

PER-SHOW OVERRIDES

A show can carry its own palette.

A scheduled show can opt into a different theme for its hour. Open a show in admin → Shows, pick one from the theme override dropdown, and the player switches to that palette while the show is on air, then back to the station default when the next hour starts.

Leave the override on Station default and the show inherits the station-wide pick. The override is also a graceful fallback: if you delete the theme file out from under a show, the player silently lands back on the station default rather than rendering with broken tokens.

YOUR OWN THEMES

Drop a JSON in state/themes/.

Every theme is a single JSON file with an id, a display name, a base mode (light or dark), and a token map. The controller creates state/themes/ on first read and seeds it with a README; drop your JSONs alongside it.

{
  "id": "midnight",
  "name": "Midnight",
  "description": "Cold dark — deep navy paper, ice-blue ink.",
  "mode": "dark",
  "tokens": {
    "--bg":          "#06121f",
    "--ink":         "#cfe2ff",
    "--muted":       "#5c7896",
    "--accent":      "oklch(0.78 0.18 250)",
    "--overlay":     "rgba(0, 0, 0, 0.55)",
    "--soft-border": "rgba(207, 226, 255, 0.12)",
    "--field":       "color-mix(in oklab, #06121f 88%, #cfe2ff)"
  }
}

After saving the file, hit Refresh themes in admin → Settings → Theme. That re-scans the directory, and the new entry appears in the picker. No mixer restart, no controller bounce.

id and filename should match. A file named midnight.json should declare "id": "midnight". The controller still loads mismatches, but a logged warning is the only hint something's off.

THE TOKEN MAP

Seven knobs, no surprises.

A theme writes a fixed set of CSS variables onto <html>. Any other key in your JSON is silently dropped, so a malformed theme can't inject styles or break out into other parts of the page.

  • --bg — page background ("paper").
  • --ink — main text colour.
  • --muted — secondary text, captions, dividers.
  • --accent — the station's accent (active states, on-air pill, focus rings).
  • --overlay — translucent wash used for hover and modal scrims.
  • --soft-border — the hairline between sections.
  • --field — input/textarea fill.

Any CSS colour value works: hex, rgb(), oklch(), color-mix(). mode tells the rest of the stylesheet whether to treat the theme as light or dark; it controls the paper-grain blend and the few shadcn rules that still key off data-theme.

UNDER THE HOOD

How the player applies it.

On every page load, a tiny pre-paint script reads the last-applied theme from the browser's localStorage and writes the seven variables onto <html> before the first frame, so listeners never see the default palette flash before yours arrives. The controller serves the live registry at /api/themes; an app-wide bootstrapper polls it every thirty seconds and re-applies whenever the active id changes.

The "active id" is the per-show override if one is set and resolves, otherwise the station default. Built-in ids are reserved. A user JSON that claims classic-light is logged and skipped.