Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
1aa9180
feat: rework to oklch and add themes
ryanatkn Jul 7, 2026
4b59e87
feat: themes and oklch
ryanatkn Jul 7, 2026
48985f6
update
ryanatkn Jul 7, 2026
3f2c8f4
add theme editor
ryanatkn Jul 8, 2026
af6553d
Merge branch 'main' into oklch
ryanatkn Jul 10, 2026
990448e
wip
ryanatkn Jul 10, 2026
6bdc86d
wip
ryanatkn Jul 10, 2026
2e6e47f
rework API
ryanatkn Jul 11, 2026
3a987f4
wip
ryanatkn Jul 11, 2026
b01bb39
wip
ryanatkn Jul 11, 2026
fef5d35
wip
ryanatkn Jul 11, 2026
b0b02e8
wip
ryanatkn Jul 12, 2026
05b9602
Merge branch 'main' into oklch
ryanatkn Jul 16, 2026
ea7aa72
wip
ryanatkn Jul 16, 2026
1b876c6
wip
ryanatkn Jul 16, 2026
a3cc9e2
add hue shift and remove an unwanted terminal theme
ryanatkn Jul 16, 2026
9263f07
wip
ryanatkn Jul 16, 2026
a5c6037
wip
ryanatkn Jul 22, 2026
187375d
wip
ryanatkn Jul 24, 2026
000210c
wip
ryanatkn Jul 24, 2026
e948f47
wip
ryanatkn Jul 24, 2026
475db25
refactor
ryanatkn Jul 24, 2026
2258867
wip
ryanatkn Jul 24, 2026
bfe364d
wip
ryanatkn Jul 24, 2026
8404f16
wip
ryanatkn Jul 24, 2026
354ac4f
wip
ryanatkn Jul 24, 2026
e690ed4
wip
ryanatkn Jul 24, 2026
659b73a
wip
ryanatkn Jul 24, 2026
9236e19
rework
ryanatkn Jul 29, 2026
566a670
more rework
ryanatkn Jul 29, 2026
2f842ea
changesets
ryanatkn Jul 29, 2026
239325b
wip
ryanatkn Jul 29, 2026
e948bcc
more
ryanatkn Jul 29, 2026
c1c19a2
cleanup
ryanatkn Jul 29, 2026
de608ef
merge
ryanatkn Jul 29, 2026
bb8757a
wip
ryanatkn Jul 29, 2026
ef61627
refactor
ryanatkn Jul 30, 2026
9cfde83
wip
ryanatkn Jul 30, 2026
0832f1e
wip
ryanatkn Jul 30, 2026
1fe23d8
Merge branch 'main' into oklch
ryanatkn Aug 18, 2026
7bc092f
rework the themes
ryanatkn Aug 18, 2026
b37262a
refactoring and simplification
ryanatkn Aug 18, 2026
9ae8501
rename to smolder
ryanatkn Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/body-font-and-button-borders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
'@fuzdev/fuz_css': minor
---

feat: `--font_family` names the body font, and buttons get their own border style

Two knobs that were previously one thing doing two jobs.

**`--font_family`** is the body font, defaulting to `var(--font_family_sans)`.
`body` reads it instead of `--font_family_sans` directly, so the three stacks
(`--font_family_sans`/`_serif`/`_mono`) keep meaning what they say - a theme
that wants serif body text sets `--font_family`, rather than declaring that
the sans stack is Georgia. Headings still take `--heading_font_family`
(default `var(--font_family_serif)`), so "one family everywhere" stays two
knobs, deliberately - the serif-headings-over-sans-body default is the design.

**`--button_border_style`** (default `var(--border_style)`) and
**`--button_border_style_active`** (default `var(--button_border_style)`) give
buttons the raised/pressed pair that `border-style: outset`/`inset` expresses
and that no single global knob could:

```ts
{name: 'border_style', light: 'inset'}, // sunken fields
{name: 'button_border_style', light: 'outset'}, // raised buttons
{name: 'button_border_style_active', light: 'inset'} // that press in
```

Buttons are the only element with a pressed appearance, which is why the
split lands here rather than as a general `--border_style_active`. The knob
catalog's border-style values gain `inset` and `outset`.

Breaking, narrowly: buttons now read `--button_border_style`, whose derived
default resolves at `:root`, so a *contextual* `--border_style` override
(set on an ancestor rather than in a theme) no longer reaches the buttons
inside it. Set `--button_border_style` there too.
7 changes: 7 additions & 0 deletions .changeset/dangling-var-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@fuzdev/fuz_css': patch
---

