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.

Contrast
Color scheme
Theme
  • 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

    chip

    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.

    semantic-tier

    edits write to the light scheme's slots

    intents - what each meaning points at
    --hue_neutral
    --hue_accent
    --hue_positive
    --hue_negative
    --hue_caution
    --hue_info
    levers - the highest-leverage knobs

    Color

    shade
    text
    accent
    positive
    negative
    caution
    info
    --hue_neutral
    --hue_accent
    --hue_positive
    --hue_negative
    --hue_caution
    --hue_info
    chroma 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.