themes #
Themes #
A theme is a set of knob values, not a stylesheet: plain data naming variables, where a handful of high-leverage knobs (hue angles, chroma_scale, the lightness curves - see colors) reshape
everything derived from them. Selecting a theme applies it to this whole website and loads its
knobs into the editor below.
base - The defaults: a blue accent, warm-tinted grays on an untinted page, serif headings over sans text, every knob at its starting value.
A heading
Body text with a link and inline code.
accent positive negative caution info
Contrast is a modifier, not a theme: the low and high contrast modifiers compose over
any theme with compose_themes (flatten + last-wins), so every theme gets both
variants for free. The OS prefers-contrast: more preference maps onto the same
variables the high contrast modifier sets, so it reaches every theme with no modifier picked.
Color scheme #
Light and dark are color schemes within a theme ( color-scheme), not
separate themes: each variable can carry a value for either, so any theme can implement dark
mode. The scheme follows prefers-color-scheme by default. To
set it manually, add the dark class to the root html element, or use
a component like ColorSchemeInput (the picker at the top of this page) from the companion Svelte library fuz_ui.
A theme can instead take a single-scheme stance with scheme: 'light' | 'dark':
its one appearance renders in both color schemes, and color-scheme is pinned to
match. The phosphor and marquee themes are dark-only this way, since a CRT and a lit sign have
no daytime appearance. Every other theme is dual-scheme, parchment included, whose dark
appearance is the same page by candlelight.
Applying a theme #
A theme applies at build time or at runtime, and most projects want build time. Every theme on
this page ships as its own module under themes/, and themes.ts holds the registry and the contrast modifiers.
Pass a theme to the Vite plugin or Gro generator and its values bake into the generated CSS - no runtime rendering, no JavaScript shipped, and the output stays tree-shaken:
// vite.config.ts
import {vite_plugin_fuz_css} from '@fuzdev/fuz_css/vite_plugin_fuz_css.ts';
import {phosphor_theme} from '@fuzdev/fuz_css/themes/phosphor.ts';
export default defineConfig({plugins: [vite_plugin_fuz_css({theme: phosphor_theme})]}); The theme's variables replace the defaults by name, so it composes with the variables option. They also render into the fuz.theme.baked cascade
layer, above the OS preference mappings and with color-scheme pinned for a
stance, so a baked theme behaves the same as one applied at runtime.
For runtime switching - a picker, or a theme loaded per user - use ThemeRoot from fuz_ui, which renders the theme to a <style> element in the fuz.theme layer. The two paths compose:
a runtime theme overrides a baked one, since a layer's direct styles outrank its sublayers.
Outside Svelte, render_theme_style in theme.ts does
the same rendering for any framework.
Knobs take effect at the root: the derived color stops compute on :root and
descendants inherit the result, so setting a knob like --chroma_scale on an
element changes nothing below it. Set knobs through a theme, and when a subtree needs its own
color, set the variable its styles read there instead.
Theme editor #
Drag a knob and the whole page rethemes live. Extreme values can make the page hard to read,
which is an honest signal, not a bug. Every edit updates a temporary "unsaved"
theme in the picker above; it survives navigating away and back, but copy the Theme object from the output below to keep it.
edits write to the light scheme's slots
--hue_neutral--hue_accent--hue_positive--hue_negative--hue_caution--hue_infoColor
shade text accent positive negative caution info --hue_neutral--hue_accent--hue_positive--hue_negative--hue_caution--hue_infochroma character & surface colors (per-slot multipliers and micro-surface colors)
palette hues (the letter slots - moving these makes the theme palette-tier)
palette_a palette_b palette_c palette_d palette_e palette_f palette_g palette_h palette_i palette_j Shape
border width & radius tokens (escape hatches - pin individual tokens)
Density
space tokens (escape hatches - pin individual tokens)
Depth
backdrop (the dialog and fullscreen backdrop dim)
Typography
line height tokens (escape hatches - pin individual tokens)
Motion
duration tokens (escape hatches - pin individual tokens)
Decoration
Gates
validate_theme and check_theme run live against the draft - the same
lint and gamut/monotonicity/contrast gates the shipped themes are held to in CI.
all gates pass (484 checks)
Output
The copyable Theme object, and the CSS it renders - the variables the theme sets,
plus the scheme-stance mirror when the theme is single-scheme.
theme object
rendered CSS
Validating and compiling themes #
validate_theme(theme) in theme_validate.ts is the
structural lint. Unknown variable names are errors, and everything else it flags is an
advisory warning: type and range mismatches on the knob-tier variables, a stance without its
resolved mirror, and intent bindings that put the accent beside a status hue or drop a palette
slot's chroma character. It returns an array of issues - empty means the theme is structurally
sound.
check_theme(theme) in theme_check.ts runs the gamut,
ramp-monotonicity, and contrast gates against the theme's resolved values. It reports rather
than throws, returning {ok, entries, unchecked}. The contrast gates measure
the pairings the default styles make on the page background - body and subtle text, links,
borders, fills, and colored labels, including a .palette_a button's label on its
own tinted fill. They follow the role variables those styles paint through, so a theme that
sets border_color to var(--text_60) has its borders measured at text_60, in an entry named border_color vs shade_00.
A value a gate depends on but can't evaluate lands in unchecked instead of
passing unread: a knob that doesn't resolve to a number, or a color stop or role set to
anything other than an oklch(L C H) numeric literal or an exact var() reference to another gated color. ok is true only when every
entry passes and nothing is unchecked, which suits a CI or test assertion:
import {test, assert} from 'vitest';
import {check_theme} from '@fuzdev/fuz_css/theme_check.ts';
import {my_theme} from './my_theme.ts';
test('my theme clears the accessibility gates', () => {
assert.isTrue(check_theme(my_theme).ok);
}); compile_theme(theme) is for themes that move hues or palette lightness -
monochrome, rotated, or dark-only. It recomputes the per-stop sRGB gamut caps from the theme's
actual hues and the lightness each stop resolves to, a pinned palette_lightness_NN included, and appends the corrected palette_chroma_NN stop overrides, returning {theme, report, issues}. Chroma pushed past the caps on purpose
(chroma_scale above 1) still clips afterward, and the report says so.
fuz_css gates its own themes with these functions in its test suite, every theme × contrast-modifier composition included, and declares the pairings an exemplar knowingly gives up as exact exceptions. The editor above runs the same lint and gates live on every edit.