fix: the dangling-`var()` warning for `base_css` with `variables: null`
never fired - it checked the variable graph that same option had emptied;
it now checks the default variable names
13 changes: 13 additions & 0 deletions .changeset/dev-initial-load-prescan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@fuzdev/fuz_css': patch
---

fix: complete utility CSS on the first dev page load

The Vite plugin now pre-scans project sources at dev-server startup (new
`prescan` option: `true` scans `src` under the Vite root, `false` disables,
or an array of directories) and resyncs clients whose HMR socket connects
after a missed CSS update. Previously the first cold-start page load could
render with incomplete utility classes until a manual refresh. Files are
isolated during the scan: one file that fails to read or extract logs and
is skipped rather than aborting the rest of the scan.
60 changes: 60 additions & 0 deletions .changeset/interaction-and-surfaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
'@fuzdev/fuz_css': minor
---

feat: rework interaction states and micro-surfaces - focus ring gap, hover previews focus, themable surface variables, contrast-safe selected buttons

Focus and hover now read as one escalating highlight:

- Focusable elements (links, buttons, inputs, contenteditable) draw their
outline with `outline-offset: var(--outline_offset)` (default `1px`). The
ring and the border share a color by design (the element color, defaulting
to the accent), so without a gap they merged into one thick band; the
offset makes them read as two shapes while keeping the ring's full
contrast against the page.
- Hovering an input, textarea, or select colors the border with
`var(--outline_color)` - the element color when one is set (an
`outline_*` class, or `.palette_*` on buttons), the accent intent
otherwise - instead of fading it to the weaker `--border_color_20`
alpha. Focus keeps setting the border to the same color and adds the
outline. Disabled inputs no longer react to hover.

New micro-surface defaults in `style.css`, each themable through an
ordinary declared variable (all in the knob catalog):

- `scrollbar-color` on `:root` - `--scrollbar_thumb_color` (default
`var(--shade_40)`) on `--scrollbar_track_color` (default transparent)
- `caret-color` on text inputs - `--caret_color`, default `var(--accent_50)`
- `dialog::backdrop` - `--backdrop_color`, default `var(--darken_60)`
- `--outline_offset` - the border-to-focus-ring gap above
- `@media (prefers-contrast: more)` maps the OS preference onto the curve
knobs, mirroring the `'high contrast'` theme, in the `fuz.preferences`
cascade layer alongside the `prefers-reduced-motion` duration
suppression, so it applies in every consumption mode and theme overrides
beat it

(`--heading_font_weight` is the lone `var()`-fallback hook - its per-tier
fallbacks mean no single declared default exists, and setting it flattens
the heading weight ladder deliberately.)

Selected-button text stays readable under contrast-bent themes and colored
fills:

- Selected buttons use `--text_00` (the text ramp endpoint) instead of
`--text_05`/`--text_10` for inverse text. Themes bending
`--text_lightness_curve` (the high-contrast modifier, the OS
`prefers-contrast: more` mapping) drag the near-background stops toward
the fill lightness, washing selected text out (down to ~1.2:1); the
endpoints are the knobs themselves, so the curve can never move them.
- Colored buttons (`.palette_X`) fill with `palette_X_50` instead of
`palette_X_40`, matching the neutral `shade_50` selected fill - stop-40
fills leave light-scheme inverse text at ~2.5:1, below the 3:1
large-text floor (disabled-active feedback fills with `negative_50` for
the same reason). The selected border now matches the fill, rendering
flat. Unselected tint fills mix from the same `--fill`, so they read
slightly richer.
- A matching contrast gate in `check_theme` - `GATE_SELECTED_TEXT`:
`text_00` on `shade_50` and on every stop-50 fill must clear 3:1 - keeps
these pairings from silently regressing. The selected-deselectable hover
uses `--text_min`, the text-semantic twin of `--shade_min` (identical
values).
89 changes: 89 additions & 0 deletions .changeset/oklch-color-system.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
'@fuzdev/fuz_css': minor
---

feat: rework the color system to derived OKLCH with semantic intents and cascade layers

Colors are now derived - curve knobs → ramp stops → color stops - in pure
CSS (`calc()`/`pow()`/`oklch()`), fitted to minimize the perceptual delta
from the old HSL palette.

Breaking changes:

