ADRs
ADR 0042 — SSR / SSG theme inlining (no-flash)
  • Date: 2026-06-06
  • Status: Accepted
  • Phase / Feature: Foundation v1 · runtime polish
  • Closes: ADR 0036 Q8 — "SSR/SSG export theme inlining"
  • Builds on: ADR 0036 §2 (tokens), §2.6 (CSS variables runtime), Style Dictionary dist/tokens.css

Context

Foundation ships tokens as CSS variables: a :root { … } block in dist/tokens.css plus a parallel .theme-dark { … } override. Components read var(--color-text-primary) etc. The runtime model is simple: toggle the theme-dark class on the document root, every component repaints.

Three SSR/SSG-mode gaps in that pipeline:

  1. First-paint flash. A server-rendered page starts with the default :root (light) class. If the user prefers dark or last visited in dark, hydration runs JS after first paint, swaps the class, and the screen flickers from light to dark. Industry standard prefers-color-scheme fix handles only the OS-preference case — explicit designer choice (the most common signal in a design tool) needs more.
  2. SSG export with baked theme. Static export (Next.js output: "export") writes one HTML per route at build time. Without coordination, the static HTML always carries theme-light even when the project's deployed brand is dark-first.
  3. Cookie vs. localStorage. The server cannot read localStorage. To pre-render the right class, the choice must live in a request-readable surface — a cookie.

This ADR settles cookie shape, pre-paint inline script, SSG forking strategy, and the helper API. The Foundation package adds a tiny pure-helpers module so every host (Next.js app router, plain Node SSR, Cloudflare Pages Functions, the ARNO canvas) drives the same logic.

Decision

§1 — Source of truth: a cookie

A cookie arno_theme with value "light" or "dark". Set on every explicit theme toggle. Read on the server before render. Persists across reloads, sessions, and devices (when the user is signed in and the cookie crosses devices — out of scope for v1 ops).

No localStorage. localStorage is a client-only mirror of the cookie at most. The flash that ADR 0042 fixes is precisely the gap during which localStorage is unreadable.

§2 — Pre-paint inline script

Even with the cookie, server-rendered HTML can carry a stale class if the cookie was set on a different origin (the static export ships pre-cookie). For these cases the rendered HTML embeds a small inline script that runs before first paint:

<script>
  (function () {
    try {
      var m = document.cookie.match(/(?:^|; )arno_theme=([^;]+)/);
      var theme = m ? decodeURIComponent(m[1]) : null;
      if (!theme) {
        theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
      }
      document.documentElement.classList.toggle('theme-dark', theme === 'dark');
    } catch (e) {}
  })();
</script>

Placed at the top of <head>, BEFORE any stylesheet. Style Dictionary's compiled CSS still ships :root for the light defaults; the theme-dark class flip toggles into the dark overrides without re-parsing CSS.

The helper themeInlineScript() returns this string for hosts that want to render it directly. The script is 12 lines and idempotent — running it on every reload is a no-op when the class is already correct.

§3 — Server-side render

Server-rendered pages call getThemeFromCookieHeader(cookieHeader) and set the matching class on the <html> element at render time. The inline script (§2) still runs to catch the rare cookieless / mismatched case.

For Next.js App Router:

import { cookies } from "next/headers";
import { THEME_COOKIE_NAME, parseThemeCookie, getThemeClassNames } from "@arno/foundation";
 
export default function RootLayout({ children }) {
  const cookieValue = cookies().get(THEME_COOKIE_NAME)?.value ?? null;
  const theme = parseThemeCookie(cookieValue);
  return (
    <html lang="en" className={getThemeClassNames(theme)}>
      <head>
        <script dangerouslySetInnerHTML={{ __html: themeInlineScript() }} />
      </head>
      <body>{children}</body>
    </html>
  );
}

For Cloudflare Pages Functions / Workers: same pattern, reading the cookie off request.headers.get('cookie').

§4 — SSG (static export)

Static export does not have a request at build time, so the inline script (§2) is the entire mechanism: HTML ships with theme-light baked, the script flips to dark before paint if the cookie / OS preference says so.

For sites that want a dark-default static export (e.g. a developer-tools docs site), Foundation exposes STATIC_DEFAULT_THEME constant and the host's build sets <html className={getThemeClassNames("dark")}> directly. The runtime script still corrects on the user's actual preference.

§5 — Helper API

@arno/foundation re-exports:

  • THEME_COOKIE_NAME: "arno_theme"
  • Theme: "light" | "dark"
  • parseThemeCookie(value: string | null | undefined): Theme — defaults to "light" on any other input
  • getThemeClassNames(theme: Theme): string"" for light, "theme-dark" for dark; concatenate with caller's own classes if any
  • themeInlineScript(): string — the inline pre-paint script body (no <script> tag — caller wraps with the right attribute set per renderer)
  • STATIC_DEFAULT_THEME: "light"

Pure functions. No DOM. Tested.

Anti-patterns explicit

  • Do not persist the theme choice in localStorage as the source of truth. Cookie is the only surface the server can read.
  • Do not ship the inline script as an external JS file. The whole point is to run before any stylesheet loads — external requests are an order of magnitude too late.
  • Do not make the helper async. It runs in render functions and the inline script — sync is the only option.
  • Do not use <noscript> fallback. With JS disabled the rendered HTML class is still authoritative; flicker only matters when JS is on.

Open questions / parking

  • Per-route theme (light marketing pages + dark dashboard inside the same domain). The cookie + script is global. A per-route override would need either a per-route cookie or a render-time mark; defer until a customer asks.
  • High-contrast / forced colors mode. Standard CSS media query forced-colors: active already handles the rare accessibility-mode case; ADR 0036 §7 WCAG validator already keeps base contrast high. No separate theme dimension needed.
  • Themed token-editor preview. When the in-app editor lands, the preview pane should render under the project's current theme, not the editor's. That's a render-time prop, not a global cookie write — naturally handled outside this ADR.