Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
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.
61 changes: 61 additions & 0 deletions .changeset/interaction-and-surfaces.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
'@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. It ships in the
`fuz.preferences` cascade layer (as does the `prefers-reduced-motion`
duration suppression), above the `fuz.base` defaults and below
`fuz.theme`, so it applies regardless of stylesheet order 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 — `border_color_NN`, `outline_color_NN`,
`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.
99 changes: 99 additions & 0 deletions .changeset/theme-authoring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
---
'@fuzdev/fuz_css': minor
---

feat: rework themes - registry and contrast modifiers, scheme stance, knob catalog, validation/gates/compile, 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. `validate_theme` warns 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.

**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.
- The stance mirror is computed before rendering, not by the renderer:
`resolve_theme_stance` (new `theme_stance.ts`, replacing `theme.ts`'s
`scheme_stance_variables`) stores it on the theme's `scheme_mirror`
field. The shipped stanced exemplars resolve at module scope, so only
hand-rolled stanced themes need the call.

**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 pass these in CI - including every theme ×
contrast-modifier composition - 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. 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.
Loading