- **`--color_*` variables renamed to `--palette_*`**: `--color_a_50` →
`--palette_a_50` for all 10 letters × 13 stops, with the TS mirrors
`ColorVariant`/`color_variants` → `PaletteVariant`/`palette_variants`.
Classes are property-first and the letter alone implies the palette: the
text-color stop classes keep their names (`.color_a_50`, now applying
`--palette_a_50`), while the compound families shorten -
`border_color_X_NN` → `border_X_NN`, `outline_color_X_NN` →
`outline_X_NN`, `shadow_color_X_NN` → `shadow_X_NN` (`bg_X_NN` and the
letterless families like `border_color_NN` and `shadow_color_umbra` keep
their names). The bare component conventions
`.color_a`-`.color_j` → `.palette_a`-`.palette_j` (they recolor buttons
and chips as a unit, not one property).
- **`.fg_NN`/`.bg_NN` token classes removed**: the adaptive alpha overlays
stay as variables (`--fg_*`/`--bg_*`) but no longer generate bare token
classes - `bg_` is the opaque background class prefix (`.bg_a_50`,
`.bg_positive_50`), and a translucent `.bg_50` beside those was the one
collision in the naming family. Use literals instead:
`background-color:var(--fg_10)`. The `.darken_NN`/`.lighten_NN` classes
are unchanged.
- **`--hue_a`…`--hue_j` are now OKLCH hue angles** (blue is `250`, not HSL
`210`). Consumer CSS doing `hsl(var(--hue_x) …)` breaks - use
`oklch(<l> <c> var(--hue_x))` or the palette/intent stops.
- **`--tint_hue`/`--tint_saturation` removed** → `--hue_neutral` (defaults
to `var(--hue_f)`) + `--neutral_chroma`.
- **Absolute `_light`/`_dark` variants removed**: the ~286 generated
variables (`--color_a_50_light`-style, `--shade_XX_light/dark`) and all
their classes. Write the literal color or define one custom property
instead.
- **`color-mix()` interpolation moved from `in hsl` to `in oklab`** in
button fills/borders, composites, and shadow classes.
- **Cascade layers**: all shipped CSS is layered `fuz.base` <
`fuz.preferences` < `fuz.theme` < `fuz.utilities`, so consumers' unlayered
styles beat everything. The OS user-preference mappings
(`prefers-contrast`, `prefers-reduced-motion`) live in `fuz.preferences`,
above the defaults and below themes, so they apply in every consumption
mode and explicit theme overrides still win. Custom `base_css` is
re-layered into `fuz.base` in bundled output (only the `fuz.preferences`
identity is preserved).

New:

- **Curve knobs** (the promoted theme API): `--chroma_scale`,
`--palette_lightness_00/_100/_curve` (same trio for `shade_`/`text_`),
and `--palette_chroma_min/_max/_curve` clamped per stop by baked worst-hue
sRGB gamut caps, plus per-stop derived variables themes can pin
individually (`--palette_lightness_NN`, `--palette_chroma_NN`,
`--chroma_shape_NN`).
- **Semantic intent knobs**: `--hue_accent`, `--hue_positive`,
`--hue_negative`, `--hue_caution`, `--hue_info`, each deriving a full
13-stop scale through the shared ramps (`--accent_00`…`--accent_100`,
etc.) with matching token classes (`.positive_50`, `.bg_caution_10`),
plus `--selection_color` and `intent_variants`/`IntentVariant` in
`variable_data.ts`. Links, focus, selection, `accent-color`, and
disabled-active feedback route through them; focus follows the element
color (via `--outline_color`) with the accent as fallback.
- **Per-slot chroma character**: `--palette_a_chroma_scale` …
`--palette_j_chroma_scale` and intent twins (`--accent_chroma_scale`,
same for positive/negative/caution/info), default `1`, each multiplying
its slot's chroma under the global `--chroma_scale` so the slot's
character holds at any global setting - grayscale stays grayscale and
vivid scales proportionally. Values at or below 1 stay inside the sRGB
gamut caps by construction; above 1 knowingly clips, like the global
knob. The brown slot `f` ships at `0.55`: brown is low-chroma dark
orange, so under uniform chroma it read as a second orange beside
`--hue_h`. An intent hue bound to a palette letter shares only the angle
- `validate_theme` warns when the bound letter's multiplier differs from
the intent's twin, and `check_theme` runs its gates through the
multipliers.
- **Derived border colors**: the `border_color_*` alpha ramp colors through
the neutral intent (new `--border_color_lightness` and
`--border_color_chroma` knobs, the chroma derived from `--neutral_chroma`),
so grayscale and retinted themes reshape borders in the same move as
surfaces and text.
- **Design-time modules**: `ramps.ts` (fitted knob constants, numeric
evaluators, CSS emitters), `oklch.ts` (OKLCH↔sRGB + gamut math), and
`wcag.ts` (luminance/contrast), with tests gating every default stop for
gamut, monotonicity, and contrast.
106 changes: 106 additions & 0 deletions .changeset/theme-authoring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
'@fuzdev/fuz_css': minor
---

