Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 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
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
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.
105 changes: 105 additions & 0 deletions .changeset/theme-authoring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
---
'@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: necromancer, sunset ember, brutalish, and terminalien. 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 necromancer and
terminalien exemplars are dark-only via the stance, resolved at module
scope.

**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: sunset ember 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 {necromancer_theme} from '@fuzdev/fuz_css/themes/necromancer.ts';

vite_plugin_fuz_css({theme: necromancer_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. 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. Pinning
`color-scheme` stays separate - the `dark`/`light` class on the root
element drives it. Also exposes `apply_theme_variables` from
`variable_graph.ts`; `build_variable_graph_from_options` takes an optional
theme.

Also fixed: 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.
Loading