Every lesson so far has left the application looking plain on purpose. All of
the polish — the colour system, both themes, the aurora, the glass panels, the
motion — arrives here, in one commit. Reading it as a single diff is the point:
you get to see exactly what the finish costs, in lines, separately from the
functionality it decorates. Checkpoint:
ui-11.
One number the palette hangs from
The entire colour system derives from a single hue:
@property --hue {
syntax: '<number>';
inherits: true;
initial-value: 258;
}
:root {
--hue: 258;
transition: --hue 400ms ease;
--accent: light-dark(oklch(55% 0.21 var(--hue)), oklch(66% 0.16 var(--hue)));
--bg: light-dark(oklch(98.5% 0.004 var(--hue)), oklch(17% 0.012 var(--hue)));
--fg: light-dark(oklch(27% 0.015 var(--hue)), oklch(93% 0.008 var(--hue)));
--muted: light-dark(oklch(88% 0.012 var(--hue)), oklch(32% 0.015 var(--hue)));
}
oklch is what makes this possible. Lightness, chroma, hue as separate
channels means “the same colour, rotated” is one number, and every token holds
its perceived lightness while the hue moves. The same rotation in hex or HSL
gives you a palette where some colours go muddy and others glow, because neither
model separates lightness from perception.
@property registers --hue as a typed custom property, which is what allows
transition: --hue 400ms. Unregistered custom properties are strings to the
animation engine and cannot be interpolated; registered as <number>, it
animates — so the entire application changes colour smoothly rather than
jumping.
light-dark() is the other half. One declaration per token holds both themes,
and color-scheme: light dark on :root selects between them. The alternative
is a second block of overrides under a media query, which is twice the tokens
and the maintenance problem where one gets updated and the other does not.
The slider that drives it sets one inline property:
document.documentElement.style.setProperty('--hue', String(value));
That is the whole theming implementation. Accent, backgrounds, borders, shadow
tints, the aurora, the heatmap’s color-mix ramp, and the focus ring all follow,
in both themes, because every one of them is expressed in terms of --hue.
Depth from three gradients
The background is three animated radial gradients behind translucent panels:
body::before {
background:
radial-gradient(40% 35% at 20% 25%, var(--aurora-1), transparent 70%),
radial-gradient(35% 40% at 80% 15%, var(--aurora-2), transparent 70%),
radial-gradient(45% 45% at 60% 85%, var(--aurora-3), transparent 70%);
animation: aurora-drift 45s ease-in-out infinite alternate;
}
The aurora colours are derived rather than picked — calc(var(--hue) + 70) and
calc(var(--hue) - 80) — so the three blobs stay in a fixed harmonic
relationship as the hue rotates. Nothing to re-pick when the theme changes.
The panels above it are glass:
backdrop-filter: blur(24px) saturate(140%);
backdrop-filter is the expensive line in this file, and it is worth being
honest about that: it forces compositing work per painted frame for every
element that has it. Used on a handful of large panels it is fine; applied to
every card in a long list it is the reason a page stutters on scroll.
Container queries, because viewports lie
The layout adapts to its container rather than the window:
.main-content {
container-type: inline-size;
}
@container app (inline-size > 1400px) { … }
@container app (inline-size < 768px) { … }
A media query asks how wide the window is. With a sidebar, a max-width, and padding in play, the window is a poor proxy for how much room a component actually has — a panel in a narrow column on a wide screen is still narrow. The container query asks the question that matters.
One viewport media query remains, for the breakpoint where the sidebar stacks. That one is genuinely about the page, not a component, and using the right tool for each is the distinction worth taking away.
Motion, and turning it off
The reduced-motion switch covers everything CSS drives:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
0.01ms rather than none so that transition-end events still fire and nothing
that waits on them hangs. This kills the aurora drift, the hue transition, and
every hover — but not the D3 transitions, which are JavaScript setting
attributes frame by frame. Those needed the explicit matchMedia check in
lesson 7. A blanket CSS rule looks like it covers everything and does not, which
is worth knowing before you assume you are done.
What it actually cost
The diff is one CSS file and one small component. No component’s markup changed, no logic moved, and no test — had there been any — would have needed touching. That separation is the argument for saving polish until last: the design system is a layer over an application that already worked, and it stayed a layer.
The repository’s final tag is byte-identical to the verified finished application. Every tag before it builds, typechecks, and runs.
Build it
| File | Action | What goes in it |
|---|---|---|
src/style.css |
modify | The full token system in oklch + light-dark(), registered --hue, aurora layers, glass panels, container queries, logical properties, reduced-motion switch |
src/components/hue-picker.ts |
write | The slider setting --hue on the document element |
index.html |
modify | <hue-picker> in the header tools |
src/index.ts |
modify | Import it |
README.md |
modify | Final architecture and run instructions |
Done when: dragging the hue slider recolours accent, backgrounds, shadows, aurora, and heatmap together in both light and dark, and the page has no horizontal overflow at any width.
Answer key: ui-10...ui-11.
Challenge: cost the glass
Open DevTools’ rendering panel, turn on paint flashing, and scroll with
backdrop-filter enabled. Then comment it out and scroll again. Decide whether
the effect is worth the compositing on the hardware you actually target — and
note that the answer differs between your laptop and a four-year-old phone.
That is the whole application: a real SQL engine, a race-free query layer, twelve components, and a design system driven by one number. Testing the Untestable covers what none of this has yet — a suite that runs against real wasm in a real browser.