feat: rework themes as knob-sets - registry and contrast modifiers, scheme stance, knob catalog, checks, pure renderer, build-time `theme` option

Themes are now knob-first: a theme moves the derived color system's curve
and scale knobs, composes with modifiers, validates and gates like the
shipped defaults, and applies at runtime or build time.

**Registry and modifiers.** `themes.ts` exports the curated
`default_themes` registry (just base) and `contrast_modifiers`: low/high
contrast are curve-knob fragments composed over any theme with the new
`compose_themes(base, ...overlays)` (flatten + last-wins; a single-scheme
base re-slots dual-slot overlay variables to its stance), not themes in the
list. Themes live one module per theme under `themes/`, with unregistered
exemplars - recognizable materials, each anchoring an era: smolder
(firelight), parchment (the illuminated manuscript, candlelit after dark),
concrete (brutalism), nineties (the 90s desktop web, beveled), phosphor (the
CRT terminal, dark-only), and neon (80s signage at night, dark-only and
palette-tier). Low contrast is tuned to the softest compression that passes
every `check_theme` contrast gate.

**Scheme stance.** `Theme` gains `scheme?: 'dual' | 'light' | 'dark'`
(default `'dual'`). A single-scheme theme renders its one appearance in
both color schemes by mirroring every scheme-adaptive default it doesn't
override and pinning `color-scheme` on the scope so form controls and
native scrollbars agree. Author a stanced theme's own variables single-slot
in the light/base position and resolve it with `resolve_theme_stance` (new
`theme_stance.ts`), which computes the mirror onto the theme's
`scheme_mirror` field - kept apart from `variables` so the authored knobs
stay distinguishable from the derived ones. `validate_theme` warns on a
missing mirror and on dark slots a stance makes meaningless;
`check_theme`/`compile_theme` resolve through the same mirror so the gates
evaluate the stanced reality in both schemes. The neon and phosphor
exemplars are dark-only via the stance, resolved at module scope - a CRT and
a lit sign have no daytime appearance, while every other exemplar does and
stays dual-scheme.

**Pure renderer.** `theme.ts` no longer depends on `variables.ts`, so
mounting a theme stops pulling the full derived variable set into the
bundle: minified, `theme.ts` drops from ~38KB to ~1.3KB (~9KB to ~0.7KB
gzipped). Breaking:

- `RenderThemeStyleOptions.empty_default_theme` is removed - to render the
full defaults, pass them:
`render_theme_style({name: 'base', variables: default_variables})`. The
default-theme special case now keys on empty `variables` rather than the
`'base'` name.
- `render_theme_style` loses `specificity` (the `:root:root` hack) and
gains `layer?: string | null` (default `'fuz.theme'`);
`generate_theme_css` loses its specificity parameter; the
`theme_specificity` generator option is removed.

**Theme knobs and the catalog.** New scale knobs derive into existing token
defaults so one knob move reshapes a whole family while individual tokens
stay pinnable: `--shadow_alpha_scale` (the `shadow_alpha_*` ramp, button
shadows included), `--radius_scale` (the `border_radius_*` tiers),
`--scale_factor` (the `space_*` scale), `--font_weight` (body),
`--heading_font_weight` (a hook with per-tier fallbacks - setting it
flattens the heading ladder), `--heading_font_family`, and the
`--background_image` decoration hook on `:root`. New `knobs.ts` catalogs
the theme-facing knobs with typed metadata (`kind`, `axis`, `leverage`,
`tier`, `bindable`, ranges), powering the inline theme editor on the themes
docs page; `variable_data.ts` gains `palette_glosses`, the letter →
color/default-intent display data.

**Validation, gates, compile.** New `theme_check.ts` resolves a theme's
authored values back to numbers (literals, `var(--hue_x)` binding chains,
compiled-cap overrides):

