A Theme Is a Discipline, Not a Syntax
This site has a theme toggle in the nav. For months, it worked — and changed almost nothing.
Click it and the background went black, the nav restyled, the starfield turned cyan. Everything below the fold stayed exactly as it was: the same slate greys, the same purple-to-cyan gradients, the same glassmorphism. Two themes fighting over one page, and the page kept most of the first one.
The custom properties were not the problem. They were defined, documented, and correct. Here is what the codebase actually looked like, measured across the nine components:
$ grep -roE '#[0-9a-fA-F]{3,8}' src/components/*.astro | wc -l
205
$ grep -roE 'var\(--[a-z-]+' src/components/*.astro | wc -l
8
Two hundred and five hardcoded colour literals. Eight references to the token system that was supposed to own all of them.
The toggle was never the feature
It is easy to read a theme switcher as a small, self-contained thing: a button, an attribute on <html>, a block of overriding declarations. Build those three and you are done.
That framing is what produced the bug. The overrides existed:
[data-theme='terminal'] {
--bg-primary: #000000;
--accent-cyan: #00ffff;
--text-primary: #e0e0e0;
}
But an override only reaches what reads the variable. A component that writes color: #94a3b8 is not participating in the theme system. It is opting out of it, silently, one declaration at a time — and nothing in CSS will ever tell you.
Broken down per component, the damage was obvious the moment I looked:
| Component | terminal rules | token refs |
|---|---|---|
| Hero | 15 | 0 |
| Nav | 17 | 1 |
| Starfield | 1 | 0 |
| About | 0 | 0 |
| BlogCard | 0 | 0 |
| Contact | 0 | 0 |
| Footer | 0 | 0 |
| Projects | 0 | 7 |
| Skills | 0 | 0 |
Three components had been themed by hand, each with its own bespoke [data-theme='terminal'] block — 33 rules of duplicated intent. The other six had nothing. They were not unthemed by decision; they were unthemed by default, because writing #94a3b8 is what you do when you are looking at a design and not at an architecture.
The theme was implemented twice and adopted once. A variable layer nobody read, and a hand-maintained override layer that only covered whatever was on screen the day someone cared.
The fix had to change nothing
The obvious repair — replace every literal with a token — has a problem. The default theme is what everyone sees. A hundred-site refactor across nine components is exactly the kind of change that quietly shifts a shadow, drops a hover state, or flattens a gradient, and you find out from a screenshot three weeks later.
So the migration used the fallback form, keeping the original value in place:
/* before */
color: #94a3b8;
/* after */
color: var(--text-secondary, #94a3b8);
var() falls back to its second argument when the property is undefined. But --text-secondary is defined in :root, as #94a3b8 — the exact value it replaced. So under the default theme every one of these resolves to the byte that was already there. The change is a provable visual no-op rather than a carefully reviewed one.
That is also why the literal count did not drop:
$ grep -roE '#[0-9a-fA-F]{3,8}' src/components/*.astro | wc -l
205 # unchanged
$ grep -roE 'var\(--[a-z-]+' src/components/*.astro | wc -l
108 # was 8
The same 205 hex values, now sitting as fallbacks behind 100 new indirections. The colours did not leave the file. They stopped being the source of truth.
Seven semantic tokens absorbed all of it: --text-primary, --text-secondary, --text-muted, --accent-cyan, --accent-purple, --bg-primary, --bg-secondary. Not one per colour — one per role. That distinction is the whole system. --slate-400 is a renamed hex and buys nothing; --text-secondary is a claim about hierarchy that a second theme can answer differently.
Two kinds of hex that must stay literal
A migration that converts everything is also wrong, and this is where an automated pass will hurt you. Two categories stayed hardcoded on purpose.
Brand colours. The technology logos in the skills grid — Python’s #3776ab, Docker’s #2496ed — belong to those brands. They are not this site’s palette and they must not shift when the theme does. A cyan Docker whale is a bug.
Decorative per-card gradients. Each project card carries its own gradient, chosen for that card. Those are content, not theme.
The test: would this colour be wrong if the theme changed it? If yes, it is not a token.
My first automated pass got this wrong in a subtler way. The replace pattern used a lookbehind that skipped any hex preceded by ", ", so gradient stops migrated inconsistently — half a rule would theme and the other half would not. The second pass masked existing var(--x, #hex) constructs with placeholders before touching anything. Worth knowing if you reach for a regex here: the values you are rewriting appear inside constructs you have already rewritten.
The flash, and the one line that prevents it
A theme stored in localStorage cannot be applied by CSS, and applying it after hydration means the browser paints the default theme first. On a dark-on-dark site the flash is brief; on a light-to-dark switch it is a white strobe on every single navigation.
The fix is a blocking inline script in <head>, before any body content parses:
<script is:inline>
(function() {
var t = localStorage.getItem('portfolio-theme') || 'glass';
document.documentElement.setAttribute('data-theme', t);
})();
</script>
is:inline matters in Astro. Without it the script is processed and bundled, which defers it, which is exactly the thing you were trying to avoid. This is one of the few places where a render-blocking synchronous script is the correct answer.
Why this site ignores prefers-color-scheme
Standard advice says to respect the OS preference. An earlier version of this very post said so, and the site it was published on has never done it.
That is worth explaining rather than quietly dropping. This site’s themes are glass and terminal — dark glassmorphism and a black CRT console. Neither of them is light mode. prefers-color-scheme: light has no honest answer in this design; both branches are dark, so the media query cannot carry a real preference and would only ever be a cargo-culted branch picking one arbitrarily.
The rule that actually generalises is not “respect prefers-color-scheme”. It is: respect the preference your themes are capable of expressing. Ship light and dark, and the OS signal is real information that it is rude to ignore. Ship two moods of dark, and it is noise — the honest default is the one you designed.
Keeping it from happening again
The reason this decayed for months is that nothing failed. The build was green, the toggle worked, and every individual hardcoded colour was a perfectly reasonable line of CSS in isolation. There is no lint rule in the stack that fires on color: #94a3b8, and if there were, it would have to be wrong about Docker’s logo.
So the guard is a measurement rather than a rule in a document. The ratio between those two greps is the health of the system, and checking it is one command:
$ grep -roE 'var\(--[a-z-]+' src/components/*.astro | wc -l
108
If that number stops growing while components keep getting added, the theme has started leaking again.
The lesson I would hand my earlier self: a design token system is not something you set up. It is something every subsequent line of CSS either joins or quietly defects from — and the defection stays invisible until someone flips a switch and watches two-thirds of the page refuse to move.