MANUAL · 09
Skins & themes.
The look of the player is two independent knobs. A skin is the whole face — the layout every listener sees. A theme is the palette that face is painted in. You set a station-wide default for each; a listener can override either in their own browser without changing what anyone else sees.
TWO LAYERS
Skin is the face. Theme is the paint.
They compose. Any skin can wear any theme, and swapping one never disturbs the other: recolour Classic to Cyberpunk and it’s the same masthead-and-deck layout in cyan and hot pink; switch Classic to the Platter skin and the palette rides straight across to the turntable. Every skin reads the same live station feed — now-playing, the booth log, the schedule, requests — so none of them lose a feature; they just present it differently.
Both are picked in admin → Settings → Skin & themes, and both propagate to every open player within about thirty seconds: no controller restart, no listener reload.
THE SKINS
Six faces ship in the box.
Each skin is a completely different full-screen layout built on the same core. The station-wide pick is a contact sheet in admin Settings — every skin gets a live pure-CSS miniature of its real layout, so you can read the rack at a glance before committing. Click a card marked Set as station skin and it goes on air for everyone.
- Classic — The original SUB/WAVE face — masthead, centre stage, waveform, transport deck. The default.
- Spool — A walkman deck — the whole station fits on one cassette, reels turning as it plays.
- Drift — Ninety percent weather, ten percent type — the cover art blooms out to fill the room.
- Subamp — A compact modular player — deck, booth and log stacked like it's 1998, with a live spectrum analyzer.
- TTY — The station as a live process — panes and a status line, everything tailing like a terminal.
- Platter — The flagship vinyl face — a reference turntable is the interface, needle tracking and all.
Skins are presentation only. Every one honours the theme tokens, renders the same tap-to-tune-in gate (browsers can’t autoplay, so the first tap is the audio-unblock gesture), and respects lite mode for low-power screens. Whichever face you choose, listeners hear the exact same broadcast in sync.
LISTENER OVERRIDES
Anyone can pick their own look.
The station default is just that — a default. Every player has an Appearance menu (the palette icon in the header) that lets a listener choose a different theme and a different skin for their own browser. The choice is saved locally and beats the station-wide pick until they clear it; a Use station default / Use station skin row drops them back to whatever the operator is running. The same menu carries the per-browser lite-mode toggle.
The skin picker only appears when the build ships more than one skin (it always does today). One listener’s override never touches the broadcast or anyone else’s screen — it’s purely how they see the station.
PICKING A THEME
The palettes.
The theme is set in the same admin section, each entry a card with a four-swatch row (paper, ink, accent, overlay) so you can read the palette without leaving Settings. Five 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.
The palette recolours both the player and the admin console, so the whole install reads as one station.
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 → Skin & themes. 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. Because skins consume these same seven tokens, a custom theme retints every skin at once.
UNDER THE HOOD
How the player applies it.
On every page load, a tiny pre-paint script reads the last-applied theme and skin from the browser’s localStorage and stamps them before the first frame, so listeners never see the default palette or the wrong face flash before yours arrives. The controller serves the live theme registry at /api/themes and the active skin id on /state; an app-wide bootstrapper polls every thirty seconds and re-applies whenever either changes.
The “active” theme is the per-show override if one is set and resolves, otherwise the station default; the active skin is the listener’s override if set, otherwise the station default. Built-in ids are reserved — a user theme JSON that claims classic-light is logged and skipped — and an unknown or removed skin id always falls back to Classic so the player never renders blank.
BEYOND SKINS
Fork the reference player.
Skins reskin the built-in player in place. When you want to go further — a wholly different app, your own framework, your own everything — there’s the SUB/WAVE Web Player ↗: a lean, forkable reference player in a separate repo, built with React + Vite + TypeScript + Tailwind. It ships pointed at the live public station, so it plays real radio the moment you run npm run dev — no config needed.
It’s built to be cloned and redesigned. The data layer (a single station API client plus a handful of hooks) is cleanly separated from the presentation (the components), so you keep the plumbing — now-playing, the booth feed, up-next, requests, the schedule, lock-screen controls — and rebuild the look however you like. Point it at your own install with one environment variable:
VITE_STATION_URL=https://radio.example.comFrom that one origin it derives /api (the controller) and /stream.mp3 (the audio); it works cross-origin out of the box. Build it to a static dist/ and deploy to any host — Vercel, Netlify, Cloudflare, or a Docker image — in one click.
The repo ships an AGENTS.md and a built-in redesign skill, so a coding agent (Claude Code, Cursor) already understands how the player is wired. Tell it the vibe — “warm 70s vinyl, cream and burnt orange, a serif logo” or “minimal monospace brutalist” — and it restyles the player while keeping the audio and live data working. Full guide at getsubwave.github.io/web-player ↗.