A palette mixer for tuning ink on paper
Live color tuning in the Astro dev toolbar — five axes, two modes, one SVG filter I never would have found without sliders.
lav.ren’s visual identity is ink on paper — British Racing Green text, brass gold accents, cream paper surface, and an SVG filter that makes everything look like it came off a letterpress. The color system has five axes (primary green, accent gold, paper, background canvas, ink) that each need to work in both light and dark mode. That’s ten color decisions, plus four effect parameters for the ink treatment.
I built a dev toolbar mixer to tune all of them live in the browser.
What the mixer does
Click the four-dot icon in the Astro dev toolbar during npm run dev. A
panel slides up with five rows of color chips and pickers — one for each
axis. Click a chip to apply a curated preset (BRG green, Racing green,
Forest, Classic for the primary axis). Use the color picker next to the
chips for unlimited hex exploration. Every change applies instantly.
Below the color rows, an ink effect section exposes four sliders:
- Blur — the
stdDeviationon the#inkbleedSVG filter’sfeGaussianBlur. Controls how much ink bleeds into the paper fibers. - Threshold — the
feFuncA tableValueson the alpha transfer function. Segmented buttons for soft, smooth, graded, hard, extreme. This is the parameter that makes text look printed vs. stamped. - Blend mode —
mix-blend-modeon the ink surfaces.color-burnis the default (fibers show through strokes), butmultiply,darken, andhard-lightare interesting alternatives. - Contrast — a CSS
contrast()multiplier on the filtered elements.
There’s also a per-word jitter slider that controls the --ij custom
property variance — each word gets a slightly different ink weight so text
reads as pen-drifted, not machine-stamped.
Per-mode overrides
The trickiest design decision was mode scoping. A green that looks rich on cream paper might be unreadable on a dark charcoal background. So the mixer tracks overrides per mode — light picks stay in light, dark picks stay in dark.
Under the hood, this works by injecting a <style> tag in <head> with
two mode-scoped rules:
html:not([data-color-mode='dark']) {
--primary: #0a3d24;
--accent: #c08858;
}
html[data-color-mode='dark'] {
--primary: #1a5c3a;
--accent: #d4a574;
}
The style tag ID is lavren-mixer-overrides. It gets rebuilt on every
change and removed on reset. Clicking a chip also clears any picker
override for that axis in the current mode — so the chip visually wins
the cascade without the <style> tag fighting it.
How it persists
The mixer state lives in localStorage under lav-palette. The shape:
{
"green": "brg",
"gold": "brass",
"primary": { "light": "#00563f" },
"ink": { "dark": "#f6f2e9" },
"inkEffect": { "blur": 0.65, "contrast": 1.1 }
}
On every page load — including view transitions via astro:after-swap —
an inline script in BaseLayout.astro reads this object and re-applies
the palette attributes and style overrides before first paint. The mixer
never touches production; the inline restore script is there because once
you’ve found a combination you like in dev, you paste the final values
into the CSS and the localStorage state becomes irrelevant.
The panel starts closed (now)
One subtle bug: the Astro toolbar was persisting which app panel was open across page reloads via Vite’s HMR state. If you had the mixer open and navigated, it would start open on the next page — but invisible, because the panel renders relative to the toolbar at the bottom. The first click would close it (toggling from open to closed), and the second click would actually open it.
The fix was one line in init():
app.toggleState({ state: false });
This clears the stale Vite HMR state so the panel always starts closed, matching how Astro’s built-in toolbar apps behave.
Building it with vanilla DOM
Astro dev toolbar apps run in a ShadowRoot with no build step — no JSX, no
React, no templating. The entire mixer UI is built with
document.createElement, Object.assign(el.style, {...}), and
addEventListener. It’s about a thousand lines of vanilla DOM code, and
honestly it’s fine. For a dev tool that never ships to production, the
ergonomics of “make an element, style it, append it” are good enough.
The ShadowRoot isolates styles completely, so there’s no risk of the
panel CSS leaking into the site.
What I actually learned
The mixer changed how I design the site. Before, I’d pick colors in the CSS, build, check, adjust, repeat. Now I open the mixer, drag sliders until it feels right, and read the final values from the DOM.
The ink effect sliders were the biggest win. I never would have found the
right feFuncA tableValues by reading SVG filter documentation. The
five presets (soft through extreme) came from slider exploration — I dragged
until the text looked right, then named the position. The “graded” preset
(0 0 0.25 0.75 1 1) was a happy accident from overshooting the “smooth”
target. It turned out to be the most natural-looking option.
Dev tools are design tools if you build them that way.