Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions .changeset/dev-initial-load-prescan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@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.
21 changes: 21 additions & 0 deletions .changeset/micro-surface-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@fuzdev/fuz_css': minor
---

feat: theme scrollbars, caret, dialog backdrop, and the OS contrast preference

New micro-surface defaults in `style.css`, each themable through a `var()`
fallback hook (all registered theme variables):

- `scrollbar-color` on `:root` — thumb defaults to `var(--shade_40)` on a
transparent track; hooks `--scrollbar_thumb_color` /
`--scrollbar_track_color`
- `caret-color` on text inputs — defaults to `var(--accent_50)`; hook
`--caret_color`
- `dialog::backdrop` — defaults to `var(--darken_60)`; hook
`--backdrop_color`
- `@media (prefers-contrast: more)` maps the OS preference onto the curve
knobs, mirroring the `'high contrast'` theme; theme overrides beat it

Also tunes the `'low contrast'` theme's shade compression to the softest
values that pass every `check_theme` contrast gate.
60 changes: 60 additions & 0 deletions .changeset/oklch-color-system.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
'@fuzdev/fuz_css': minor
---

feat: rework the color system to derived OKLCH, add semantic intents, cascade layers, and themes

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_` renamed to `palette_`**: `--color_a_50` → `--palette_a_50`,
`.color_a_50` → `.palette_a_50`, `.color_a`–`.color_j` →
`.palette_a`–`.palette_j`. In compound families the letter alone implies
the palette: `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). In TS:
`ColorVariant`/`color_variants` → `PaletteVariant`/`palette_variants`.
- **`--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.
- **New curve knobs** (the promoted theme API): `--chroma_scale`,
`--hue_shift`, `--palette_lightness_00/_100/_curve` (same trio for
`shade_`/`text_`), `--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`, `--hue_shift_NN`).
- **New semantic intent knobs**: `--hue_accent`, `--hue_positive`,
`--hue_negative`, `--hue_caution`, `--hue_info`, each deriving a 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.
- **Cascade layers**: all shipped CSS is layered `fuz.base` < `fuz.theme` <
`fuz.utilities`; consumers' unlayered styles beat everything.
`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 default-theme special case now keys on empty `variables`
rather than the `'base'` name. Custom `base_css` is re-layered into
`fuz.base` in bundled output.
- **`color-mix()` interpolation moved from `in hsl` to `in oklab`** in
button fills/borders, composites, and shadow classes.
- **Themes**: `themes.ts` exports the curated `default_themes` registry
(base, low contrast, high contrast — the contrast themes are rewritten as
curve-knob overrides), one module per theme under `themes/`, plus
unregistered exemplars (necromancer, sunset ember, brutalish, terminal
green + the `create_terminal_theme(hue)` factory).
- **New design-time modules**: `ramps.ts` (fitted knob constants, numeric
evaluators, CSS emitters), `oklch.ts` (OKLCH↔sRGB + gamut math), `wcag.ts`
(luminance/contrast), with tests gating every default stop for gamut,
monotonicity, and contrast.
33 changes: 33 additions & 0 deletions .changeset/theme-knobs-and-checks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
'@fuzdev/fuz_css': minor
---

feat: add theme scale knobs, the knob catalog, and theme validation/gates/compile