- `validate_theme` - structural lint: shape and unknown-name errors, plus
advisory type/range warnings for the knob-tier variables
- `check_theme` - report-only gamut, ramp-monotonicity, and contrast
gates, with the thresholds exported as the `GATE_*` constants
- `compile_theme` - recomputes per-stop worst-hue chroma caps from a
theme's own hues and lightness ramp, emits `palette_chroma_NN` overrides
where the baked caps no longer fit, and re-checks the result

The shipped themes and their contrast-modifier compositions are gated in
CI (one declared marginal exception: smolder composed with low contrast sits
just under three light-scheme UI-fill gates), and the docs page's inline
editor runs the same lint and gates live on every edit.

**Build-time `theme` option.** The Vite plugin and Gro generator take a
`theme` baked into the generated CSS:

```ts
import {phosphor_theme} from '@fuzdev/fuz_css/themes/phosphor.ts';

vite_plugin_fuz_css({theme: phosphor_theme});
```

The theme overlays the resolved `variables` last-wins by name, so its
values flow through the dependency graph like any other - the variables a
theme references are pulled in transitively and the output stays
tree-shaken. A single-scheme theme's `scheme_mirror` applies first,
matching the renderer's order, computed automatically if the theme arrives
unresolved. The theme's own overlay also renders into the `fuz.theme`
cascade layer - above the `fuz.preferences` OS mappings, with
`color-scheme` pinned for a single-scheme stance - so a baked theme
behaves exactly like the same theme rendered at runtime. This is the
static counterpart to fuz_ui's `ThemeRoot`: no runtime theme rendering, no
JavaScript shipped; the two compose, the runtime theme winning by cascade
layer. Also exposes `apply_theme_variables` from `variable_graph.ts`;
`build_variable_graph_from_options` takes an optional theme.
26 changes: 26 additions & 0 deletions .changeset/theme-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@fuzdev/fuz_css': minor
---

feat: `Theme` is a zod schema, beside `StyleVariable` in `variable.ts`

`Theme` and `ThemeScheme` move out of `theme.ts` into `variable.ts`, where
they join `StyleVariable` as zod schemas with their types inferred:

```ts
import type {Theme} from '@fuzdev/fuz_css/variable.ts'; // was theme.ts
```

`theme.ts` imports the types type-only, so the renderer stays zod-free and
mounting a theme still costs ~1.3KB minified.

Being a schema means a theme validates at runtime: `Theme.safeParse(value)`
when the failure detail matters, and the new `parse_theme(value)` for a
theme-or-`null` at a boundary like restoring one from storage.
`validate_theme` in `theme_check.ts` runs the schema in place of its
hand-rolled shape checks, so it reports the whole theme's shape at once and
returns those errors on their own - the advisory knob-tier warnings run only
over a theme that parsed.

`Theme` is strict: an unknown property is an error rather than quietly
ignored.
37 changes: 37 additions & 0 deletions .changeset/variables-single-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
'@fuzdev/fuz_css': minor
---

refactor: `variables.ts` exports only `default_variables`, with the value tables in `variable_data.ts`

The 558 per-variable exports (`hue_a`, `palette_a_50`, `space_md`, …) are
gone. Every family that follows one template is now built by loop from the
variant lists in `variable_data.ts` and the emitters in `ramps.ts`, then
spread into `default_variables` in place, so the module is a single array
declaration rather than a name-by-name transcript of one.

`variable_data.ts` gains the fitted value tables those ladders step through,
each beside the variant list that names its steps: `FONT_SIZES`,
`SPACE_SIZES`, `BORDER_RADII`, `DISTANCES`, `LINE_HEIGHTS`, `DURATIONS`
(with a new `duration_variants`), `SHADOW_GEOMETRY`, `SHADOW_ALPHAS`, and
`OVERLAY_ALPHAS`. They're keyed by variant and unitless - `variables.ts` adds
the unit and any `calc()` wrapper - so one table serves both the emitted CSS
and anything that wants the numbers.

Breaking: `icon_sizes` is now `ICON_SIZES`, keyed by variant with unitless
numbers, joining the tables above:

```ts
icon_sizes.icon_size_xs; // was '18px'
ICON_SIZES.xs; // now 18
```

The variables themselves are unchanged - same names, same order, same
light/dark slots, same summaries, and `theme.css` renders byte-identical.
Code that imported an individual variable reads it off the array instead:

```ts
import {default_variables} from '@fuzdev/fuz_css/variables.ts';

const space_md = default_variables.find((v) => v.name === 'space_md');
```
Loading