The Case of the Vanishing Light Mode
Valentine’s Day, 2026. Rather than a romantic evening, my AI partner and I spent the afternoon hunting a ghost in the CSS.
Here’s what was happening: you’d visit EasyWebTools, toggle to light mode (because some of you are morning people and we respect that), and then click any link on the entire site. The page would load. And it would be dark again. Every. Single. Time.
Light mode just… vanished. Like a browser-tab Houdini.
(Darkness mode, on the other hand, was rock solid. Make of that what you will.)
The Setup
A little context for the non-developers in the room. Our site uses something called client-side routing — specifically, Astro’s ClientRouter. Instead of your browser loading a completely new page every time you click a link (the way the internet worked in, say, 2003), the router fetches the new page’s content and swaps it into your current page. It’s faster. It enables smooth animations. And, apparently, it steals your theme preference when you’re not looking.
The theme system itself is simple: dark mode is the default (we’re a dark-mode-first kind of site), and light mode works by adding a CSS class called light to the root HTML element. Toggle on, class appears, colors change. Toggle off, class disappears, darkness returns.
So the question was: who keeps removing the light class?
Suspect #1: The Theme Script
The first place we looked was our own theme detection script — a little inline snippet that runs before the page renders to prevent the dreaded “flash of wrong theme.” It reads your preference from localStorage, checks your system settings, and adds the light class if needed.
The script was fine. Innocent. Doing exactly what it was told.
But here’s the thing about Astro’s ClientRouter: it’s clever about performance. When it navigates between pages, it compares the old page’s inline scripts with the new page’s inline scripts. If the text is identical — byte for byte — it doesn’t re-execute the script. Why would it? It already ran. Same code, same result.
Except… not the same result. Because the DOM changed. The navigation swapped out the entire <html> element’s attributes, and our theme class went with it. And the script that was supposed to put it back? Already ran. Not running again.
(This is the kind of bug that makes you stare at perfectly correct code for twenty minutes wondering if you’ve forgotten how computers work.)
Suspect #2: The Swap
Digging into Astro’s source code (which is, blessedly, open source), we found the real culprit: a function called swapRootAttributes().
During every navigation, this function does something aggressive. It takes the current <html> element — the one in your live page, the one with your carefully applied light class — and strips every single attribute off of it. Then it copies the attributes from the new page’s HTML. The new page, which was just fetched as raw HTML from the server, has no idea what theme you chose. It renders with whatever the server gave it. Which is the default. Which is dark.
So the sequence was:
- You click a link
- ClientRouter fetches the new page
swapRootAttributes()strips yourlightclass- New page’s attributes (no
lightclass) get applied - You’re in dark mode now
- The theme script? Already ran. Not gonna help.
Mystery solved. Sort of.
The First Fix (That Didn’t Work)
Armed with this knowledge, we wrote what seemed like a perfectly logical fix. Astro provides a before-swap event that lets you modify the incoming page before the swap happens. So we added a listener:
document.addEventListener('astro:before-swap', function (e) {
if (getTheme() === 'light') {
e.newDocument.documentElement.classList.add('light');
}
});
Brilliant, right? Before the swap, we add the light class to the incoming document. Then when swapRootAttributes() copies attributes from the new document… it copies our light class too. Problem solved. Ship it.
We committed, pushed, created a PR, merged it, watched it deploy. Then we tested it.
Dark mode. Every time.
(This is the part of the debugging story where the detective throws the case files across the room.)
The Actual Fix
Here’s what we think happened: the classList.add() on a DOMParser-created document (which is how Astro parses the fetched HTML) might not serialize into the attributes collection the same way it does on a live DOM element. The swapRootAttributes() function iterates over newDoc.documentElement.attributes, and a class modification via classList on a parsed (non-live) document may not be reflected there.
Whatever the exact reason — and honestly, this one’s deep enough in browser internals that we’re not 100% certain — the before-swap approach wasn’t reliable.
So we added a belt-and-suspenders fix: astro:after-swap.
document.addEventListener('astro:after-swap', function () {
document.documentElement.classList.toggle('light', getTheme() === 'light');
});
This fires after the swap is complete. The DOM is live. document.documentElement is the real, rendered, in-your-browser element. We’re not modifying a parsed document or hoping attributes carry through an internal function. We’re just… setting the class. On the actual page. After everything else is done.
Six lines of code. The kind that makes you wonder why you didn’t just do this from the start.
(Because we wanted to be elegant, that’s why. Elegance is a trap.)
The Bonus Bug: 308 Redirects
While we were in the neighborhood, we hunted a second ghost: the browser back button sometimes skipping pages entirely. You’d go from the blog index to a blog post, hit back, and end up on the home page instead.
This one turned out to be Cloudflare Pages issuing 308 redirects on every single internal link. Our links pointed to /blog but Cloudflare wanted /blog/. So every navigation was actually: click link → redirect → load page. That redirect was adding async complexity that confused the browser’s history stack.
The fix: add trailing slashes to every internal link on the site (14 files) and tell Astro to enforce them. Less dramatic than the theme bug, but equally satisfying to squash.
What We Learned
Three things:
1. “Correct in theory” and “works in production” are different countries. The before-swap fix was logically sound. It just didn’t work. Sometimes you need the belt and the suspenders.
2. Read the framework source. We couldn’t have diagnosed either bug without reading Astro’s actual ClientRouter code. Documentation tells you what a feature does. Source code tells you how — and “how” is where the bugs live.
3. Dark mode is never finished. Every web developer who’s ever shipped a theme toggle has a story like this. It’s a rite of passage. You think you’re done, and then someone clicks a link and the whole thing falls apart.
The Vibecoding Part
We should mention: this entire debugging session was a collaboration between a human (Cap’n Vic, product owner, self-described “rusty at HTML these days”) and an AI (Claude, the one typing furiously through source code at speeds no human wrist could survive). Cap’n spotted the bug by actually using the site. Claude traced it through three layers of framework internals. Together: faster than either alone.
That’s what vibecoding looks like when it’s not just the fun parts. It’s not all greenfield features and “look what we built.” Sometimes it’s reading swap-functions.js at line 21 and muttering “ah, that’s why” while your Valentine’s Day plans sit patiently in the other browser tab.
But the site works now. Light mode persists. Dark mode persists. The back button goes where you’d expect. And somewhere, a very small CSS class named light is finally allowed to stay on the page it was assigned to.
Happy Valentine’s Day. We fixed a bug for you.
(The chocolates can wait. The DOM cannot.)