New theme knobs, each derived into existing token defaults so one knob move
reshapes a whole family while individual tokens stay pinnable:
`--shadow_alpha_scale` (multiplies the `shadow_alpha_*` ramp, including
button shadows), `--radius_scale` (multiplies the `border_radius_*` tiers),
`--scale_factor` (multiplies 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
`--background_image` (decoration hook on `:root`).

New `knobs.ts`: `theme_knobs`, a typed catalog of the theme-facing knobs
(`kind`, `axis`, `leverage`, `tier`, `bindable`, ranges), which powers the
inline theme editor on the themes docs page. `variable_data.ts` gains
`palette_glosses`, the letter → color/default-intent display data.

New `theme_check.ts`, resolving 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
(the same thresholds the repo's tests assert, exported as the `GATE_*`
constants)
- `compile_theme` — recomputes per-stop worst-hue chroma caps from a theme's
own hues, lightness ramp, and hue shift, emits `palette_chroma_NN`
overrides where the baked caps no longer fit, and re-checks the result

`ramps.ts` gains `ramp_hue_shift_offset` and `compute_palette_chroma_caps`.
18 changes: 18 additions & 0 deletions .changeset/theme-scheme-stance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@fuzdev/fuz_css': minor
---

feat: add the theme scheme stance

`Theme` gains `scheme?: 'dual' | 'light' | 'dark'` (default `'dual'`). A
single-scheme theme renders its one appearance in both color schemes:
`render_theme_style` mirrors every scheme-adaptive default the theme doesn't
override (exported as `scheme_stance_variables`, including the
`palette_chroma_NN` gamut-cap stops) and pins `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 single-scheme 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 terminal exemplars use the stance instead of hand-mirrored ramp knobs.
114 changes: 95 additions & 19 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ should answer "what specific gap in the defaults does this close?" — the most
common misuse is hand-spacing elements that flow margin already spaces, or
re-declaring typography/color the element already carries. When you do style,
work down the ladder and stop at the first rung that suffices: right semantic
element → built-in class convention (`.selected`, `.color_a`) → composite
element → built-in class convention (`.selected`, `.palette_a`) → composite
(`box`, `row`, `panel`) → token class (`p_md`, `gap_lg`) → literal
(`display:flex`) → `<style>` block with design tokens. Never hardcode spacing
or color values.
Expand Down Expand Up @@ -107,16 +107,30 @@ combined and only used content is included. In utility-only mode, import
tokens
- Each variable can have `light` and/or `dark` values
- Light/dark are color-schemes _within_ a theme, not separate themes
- [`render_theme_style()`](src/lib/theme.ts) generates CSS with specificity
multiplier
- [`render_theme_style()`](src/lib/theme.ts) generates CSS into the
`fuz.theme` cascade layer (defaults live in `fuz.base`, generated utility
classes in `fuz.utilities`; consumers' unlayered styles beat everything)
- Color values are derived: curve knobs → ramp stops → color stops, computed
in pure CSS (`calc()`/`pow()`/`oklch()`); the fitted knob constants and CSS
emitters live in [ramps.ts](src/lib/ramps.ts) with design-time gamut and
contrast gates in [oklch.ts](src/lib/oklch.ts)/[wcag.ts](src/lib/wcag.ts)
- [theme_check.ts](src/lib/theme_check.ts) turns those design-time gates into
a theme API: `validate_theme` lints a theme's shape, `check_theme` runs the
gamut/monotonicity/contrast gates against an arbitrary theme (resolving its
bindings back to numbers), and `compile_theme` recomputes per-theme
worst-hue chroma caps so rotated, monochrome, or dark-only themes stay in
gamut

### Smart utility class generation

Two generators available, both using AST-based extraction and per-file caching:

1. **Vite plugin** (preferred) - [vite_plugin_fuz_css.ts](src/lib/vite_plugin_fuz_css.ts)
exposes the generated CSS as `virtual:fuz.css` with HMR; works across
SvelteKit/Svelte/React/Preact/Solid and needs no committed output file
SvelteKit/Svelte/React/Preact/Solid and needs no committed output file.
In dev it pre-scans project sources at server startup (see `prescan`) so
the first served CSS is complete, and resyncs clients whose HMR socket
connects after a missed update
2. **Gro generator** - [gen_fuz_css.ts](src/lib/gen_fuz_css.ts), a SvelteKit
alternative that writes a `fuz.css` genfile

Expand Down Expand Up @@ -145,7 +159,12 @@ See `GenFuzCssOptions` and `VitePluginFuzCssOptions` types for configuration.

### Three class types

- **Token classes** - Map to style variables: `p_md`, `color_a_50`, `gap_lg`
- **Token classes** - Map to style variables: `p_md`, `palette_a_50`,
`positive_50`, `gap_lg`. A bare scale class applies its family's dominant
use (`palette_a_50`/`positive_50`/`text_70` set text color, `shade_50` sets
background) with `bg_` twins (`bg_a_50`, `bg_positive_50`); in compound
families a letter alone implies the palette (`border_a_50` vs the
`border_color_50` alpha ramp)
- **Composite classes** - Multi-property shortcuts: `box`, `column`, `row`,
`ellipsis`, `pixelated`, `circular`, `selectable`, `clickable`, `pane`,
`panel`, the size composites `xs`/`sm`/`md`/`lg`/`xl` (uniform step offsets
Expand All @@ -172,20 +191,52 @@ breakpoints via `min-width(800px):` and `max-width(600px):`. Built-in max-width
variants (`max-sm:`, `max-md:`, etc.) and media feature queries (`print:`,
`motion-safe:`, `contrast-more:`, etc.) are also available.

Custom properties work as literals too — `--flow_margin:0`, `--button_shadow:none`
set the property on the element straight from markup, which is how a consumer
reaches any theme/base hook without a dedicated token class.

## Variable naming

See [variables.ts](src/lib/variables.ts) for definitions,
[variable_data.ts](src/lib/variable_data.ts) for size/color variants.

**Colors:**

- 10 hues with semantic roles: `a` (primary/blue), `b` (success/green), `c`
(error/red), `d` (secondary/purple), `e` (tertiary/yellow), `f` (muted/brown),
`g` (decorative/pink), `h` (caution/orange), `i` (info/cyan), `j`
(flourish/teal)
- 13 intensity stops: `color_a_00` (lightest) through `color_a_100` (darkest),
with `_50` as the base (steps: 00, 05, 10, 20, 30, 40, 50, 60, 70, 80, 90,
95, 100)
[variable_data.ts](src/lib/variable_data.ts) for size/palette/intent variants.

**Colors (OKLCH, derived):**

- 10 palette hues glossed by color + default intent binding
(`palette_glosses` in `variable_data.ts`): `a` (blue ·
accent), `b` (green · positive), `c` (red · negative), `d` (purple), `e`
(yellow), `f` (brown · neutral), `g` (pink), `h` (orange · caution), `i`
(cyan · info), `j` (teal)
- Semantic intent knobs alias meaning over the letters: `--hue_accent`
(links/focus/selection/selected), `--hue_neutral` + `--neutral_chroma`
(all surfaces/text/borders/shadows — the neutral is an intent whose scales
are `shade_*`/`text_*`), `--hue_positive`/`--hue_negative`/
`--hue_caution`/`--hue_info`; each intent derives a full 13-stop scale
through the shared ramps (`--accent_00`–`--accent_100`, same for the
others) with matching text/background token classes (`.positive_50`,
`.bg_caution_10`)
- Curve knobs drive everything: `--chroma_scale` (0 grayscale → >1 vivid),
`--hue_shift` (degrees of rotation across a ramp), per-scheme lightness
ramps (`--palette_lightness_00`/`_100`/`_curve`, same trio for `shade_`
and `text_`), and the chroma curve
(`--palette_chroma_min`/`_max`/`_curve`) clamped per stop by baked
worst-hue sRGB gamut caps
- 13 intensity stops: `palette_a_00` (nearest the background) through
`palette_a_100`, with `_50` as the base (steps: 00, 05, 10, 20, 30, 40,
50, 60, 70, 80, 90, 95, 100)
- Form/scale knobs derive into token defaults so one move reshapes a family
while tokens stay pinnable: `--radius_scale` (border radii), `--scale_factor`
(spaces), `--shadow_alpha_scale` (shadow alphas incl. button shadows), plus
`--font_weight`, `--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`
- Micro-surface hooks consumed by `style.css` via `var()` fallbacks:
`--caret_color` (defaults to the accent), `--scrollbar_thumb_color`/
`--scrollbar_track_color` (default into the shade scale), `--backdrop_color`
(the `dialog::backdrop` dim); `prefers-contrast: more` maps onto the curve
knobs mirroring the high-contrast theme, theme-overridable
- [knobs.ts](src/lib/knobs.ts) is the typed knob catalog (`kind`, `axis`,
`leverage`, `tier`, ranges) powering the themes docs page's inline editor
- `bg_*`/`fg_*` - color-scheme-aware (swap in dark mode, use alpha for stacking)
- `darken_*`/`lighten_*` - color-scheme-agnostic (don't swap)
- `text_*` - opaque text colors (`text_00`–`text_100`, alpha avoided for
Expand Down Expand Up @@ -264,6 +315,9 @@ Use `GenFuzCssOptions` or `VitePluginFuzCssOptions` to customize:
variable that shipped styles still reference)
- `filter_file` - which files get extracted (the default filter includes
node_modules deps)
- `prescan` (Vite plugin only) - dev-only eager source scan at server
startup so the first served CSS is complete (`true` = `src` under the
Vite root, `false` disables, or an array of directories)
- `cache_dir` - extraction cache location (default `.fuz/cache/css`)

These are the common options — see
Expand All @@ -286,8 +340,31 @@ typography, borders, shading, shadows, layout. See
- [variables.ts](src/lib/variables.ts) - All style variable definitions
- [variable.ts](src/lib/variable.ts) - `StyleVariable` type and validation
- [variable_data.ts](src/lib/variable_data.ts) - Size, color, border variants
- [theme.ts](src/lib/theme.ts) - Theme rendering, `ColorScheme` type
- [themes.ts](src/lib/themes.ts) - Theme definitions (base, low/high contrast)
- [ramps.ts](src/lib/ramps.ts) - The derived color system: fitted knob
constants, numeric evaluators, and the CSS `calc()`/`oklch()` emitters
- [oklch.ts](src/lib/oklch.ts) - OKLCH↔sRGB math and gamut search
(design-time + tests only)
- [wcag.ts](src/lib/wcag.ts) - WCAG luminance/contrast (design-time + tests)
- [theme.ts](src/lib/theme.ts) - Theme rendering, cascade layers, the
`scheme` stance (a single-scheme theme mirrors the adaptive defaults and
pins `color-scheme`, rendering its one appearance in both schemes),
`ColorScheme` type
- [themes.ts](src/lib/themes.ts) - The curated theme registry
- `src/lib/themes/` - One module per theme. The registry (base, low/high
contrast) is semantic-tier: intent bindings + levers only, palette hues
untouched. Unregistered exemplars: necromancer, sunset ember, brutalish,
and `terminal.ts` (the `terminal_theme` flagship — dark mono terminal chrome
with a green cast but functional palette — plus a `create_terminal_theme(hue)`
factory for pure single-phosphor monochrome terminals); necromancer and
terminal are dark-only via `scheme: 'dark'`
- [knobs.ts](src/lib/knobs.ts) - The theme knob catalog: typed metadata
(kind/axis/leverage/tier/bindable/range) for the knob-tier variables, joined
against `default_variables` by name; includes hook knobs like
`heading_font_weight` and the micro-surface color hooks
- [theme_check.ts](src/lib/theme_check.ts) - Theme lint (`validate_theme`),
numeric-twin accessibility gates (`check_theme`: gamut, ramp monotonicity,
contrast), and the worst-hue chroma-cap compile step (`compile_theme`) over
a shared string→number resolution core
- [theme.gen.css.ts](src/lib/theme.gen.css.ts) - Gro generator that produces
`theme.css`

Expand Down Expand Up @@ -386,7 +463,6 @@ Integration: `vite_plugin_examples.test.ts` (skip with
- **Static extraction only** - Runtime dynamic classes (`document.createElement`,
`innerHTML`) won't be detected. Use `additional_classes` option as workaround.
- **No animation utilities** - Animation class generation not yet supported
- **HSL color system** - OKLCH migration planned for better perceptual uniformity
- **Button composites incomplete** - Some button variant classes are work in
progress
- **CSS Cascade Layers** - `@layer` support under consideration but not yet
Expand Down
10 changes: 5 additions & 5 deletions examples/vite-preact/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export const App = () => {
<div>
<h3>Literal classes</h3>
<div class="opacity:60%">.opacity:60%</div>
<div class="color:var(--color_j_50)">.color:var(--color_j_50)</div>
<div class="color:var(--palette_j_50)">.color:var(--palette_j_50)</div>
<div class="box-shadow:0~4px~8px~rgb(0,0,0,0.2)">.box-shadow:0~4px~8px~rgb(0,0,0,0.2) (~ encodes spaces)</div>
</div>
</section>
Expand All @@ -83,16 +83,16 @@ export const App = () => {
<div>
<h3>Interactive</h3>
<div class="row gap_md mb_lg">
<button class="hover:border_color_b_50 hover:outline_color_b_50 active:border_color_d_50 active:outline_color_d_50" onClick={() => setCount((c) => c + 1)}>
<button class="hover:border_b_50 hover:outline_b_50 active:border_d_50 active:outline_d_50" onClick={() => setCount((c) => c + 1)}>
count: {count}
</button>
<span>.hover:border_color_b_50 .hover:outline_color_b_50 .active:border_color_d_50 .active:outline_color_d_50</span>
<span>.hover:border_b_50 .hover:outline_b_50 .active:border_d_50 .active:outline_d_50</span>
</div>
<div class="row gap_md mb_lg">
<button class="hover:border_color_g_50 hover:outline_color_g_50 active:border_color_h_50 active:outline_color_h_50" onClick={() => setCount(0)}>
<button class="hover:border_g_50 hover:outline_g_50 active:border_h_50 active:outline_h_50" onClick={() => setCount(0)}>
reset
</button>
<span>.hover:border_color_g_50 .hover:outline_color_g_50 .active:border_color_h_50 .active:outline_color_h_50</span>
<span>.hover:border_g_50 .hover:outline_g_50 .active:border_h_50 .active:outline_h_50</span>
</div>
</div>
</section>
Expand Down
Loading