← All posts

The theme you can't see

Soufiane3 min read

Dark mode in your own app is a solved problem. A class on <html>, some CSS variables, a toggle that writes to localStorage. An afternoon.

Dark mode in a widget that runs on someone else's website is a different problem, and the reason is a wall we put up on purpose.

The wall

The widget renders inside a shadow root. That's not decoration — it's the only reason a third-party widget is safe to install. Your CSS cannot reach in and break our layout. Ours cannot leak out and restyle your buttons. Without it, every customer with an aggressive * { box-sizing } reset becomes a support ticket about our chat window.

The cost is that the isolation runs both ways. Inside the shadow root we cannot see the host page's theme. Not its .dark class, not its CSS variables, not prefers-color-scheme as the site interpreted it. We know nothing about the page we're sitting on.

So "match the site's theme" is not available. What's available is: what the workspace configured, and what the visitor's operating system says.

Three options, one of them honest

We ship light, dark, and system.

Light and dark are a variable swap. System emits the light tokens plus a prefers-color-scheme media query carrying the dark ones — and inside a shadow root, that query still evaluates, because it's a property of the browsing context, not the DOM tree.

Here's the honest part, which is on the settings page in plain words: system follows the visitor's device, not your site. A visitor with dark-mode macOS browsing your light-only site gets a dark widget on a white page.

That's a real mismatch and we can't detect it. Better to say so next to the control than to let someone discover it in production.

The invisible prerequisite

Before any of this, the stylesheet had to stop hardcoding greys.

It was light-only, so a dozen rules had colours baked into them. #fafafa for row hover. rgba(10,10,10,.8) for unread previews. #c4c4c4 for the typing dots. Two box shadows. The amber warning strip. The red error line.

Every one of them had to become a token before dark mode was a swap rather than a second stylesheet. That was most of the work, and it produced no visible change on its own.

We checked it mechanically rather than by eye — pull every var(--…) the stylesheet references, assert each is defined in both schemes. Twenty tokens, both palettes, no gaps. Reviewing that by looking at it would have missed something.

Then it flickered

Shipped it. Set a workspace to dark. Reloaded. The launcher appeared light, then snapped to dark a moment later.

Of course it did. The widget mounts immediately with default branding, then fetches the session, then learns the workspace's actual theme. The gap is one network round trip and it's plainly visible.

This bug was always there, incidentally — the brand colour had been flashing indigo-then-yours since the beginning. Nobody noticed. Dark mode made a subtle wrong colour into an obviously wrong one.

Two fixes:

Cache the branding. Store what the server sent in localStorage under the public key. A return visitor paints correctly on the first frame, no network involved. We cache the raw payload rather than the resolved one, so later changes to our defaults still apply instead of being frozen at whatever they were when the cache was written.

Render nothing until the theme is known. A flag gates the entire tree. First-time visitors get the launcher a beat late instead of getting it wrong. A late launcher reads as loading; a colour-changing one reads as broken.

The part that needed care was the gate's release conditions. It has to open on every outcome: branding arrived, session answered with no branding saved, session failed, or a two-and-a-half second timeout for a request that never comes back. Miss the last one and a hung fetch leaves the page with no widget at all — which is considerably worse than a flicker.

The general lesson

We tested it the way it fails: boot the widget in a headless DOM and sample the shadow root on every macrotask, so a repaint shows up as two entries in a list rather than something you have to catch by eye.

Flicker is a sequence bug. Screenshots can't see it. If you're fixing one, find a way to record the sequence — otherwise you're just reloading and squinting, and you'll declare victory on a fast connection.