Colors & Theming
TVfolio’s colour system has three layers and one hard rule for markup: use bg-primary, text-foreground, text-base-700 — never raw bg-violet-700 or text-zinc-300. A raw Tailwind colour bypasses both theming and dark mode, and the theme’s lint rules forbid it.
The three layers live across two files:
| Layer | Where | What |
|---|---|---|
| 1 | tailwind-theme.css @theme |
palette aliases — name your brand on top of Tailwind’s scale |
| 2 | global.css :root / .dark |
semantic runtime vars that flip with the theme |
| 3 | tailwind-theme.css @theme inline |
the bridge that makes utilities resolve to layer 2 |
Layer 1 — palette aliases
@theme {
--color-primary-50: var(--color-violet-50);
/* … through 950 */
--color-base-50: var(--color-zinc-50);
/* … through 950 */
}
Rebranding is one edit per ramp: repoint --color-primary-* at another Tailwind ramp and every bg-primary, text-primary-600 and from-primary-800 in the codebase follows.
Layer 2 — semantic runtime vars
:root {
--background: var(--color-base-50);
--foreground: var(--color-base-900);
--primary: var(--color-primary-700);
--muted: var(--color-base-100);
/* … */
}
.dark {
--background: var(--color-base-950);
--foreground: var(--color-base-100);
--primary: var(--color-primary-400);
--muted: var(--color-base-900);
/* … */
}
Note that --primary is 700 in light and 400 in dark. That is not a detail to gloss over — it is what keeps a brand colour legible against both backgrounds, and it is the pattern to follow if you add a semantic token of your own.
Dark mode is class-based: @variant dark (&:where(.dark, .dark *)), with .dark toggled on <html> by the pre-paint script in BaseHead.
Layer 3 — the bridge
@theme inline {
--color-background: var(--background);
--color-primary: var(--primary);
--color-crt-phosphor: var(--crt-phosphor);
/* … */
}
The inline keyword is required. Without it these resolve to literal values at build time rather than to the runtime variables, and the theme stops flipping.
The cabinet is fixed; the tube flips
This is the split that makes the theme’s palette unusual, and it is a genuine design observation rather than a technical convenience.
The cabinet’s materials are fixed. Comparing the design’s two modes token by token, not one of them moves — a walnut cabinet has no dark variant. So they stay at layer 1, in their own @theme block, and resolve to one value in both themes:
--color-page-backdrop: #111010; /* the wall behind the set */
--color-table-top: #4d3a2a; /* the surface it stands on */
--color-chassis-shell: #2b211c;
--color-chassis-shell-lit: #44352d; /* top/left edges catching the room light */
--color-chassis-label: #ad9f96; /* the silk-screened legends */
--color-led-power: #dc2626;
--color-chassis-metal-0 … -5; /* the turned-aluminium knob ramp */
The tube is a display, so every crt/* token flips. Those live in :root / .dark alongside the semantic set:
:root { /* the lit screen: dark ink on warm white paper */
--crt-phosphor: #14120f;
--crt-glass: #fbf9f5;
--crt-panel: #f0ede6;
--crt-scanline-opacity: 0.09;
--crt-vignette-opacity: 0.85;
}
.dark { /* unlit: phosphor glowing on near-black glass */
--crt-phosphor: #edeae3;
--crt-glass: #080706;
--crt-panel: #12100d;
--crt-scanline-opacity: 0.14;
--crt-vignette-opacity: 1;
}
Both dimmer knobs ease off on a lit screen, because black scanlines and a black vignette read far louder over white than they do over unlit glass. If the CRT effect is too strong for you, these two numbers are the first thing to change — and they are the only thing you need to change.
The chromatic-aberration pair is worth a look too: --crt-fringe-lead and --crt-fringe-trail are the red-leads, cyan-trails fringing a mistimed beam produces.
The six fastext keys
Five of the six key colours are ink on plastic and never move, so they sit with the fixed materials. The sixth — white — is the only key light enough to need a darker face on a lit screen, so it lives in global.css and flips:
--color-fastext-red: #f75043;
--color-fastext-green: #35e884;
--color-fastext-yellow: #ffd93b;
--color-fastext-cyan: #3ad2ee;
--color-fastext-magenta: #ef5fd4;
--color-fastext-ink: #000000; /* the one ink all six share */
/* --fastext-white lives in :root / .dark */
The two checks
Two of the theme’s twelve self-checks parse the real CSS files and assert on what they find. Both catch failures that are completely silent in a browser, which is why they exist.
theme-parity.test.ts
Two invariants:
Nothing may exist only in .dark. A custom property declared there alone is undefined in light mode. var() then hits “invalid at computed-value time” and the property falls back to its initial value — so opacity: var(--missing) becomes opacity: 1, and a 9%-opacity scanline layer turns into an opaque black sheet. Nothing throws, nothing logs. The reverse is fine and deliberate: a variable in :root only, like --radius, simply does not flip.
Every @theme inline bridge must point at a variable :root actually defines, or the utility it backs renders as nothing at all.
The check currently reports 40 variables in :root, 39 of which flip, 38 bridged. Moving the tube’s crt/* set down from a fixed @theme block into :root/.dark is what made these reachable — that is fifteen paired declarations that have to stay paired.
contrast.test.ts
Parity proves every token exists, flips and bridges. It says nothing about whether the result can be read, and a CRT palette is exactly where that goes wrong: phosphor on dark glass, six saturated key colours against one fixed ink, and a silk-screened cabinet whose legends are drawn at 7–11px.
The check scores 25 pairs against WCAG AA’s 4.5:1, and passes with a worst case of 4.56. It pins the cabinet legends, all six fastext keys in both their label and F-number weights, the white key in both themes, and the tube’s four text tones against the glass in both themes.
Three pairs were measured under the bar and fixed, and they are worth knowing about because they show the theme leaving the Figma frame deliberately:
--color-chassis-labelwas the frame’s#8a7f78, which measured 4.03:1 on the shell and 3.00:1 on the lit edge.#ad9f96is the same hue at 1.25× and clears both, at 6.12 and 4.56. It is the one place the port leaves the design, because the design is not legible.- The brand badge’s spec line sat at
opacity: .55— 2.16:1, the worst on the set. - The fastext F-numbers sat at the frames’
opacity: .6, giving 3.53 on red and 3.82 on magenta, for 8px text naming the key’s own shortcut. They are drawn at.75now, worst case 4.72.
That last one shows a pattern worth stealing. The opacity is a module (Sections/Global/_fastext.ts) rather than a literal, because three places need the value: the two components that draw a key, and the contrast check, which imports it rather than guessing. The two components used to hold a copy each, and they drifted — raising one left the other at opacity-60 and 3.72:1 on the white key, with every gate still green.
Retheming
For a normal rebrand, edit layer 1 — repoint --color-primary-* at your ramp — and you are done. Markup only ever uses bg-primary / text-foreground / text-base-700, so light and dark follow automatically.
For the set, edit the cabinet block. For the screen, edit the crt/* variables in :root and .dark. Whatever you change, run pnpm test afterwards: if you touch only one half of a pair, or drop a colour below AA, both checks will say so before the build ships.