A writeup on the approach used in this server, applicable to any server-rendered web app (Go, Ruby, Python, etc.).


Browser                                     Server
───────                                     ──────

First visit (no cookies)
────────────────────────
GET /a/<id> ──────────────────────────────► resolveTheme()
                                              no cookies → defaults:
                                              darkID=asciinema, lightID=solarized-light
                                              mode=auto → emit @media rule
            ◄──────────────────────────────  HTML {
                                               :root{--bg:#121314;--fg:#ccc}
                                               @media(light){--bg:#fdf6e3;--fg:#657b83}
                                             }
                                             browser applies correct colors natively,
                                             no JS needed for initial render

[JS runs]
  updateOptgroups() → hide dark optgroup (OS is light)
  select.value = 'solarized-light'
  applyTheme() → visual no-op, CSS already correct


User picks a theme from dropdown (mode=auto, OS=light, showing light optgroup)
──────────────────────────────────────────────────────────────────────────────
[JS: select change]
  id = 'solarized-dark' (user picked a dark theme... wait, optgroup is hidden)
  → can't happen: only light themes are shown in auto+light mode
  id = 'tango' (another light theme)
  lightID = 'tango'
  setCookie(asciinema-theme-light, tango) ──────────────────────────────────► (stored in browser)
  setProperty(--bg, #121314)
  setProperty(--fg, #ccc)
  mode stays 'auto'


User clicks Dark mode button
────────────────────────────
[JS: mode button click]
  curMode = 'dark'
  setCookie(asciinema-theme-mode, dark) ────────────────────────────────────► (stored in browser)
  updateOptgroups() → show dark optgroup, hide light
  activeThemeID() → darkID=asciinema
  select.value = 'asciinema'
  setProperty(--bg, #121314)
  setProperty(--fg, #ccc)


User picks a different dark theme
──────────────────────────────────
[JS: select change]
  id = 'dracula'
  darkID = 'dracula'
  setCookie(asciinema-theme-dark, dracula) ─────────────────────────────────► (stored in browser)
  setProperty(--bg, #282a36)
  setProperty(--fg, #f8f8f2)


User navigates to next page
────────────────────────────
GET /user/my-recordings ──────────────────────────────────────────────────► resolveTheme()
  cookies: dark=dracula, light=tango, mode=dark                              mode=dark → activeID=dracula
                                                                             bg=#282a36, fg=#f8f8f2
            ◄──────────────────────────────────────────────────────────────  HTML { :root{--bg:#282a36;--fg:#f8f8f2} }
                                                                             (correct, no JS correction needed)
[JS runs]
  updateOptgroups() → show dark optgroup (mode=dark)
  mode button 'dark' gets .active class
  activeThemeID() → 'dracula' (already correct, applyTheme is no-op visually)


OS switches to light while page is open (mode=auto)
────────────────────────────────────────────────────
[OS event fires matchMedia listener]
  curMode === 'auto' → yes
  updateOptgroups() → hide dark optgroup, show light
  activeThemeID() → lightID=tango
  select.value = 'tango'
  setProperty(--bg, #fdf6e3)
  setProperty(--fg, #657b83)
  onThemeChange('tango') → player recreated with tango theme
  (no cookie written — OS change doesn't alter stored preferences)


localStorage is invisible to the server. Cookies are sent with every request. By storing the preference in a cookie, the server can inject the correct colors directly into the HTML — no JavaScript required to avoid a flash of wrong color.

Three cookies:

Cookie Values Default
asciinema-theme-dark any dark theme ID asciinema
asciinema-theme-light any light theme ID solarized-light
asciinema-theme-mode dark, light, auto auto

Each theme has an ID, a display label, a dark/light flag, and its background and foreground hex colors (copied from the theme's CSS):

type themeInfo struct {
    ID         string
    Label      string
    Dark       bool
    Background string // e.g. "#121314"
    Foreground string // e.g. "#cccccc"
}

func resolveTheme(r *http.Request) pageTheme {
    darkID  := cookieOr(r, "asciinema-theme-dark",  "asciinema")
    lightID := cookieOr(r, "asciinema-theme-light", "solarized-light")
    mode    := cookieOr(r, "asciinema-theme-mode",  "auto")

    // Server can't know OS preference, so auto falls back to dark.
    // JavaScript will correct this immediately on load if needed.
    activeID := darkID
    if mode == "light" {
        activeID = lightID
    }

    t := themesByID[activeID]
    return pageTheme{
        ThemeBg: t.Background,
        ThemeFg: t.Foreground,
        DarkID:  darkID,
        LightID: lightID,
        Mode:    mode,
        // also pass the full theme lists for rendering the dropdown
    }
}

Embed pageTheme into every template data struct. In each page's <style>:

<style>
  :root { --bg: {{.ThemeBg}}; --fg: {{.ThemeFg}}; }
  body  { background: var(--bg); color: var(--fg); }
  /* Everything else uses var(--bg), var(--fg), or currentColor */
</style>

This is the only place template values appear in CSS. Every other rule uses CSS custom properties or currentColor, so JavaScript can update the whole page by changing just two properties on :root.


A single <select> with two <optgroup>s. Each <option> is styled with its own background/color inline so the browser renders per-item colors in the dropdown list:

<select id="sel-theme">
  <optgroup label="Dark">
    {{range .DarkThemes}}
    <option value="{{.ID}}"
            style="background:{{.Background}};color:{{.Foreground}}"
            {{if eq $.DarkID .ID}}{{if ne $.Mode "light"}}selected{{end}}{{end}}>
      {{.Label}}
    </option>
    {{end}}
  </optgroup>
  <optgroup label="Light">
    {{range .LightThemes}}
    <option value="{{.ID}}"
            style="background:{{.Background}};color:{{.Foreground}}"
            {{if eq $.LightID .ID}}{{if eq $.Mode "light"}}selected{{end}}{{end}}>
      {{.Label}}
    </option>
    {{end}}
  </optgroup>
</select>

The server pre-selects the correct option. For auto mode, the dark theme is pre-selected as a safe fallback; JavaScript corrects it if the OS is light.

Three icon buttons (sun / half-circle / moon) with title tooltips. The active button gets an .active class:

<div id="mode-toggle" role="group" aria-label="Color mode">
  <button type="button" data-mode="light" title="Light mode">☀ svg</button>
  <button type="button" data-mode="auto"  title="Auto (follow system)">◑ svg</button>
  <button type="button" data-mode="dark"  title="Dark mode">☾ svg</button>
</div>

This is the hardest design problem in the whole system.

The problem: The user sets auto mode. The OS is dark, so the dark theme is shown. The user opens the dropdown and picks a light theme to preview it. What should happen?

Several options were considered:

The key insight is that mode is an intent setting, not just a current-state setting. Showing only relevant themes makes the dropdown's effect unambiguous: whatever you pick will actually apply, both now and on the next page load.

The tradeoff: you can't browse all themes while in auto mode. This is acceptable — the workflow to change your light theme preference is explicit but not onerous.

A. Data

var themeBg = { 'asciinema': '#121314', 'solarized-light': '#fdf6e3', ... };
var themeFg = { 'asciinema': '#cccccc', 'solarized-light': '#657b83', ... };

var darkID  = '{{.DarkID}}';   // server-resolved current dark preference
var lightID = '{{.LightID}}';  // server-resolved current light preference
var curMode = '{{.Mode}}';     // "dark" | "light" | "auto"

B. Apply a theme

function applyTheme(id) {
    document.documentElement.style.setProperty('--bg', themeBg[id]);
    document.documentElement.style.setProperty('--fg', themeFg[id]);
    // if the page has a player/widget that needs recreating, call it here
}

Because every CSS rule uses var(--bg) / var(--fg), updating two custom properties on :root instantly repaints the entire page correctly.

C. Show only the active polarity's optgroup

function activePolarityIsDark() {
    if (curMode === 'dark')  return true;
    if (curMode === 'light') return false;
    return osIsDark(); // auto
}

function updateOptgroups() {
    var darkActive = activePolarityIsDark();
    optgrpDark.hidden  = !darkActive;
    optgrpLight.hidden =  darkActive;
}

Called on init, on every mode button click, and on OS preference change.

D. On dropdown change

Since only same-polarity themes are shown, the mode never changes on select:

selTheme.addEventListener('change', function() {
    var id = selTheme.value;
    if (activePolarityIsDark()) { darkID = id;  setCookie(CKDARK,  id); }
    else                        { lightID = id; setCookie(CKLIGHT, id); }
    applyTheme(id);
    // no mode change, no updateModeButtons needed
});

E. On mode button click

modeButtons.forEach(function(btn) {
    btn.addEventListener('click', function() {
        curMode = btn.dataset.mode;
        setCookie(CKMODE, curMode);
        updateOptgroups();              // show/hide the correct optgroup
        var id = activeThemeID();
        selTheme.value = id;           // select the right theme for this mode
        applyTheme(id);
        updateModeButtons();
    });
});

F. Initial setup — runs synchronously before first paint

updateOptgroups();   // hide the wrong optgroup
updateModeButtons(); // mark the right mode button as active
var id = activeThemeID();
selTheme.value = id; // select the correct option in the dropdown
applyTheme(id);      // sync --bg/--fg in case JS changes them later

Note: for mode=auto the colors are already correct from CSS (see below) — applyTheme here is a no-op visually, but keeps the JS state consistent for subsequent user interactions.

G. Live OS preference changes

window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() {
    if (curMode === 'auto') {
        updateOptgroups();
        var id = activeThemeID();
        selTheme.value = id;
        applyTheme(id);
    }
});

The JS-based correction for auto mode works in practice, but it has a theoretical flaw: it requires JS to execute before the first paint to avoid showing the wrong colors. A slow device or a script error would cause a flash.

The real fix is to not use JS for the initial color selection at all in auto mode — use a CSS @media rule instead, which the browser resolves natively:

/* mode=auto: server emits both, browser picks */
:root { --bg: #121314; --fg: #cccccc; }           /* dark default */
@media (prefers-color-scheme: light) {
  :root { --bg: #fdf6e3; --fg: #657b83; }          /* light override */
}

For mode=dark or mode=light the server injects a single :root block as before — no media query needed since the user has made an explicit choice.

The server branches on mode when rendering themeCSS:

const themeCSS = `{{if eq .Mode "auto"}}
    :root { --bg: {{.AutoDarkBg}}; --fg: {{.AutoDarkFg}}; }
    @media (prefers-color-scheme: light) {
      :root { --bg: {{.AutoLightBg}}; --fg: {{.AutoLightFg}}; }
    }{{else}}
    :root { --bg: {{.ThemeBg}}; --fg: {{.ThemeFg}}; }{{end}}
    body { background: var(--bg); color: var(--fg); }
`

AutoDarkBg/AutoDarkFg and AutoLightBg/AutoLightFg are the resolved colors for the user's stored dark and light theme preferences respectively, both passed from the server alongside ThemeBg/ThemeFg.

The rendering matrix is now:

Mode Server emits JS needed for correct initial colors?
dark single :root block No
light single :root block No
auto :root + @media (prefers-color-scheme: light) override No

JS still runs to handle the dropdown, optgroup visibility, and mode buttons — but the initial render is always correct via pure CSS, even with JS disabled.


With multiple pages each needing the same controls, define the shared pieces as string constants and concatenate them into each template's <style> block:

const themeCSS = `
    :root { --bg: {{.ThemeBg}}; --fg: {{.ThemeFg}}; }
    body  { background: var(--bg); color: var(--fg); }
`

const commonCSS = `
    a       { color: inherit; }
    button  { background: var(--bg); border: 1px solid currentColor; color: inherit; ... }
    button:hover { background: var(--fg); color: var(--bg); }
    input   { background: var(--bg); color: inherit; border: 1px solid currentColor; ... }
    /* etc */
`

// In each template:
var myTmpl = template.Must(template.New("page").Parse(`
<style>` + themeCSS + commonCSS + `
  /* page-specific rules here */
</style>
`))

The theme bar HTML+JS is similarly a single string constant interpolated into every template at the appropriate position.


Scenario Colors correct without JS?
Returning user, mode=dark Yes — single :root block
Returning user, mode=light Yes — single :root block
Returning user, mode=auto, OS=dark Yes — @media dark default applies
Returning user, mode=auto, OS=light Yes — @media (prefers-color-scheme: light) override applies
First visit (no cookie), OS=dark Yes — defaults to dark, @media dark default applies
First visit (no cookie), OS=light Yes — @media (prefers-color-scheme: light) override applies

Every case is handled by pure CSS. JS is only needed for interactive behavior (dropdown, mode buttons, player recreation) — never for correctness of the initial render.