From 1aa9180641e79c1616fffd7f895ce62b63314d4e Mon Sep 17 00:00:00 2001
From: Ryan Atkinson
Date: Tue, 7 Jul 2026 11:49:45 -0400
Subject: [PATCH 01/11] feat: rework to oklch and add themes
---
.changeset/twelve-shrimps-report.md | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 .changeset/twelve-shrimps-report.md
diff --git a/.changeset/twelve-shrimps-report.md b/.changeset/twelve-shrimps-report.md
new file mode 100644
index 000000000..28c4b94f6
--- /dev/null
+++ b/.changeset/twelve-shrimps-report.md
@@ -0,0 +1,5 @@
+---
+'@fuzdev/fuz_css': minor
+---
+
+feat: rework to oklch and add themes
From 4b59e87713e4c4f85cbda15172863d118ff43a8a Mon Sep 17 00:00:00 2001
From: Ryan Atkinson
Date: Tue, 7 Jul 2026 14:18:24 -0400
Subject: [PATCH 02/11] feat: themes and oklch
---
.changeset/twelve-shrimps-report.md | 56 +-
CLAUDE.md | 56 +-
examples/vite-preact/src/App.tsx | 10 +-
examples/vite-react/src/App.tsx | 10 +-
examples/vite-solid/src/App.tsx | 10 +-
examples/vite-svelte/src/App.svelte | 14 +-
src/lib/css_bundled_resolution.ts | 22 +-
src/lib/css_class_composites.ts | 72 +-
src/lib/css_class_definitions.ts | 68 +-
src/lib/css_class_generators.ts | 2 +-
src/lib/css_plugin_options.ts | 5 -
src/lib/css_variable_utils.ts | 2 +-
src/lib/gen_fuz_css.ts | 2 -
src/lib/generate_css.ts | 3 -
src/lib/oklch.ts | 178 +
src/lib/ramps.ts | 341 ++
src/lib/style.css | 1397 +++---
src/lib/style_rule_parser.ts | 94 +-
src/lib/theme.css | 2307 ++++++----
src/lib/theme.gen.css.ts | 4 +-
src/lib/theme.ts | 27 +-
src/lib/themes.ts | 56 +-
src/lib/themes/base.ts | 9 +
src/lib/themes/brutalist.ts | 52 +
src/lib/themes/dark_only.ts | 26 +
src/lib/themes/high_contrast.ts | 15 +
src/lib/themes/low_contrast.ts | 13 +
src/lib/themes/necromancer.ts | 54 +
src/lib/themes/sunset_ember.ts | 36 +
src/lib/themes/terminal.ts | 57 +
src/lib/variable_graph.ts | 8 +-
src/lib/variables.ts | 2673 +++++------
src/lib/vite_plugin_fuz_css.ts | 2 -
src/lib/wcag.ts | 31 +
src/routes/ThemeForm.svelte | 6 +-
src/routes/docs/borders/+page.svelte | 10 +-
src/routes/docs/buttons/+page.svelte | 6 +-
src/routes/docs/chips/+page.svelte | 8 +-
src/routes/docs/classes/+page.svelte | 27 +-
src/routes/docs/colors/+page.svelte | 170 +-
src/routes/docs/colors/ColorSwatch.svelte | 29 +-
src/routes/docs/colors/ColorSwatchItem.svelte | 71 +-
src/routes/docs/colors/HueSwatch.svelte | 3 +-
src/routes/docs/shading/+page.svelte | 2 +-
src/routes/docs/shadows/+page.svelte | 28 +-
src/routes/docs/themes/+page.svelte | 27 +
src/routes/docs/typography/+page.svelte | 2 +-
src/test/class_variable_index.test.ts | 6 +-
...css_bundled_resolution.diagnostics.test.ts | 20 +-
src/test/css_bundled_resolution.test.ts | 154 +-
.../css_bundled_resolution.variables.test.ts | 17 +-
src/test/css_literal.test.ts | 2 +-
src/test/css_plugin_options.test.ts | 4 +-
src/test/css_ruleset_parser.modifiers.test.ts | 8 +-
src/test/css_ruleset_parser.parse.test.ts | 6 +-
src/test/css_variable_utils.test.ts | 4 +-
src/test/fixtures/css_classes_fixture.json | 4046 +++++------------
src/test/generate_classes_css.test.ts | 4 +-
src/test/modified_class_interpreter.test.ts | 4 +-
src/test/oklch.test.ts | 137 +
src/test/ramps.test.ts | 189 +
src/test/style_rule_parser.test.ts | 10 +-
src/test/styles.test.ts | 9 +-
src/test/themes.test.ts | 30 +
src/test/variable.test.ts | 2 +-
src/test/variable_graph.test.ts | 42 +-
src/test/variables.test.ts | 7 +-
src/test/vite_plugin_examples.test.ts | 22 +-
68 files changed, 6465 insertions(+), 6359 deletions(-)
create mode 100644 src/lib/oklch.ts
create mode 100644 src/lib/ramps.ts
create mode 100644 src/lib/themes/base.ts
create mode 100644 src/lib/themes/brutalist.ts
create mode 100644 src/lib/themes/dark_only.ts
create mode 100644 src/lib/themes/high_contrast.ts
create mode 100644 src/lib/themes/low_contrast.ts
create mode 100644 src/lib/themes/necromancer.ts
create mode 100644 src/lib/themes/sunset_ember.ts
create mode 100644 src/lib/themes/terminal.ts
create mode 100644 src/lib/wcag.ts
create mode 100644 src/test/oklch.test.ts
create mode 100644 src/test/ramps.test.ts
diff --git a/.changeset/twelve-shrimps-report.md b/.changeset/twelve-shrimps-report.md
index 28c4b94f6..c217dd2ac 100644
--- a/.changeset/twelve-shrimps-report.md
+++ b/.changeset/twelve-shrimps-report.md
@@ -2,4 +2,58 @@
'@fuzdev/fuz_css': minor
---
-feat: rework to oklch and add themes
+feat: rework the color system to derived OKLCH, add semantic roles, cascade layers, and themes
+
+The color system is now derived: curve knobs → ramp stops → color stops →
+utility classes, computed in pure CSS (`calc()`/`pow()`/`oklch()`), fitted to
+minimize the perceptual delta from the old HSL palette. Breaking changes:
+
+- **`color_` family renamed to `palette_`**: variables `--color_a_50` →
+ `--palette_a_50`, token classes `.color_a_50` → `.palette_a_50`, semantic
+ component classes `.color_a`–`.color_j` → `.palette_a`–`.palette_j`, and
+ the hue+intensity class families `border_color_X_NN` → `border_palette_X_NN`,
+ `outline_color_X_NN` → `outline_palette_X_NN`, `shadow_color_X_NN` →
+ `shadow_palette_X_NN`. `bg_X_NN` classes keep their names. The
+ `border_color_NN` alpha-ramp family (no hue letter) keeps its names.
+- **`--hue_a`…`--hue_j` values reinterpreted** as OKLCH hue angles (blue is
+ now `250`, not HSL `210`). Any consumer CSS doing `hsl(var(--hue_x) …)`
+ breaks — use `oklch( var(--hue_x))` or the palette/role stops.
+- **`--tint_hue`/`--tint_saturation` removed** → `--hue_neutral` (defaults to
+ `var(--hue_f)`) + `--neutral_chroma` (peak chroma of the neutral scales).
+- **Absolute `_light`/`_dark` variants removed** (~260 generated variables
+ like `--color_a_50_light`/`--shade_20_dark` and their classes, including
+ `shade_XX_light/dark`). 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 derived per-stop variables
+ (`--palette_lightness_NN`, `--palette_chroma_NN`, `--chroma_shape_NN`,
+ `--hue_shift_NN`) that themes can pin individually.
+- **New semantic role knobs**: `--hue_accent`, `--hue_positive`,
+ `--hue_negative`, `--hue_caution`, `--hue_info`, with derived role stops
+ `--accent_50`, `--accent_60`, `--negative_40`, `--negative_50` and the
+ `--selection_color` site variable. Links, focus, selection, selected
+ states, `accent-color`, and disabled-active feedback all route through
+ them; focus now follows the element color (via `--outline_color`) with the
+ accent as fallback.
+- **Cascade layers**: all shipped CSS is layered `fuz.base` (defaults) <
+ `fuz.theme` (theme overrides) < `fuz.utilities` (generated classes);
+ consumers' unlayered styles beat everything. `render_theme_style` lost its
+ `specificity` option (the `:root:root` hack) and gained
+ `layer?: string | null` (default `'fuz.theme'`); `generate_theme_css` lost
+ its specificity parameter; the `theme_specificity` generator option is
+ removed.
+- **`color-mix()` interpolation moved from `in hsl` to `in oklab`** in button
+ fills/borders, composites, and shadow classes.
+- **Themes**: one module per theme under `themes/`
+ (`@fuzdev/fuz_css/themes/necromancer.ts` etc.); `themes.ts` exports the
+ curated `default_themes` registry (base, low contrast, high contrast,
+ terminal green) with unregistered expressive exemplars (necromancer,
+ sunset ember, brutalist). The contrast themes are rewritten as curve-knob
+ overrides.
+- **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 (AA/AAA thresholds the old palette partly
+ failed).
diff --git a/CLAUDE.md b/CLAUDE.md
index c6948b9c9..2c30c03b5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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`) → `
diff --git a/src/routes/ThemeEditor.svelte b/src/routes/ThemeEditor.svelte
new file mode 100644
index 000000000..97f588a72
--- /dev/null
+++ b/src/routes/ThemeEditor.svelte
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+
+
+ {#each quick_knobs as knob (knob.name)}
+ editor.set_value(knob.name, value, editing_scheme)}
+ onreset={() => editor.reset(knob.name)}
+ />
+ {/each}
+
+
+
+ {#each axes as { axis, title } (axis)}
+ {@const knobs = semantic_knobs(axis)}
+ {#if knobs.length}
+
+ {title}
+
+ {#each knobs as knob (knob.name)}
+ editor.set_value(knob.name, value, editing_scheme)}
+ onreset={() => editor.reset(knob.name)}
+ />
+ {/each}
+
+ {#if axis === 'color'}
+
+ palette hues (the letter slots - moving these makes the theme palette-tier)
+
+ {#each palette_knobs as knob (knob.name)}
+ editor.set_value(knob.name, value, editing_scheme)}
+ onreset={() => editor.reset(knob.name)}
+ />
+ {/each}
+
+
+ {/if}
+
+ {/if}
+ {/each}
+
+
+ Output
+
+ The copyable Theme object, and the CSS it renders — the theme's own footprint.
+
+
+
+
+
+
+
diff --git a/src/routes/ThemeForm.svelte b/src/routes/ThemeForm.svelte
deleted file mode 100644
index 5ce62cd2c..000000000
--- a/src/routes/ThemeForm.svelte
+++ /dev/null
@@ -1,160 +0,0 @@
-
-
-
-{#if selected_variable}
- (selected_variable = null)}>
-
- {#snippet children({close})}
-
-
-
- ok
-
- {/snippet}
-
-
-{/if}
-
-
diff --git a/src/routes/docs/colors/+page.svelte b/src/routes/docs/colors/+page.svelte
index 1e082c46c..2a5d1e6c0 100644
--- a/src/routes/docs/colors/+page.svelte
+++ b/src/routes/docs/colors/+page.svelte
@@ -52,7 +52,8 @@
Hues use letters so themes can reassign colors without breaking semantics -- "a" is blue by
default but could be any color. Meaning attaches through the role knobs layered on top:
- --hue_accent (links, focus, selection, selected states) defaults to
+ --hue_accent (links, focus, selection, selected states -- what other systems call
+ the "primary" color, named here for what it communicates) defaults to
--hue_a, --hue_negative to --hue_c, and so on. Retarget
a role to move just that meaning; rotate a letter to move the palette.
diff --git a/src/routes/docs/themes/+page.svelte b/src/routes/docs/themes/+page.svelte
index 1147c58d3..465225cdb 100644
--- a/src/routes/docs/themes/+page.svelte
+++ b/src/routes/docs/themes/+page.svelte
@@ -1,7 +1,5 @@
@@ -98,9 +142,9 @@
module_path="theme.ts"
/> and .
-
+ Selecting a theme loads its knobs into the editor below.
- (editing_theme = t)} />
+
@@ -115,21 +159,18 @@
content={`import {necromancer_theme} from '@fuzdev/fuz_css/themes/necromancer.ts';`}
/>
- (editing_theme = t)} />
+
+
+
+
+ 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_NAME}"
+ theme in the picker above; it persists across navigation until you leave or reset, so copy
+ the Theme object below to keep it. Knob sizes reflect leverage: the big controls
+ reshape the whole system, the small ones are surgical escape hatches.
+
+
+
-
-{#if editing_theme}
- (editing_theme = null)}>
-
- {
- console.log(`update theme`, theme); // eslint-disable-line no-console
- alert('todo'); // eslint-disable-line no-alert
- }}
- />
-
-
-{/if}
diff --git a/src/routes/theme_editor_state.svelte.ts b/src/routes/theme_editor_state.svelte.ts
new file mode 100644
index 000000000..c3f3e181a
--- /dev/null
+++ b/src/routes/theme_editor_state.svelte.ts
@@ -0,0 +1,219 @@
+import {SvelteMap} from 'svelte/reactivity';
+
+import type {Theme} from '$lib/theme.ts';
+import type {StyleVariable} from '$lib/variable.ts';
+import {default_variables} from '$lib/variables.ts';
+import {theme_knob_by_name} from '$lib/knobs.ts';
+import type {ColorSchemeVariant} from '$lib/variable_data.ts';
+
+// TODO upsteam to fuz_ui
+
+/**
+ * The name of the in-progress theme shown in pickers. Never `'base'`, which
+ * `render_theme_style`/`ThemeRoot` special-case to render nothing, and never
+ * a registry/exemplar name, which would collide with `ThemeInput`'s
+ * name-keyed selection.
+ */
+export const UNSAVED_THEME_NAME = 'unsaved';
+
+const default_variable_by_name: Map = new Map(
+ default_variables.map((v) => [v.name, v]),
+);
+
+export interface SlotOverride {
+ light?: string;
+ dark?: string;
+}
+
+export interface ThemeEditorSnapshotData {
+ name: string;
+ based_on: string;
+ overrides: Array<[string, SlotOverride]>;
+}
+
+/**
+ * State for the inline theme editor: a base theme selected by name plus a map
+ * of per-variable slot overrides, merged into a draft `Theme` on the fly.
+ *
+ * Scheme semantics: variables whose effective definition is scheme-adaptive
+ * (dual-slot) edit the slot of the scheme being viewed; single-slot variables
+ * always edit the light (base) slot so the change applies to both schemes.
+ * When a fresh light-slot override lands on a variable whose default is
+ * dual-slot, the merge preserves the default's dark slot explicitly — a
+ * theme's `:root` block beats the base defaults' `:root.dark` by cascade
+ * layer order, so omitting it would silently change dark mode too. A base
+ * theme that itself sets only light slots (e.g. dark-only mirrors) chose
+ * those cross-scheme semantics deliberately and is left alone.
+ */
+export class ThemeEditorState {
+ readonly themes: Array = [];
+
+ name: string = $state.raw('new theme');
+ based_on: string = $state.raw('base');
+ readonly overrides: SvelteMap = new SvelteMap();
+
+ constructor(themes: Array) {
+ this.themes = themes;
+ }
+
+ readonly base_theme: Theme = $derived(
+ this.themes.find((t) => t.name === this.based_on) ?? this.themes[0]!,
+ );
+
+ readonly base_variable_by_name: Map = $derived(
+ new Map(this.base_theme.variables.map((v) => [v.name, v])),
+ );
+
+ readonly dirty: boolean = $derived(this.overrides.size > 0);
+
+ /**
+ * True when the draft (or its base) moves palette-tier knobs — the letter
+ * hues — making it an exemplar-tier theme per the two-tier policy.
+ */
+ readonly is_palette_tier: boolean = $derived.by(() => {
+ for (const name of this.overrides.keys()) {
+ if (theme_knob_by_name.get(name)?.tier === 'palette') return true;
+ }
+ for (const v of this.base_theme.variables) {
+ if (theme_knob_by_name.get(v.name)?.tier === 'palette') return true;
+ }
+ return false;
+ });
+
+ readonly merged_variables: Array = $derived.by(() => {
+ const merged: Array = [];
+ const seen: Set = new Set();
+ for (const v of this.base_theme.variables) {
+ seen.add(v.name);
+ const m = this.#merge_variable(v.name);
+ if (m) merged.push(m);
+ }
+ for (const name of this.overrides.keys()) {
+ if (seen.has(name)) continue;
+ const m = this.#merge_variable(name);
+ if (m) merged.push(m);
+ }
+ return merged;
+ });
+
+ #merge_variable(name: string): StyleVariable | null {
+ const o = this.overrides.get(name);
+ const b = this.base_variable_by_name.get(name);
+ const light = o?.light ?? b?.light;
+ let dark = o?.dark ?? b?.dark;
+ // preserve a scheme-adaptive default's dark slot for fresh light-only
+ // overrides - see the class comment for the cascade-layer rationale
+ if (o?.light !== undefined && dark === undefined && !b) {
+ dark = default_variable_by_name.get(name)?.dark;
+ }
+ if (dark !== undefined && dark === light) dark = undefined;
+ if (light === undefined && dark === undefined) return null;
+ const merged: StyleVariable = {name};
+ if (light !== undefined) merged.light = light;
+ if (dark !== undefined) merged.dark = dark;
+ return merged;
+ }
+
+ /** The live-applied theme, stably named so pickers key it consistently. */
+ readonly draft: Theme = $derived({name: UNSAVED_THEME_NAME, variables: this.merged_variables});
+
+ /** The copyable theme, carrying the user's chosen name. */
+ readonly output: Theme = $derived({name: this.name, variables: this.merged_variables});
+
+ /**
+ * The value a scheme currently renders for a variable, accounting for the
+ * theme layer's light slots beating the base defaults' dark slots.
+ */
+ display_value(name: string, scheme: ColorSchemeVariant): string | undefined {
+ const o = this.overrides.get(name);
+ const b = this.base_variable_by_name.get(name);
+ const d = default_variable_by_name.get(name);
+ if (scheme === 'light') return o?.light ?? b?.light ?? d?.light;
+ return o?.dark ?? b?.dark ?? o?.light ?? b?.light ?? d?.dark ?? d?.light;
+ }
+
+ changed(name: string): boolean {
+ return this.overrides.has(name);
+ }
+
+ set_value(name: string, value: string, scheme: ColorSchemeVariant): void {
+ const o = this.overrides.get(name);
+ const b = this.base_variable_by_name.get(name);
+ const d = default_variable_by_name.get(name);
+ const adaptive = d?.dark !== undefined || b?.dark !== undefined || o?.dark !== undefined;
+ const slot = adaptive ? scheme : 'light';
+ this.overrides.set(name, {...o, [slot]: value});
+ }
+
+ reset(name: string): void {
+ this.overrides.delete(name);
+ }
+
+ reset_all(): void {
+ this.overrides.clear();
+ }
+
+ /**
+ * Loads a theme as the new base: overrides clear and the editor edits on
+ * top of its flattened variables (flatten-on-load composition).
+ *
+ * @mutates `this`
+ */
+ load_theme(theme: Theme): void {
+ if (theme.name === UNSAVED_THEME_NAME) return;
+ this.based_on = theme.name;
+ this.overrides.clear();
+ this.name = theme.name === 'base' ? 'new theme' : `custom ${theme.name}`;
+ }
+
+ to_snapshot(): ThemeEditorSnapshotData {
+ return {
+ name: this.name,
+ based_on: this.based_on,
+ overrides: Array.from(this.overrides.entries()).map(([name, o]) => [name, {...o}]),
+ };
+ }
+
+ /**
+ * @mutates `this`
+ */
+ restore_snapshot(data: ThemeEditorSnapshotData): void {
+ this.name = data.name;
+ this.based_on = data.based_on;
+ this.overrides.clear();
+ for (const [name, o] of data.overrides) {
+ this.overrides.set(name, {...o});
+ }
+ }
+}
+
+const escape_single_quotes = (s: string): string =>
+ s.replaceAll('\\', '\\\\').replaceAll("'", "\\'");
+
+/**
+ * Renders a theme as a copyable TypeScript module.
+ */
+export const render_theme_ts = (theme: Theme): string => {
+ const identifier =
+ theme.name
+ .toLowerCase()
+ .replaceAll(/[^a-z0-9]+/gu, '_')
+ .replaceAll(/^_+|_+$/gu, '') || 'custom';
+ const variables = theme.variables
+ .map((v) => {
+ const parts = [`name: '${escape_single_quotes(v.name)}'`];
+ if (v.light !== undefined) parts.push(`light: '${escape_single_quotes(v.light)}'`);
+ if (v.dark !== undefined) parts.push(`dark: '${escape_single_quotes(v.dark)}'`);
+ return `\t\t{${parts.join(', ')}},`;
+ })
+ .join('\n');
+ return `import type {Theme} from '@fuzdev/fuz_css/theme.ts';
+
+export const ${identifier}_theme: Theme = {
+ name: '${escape_single_quotes(theme.name)}',
+ variables: [
+${variables}
+ ],
+};
+`;
+};
diff --git a/src/test/knobs.test.ts b/src/test/knobs.test.ts
new file mode 100644
index 000000000..e77f5950f
--- /dev/null
+++ b/src/test/knobs.test.ts
@@ -0,0 +1,70 @@
+import {test, assert, describe} from 'vitest';
+
+import {theme_knobs, theme_knob_by_name, theme_knob_hook_names} from '$lib/knobs.ts';
+import {default_variables} from '$lib/variables.ts';
+
+const declared_names = new Set(default_variables.map((v) => v.name));
+
+describe('theme_knobs', () => {
+ test('knob names are unique', () => {
+ const names = theme_knobs.map((k) => k.name);
+ assert.strictEqual(new Set(names).size, names.length);
+ });
+
+ test('non-hook knobs resolve to declared variables', () => {
+ for (const knob of theme_knobs) {
+ if (knob.hook) continue;
+ assert.isTrue(
+ declared_names.has(knob.name),
+ `Knob "${knob.name}" is not declared in default_variables`,
+ );
+ }
+ });
+
+ test('hook knobs are not declared variables', () => {
+ // a hook knob that gains a declaration should drop its stale `hook` flag
+ for (const name of theme_knob_hook_names) {
+ assert.isFalse(
+ declared_names.has(name),
+ `Hook knob "${name}" is declared in default_variables - remove its hook flag`,
+ );
+ }
+ });
+
+ test('ranges and steps are sane', () => {
+ for (const knob of theme_knobs) {
+ if (knob.range) {
+ assert.isBelow(knob.range[0], knob.range[1], `Knob "${knob.name}" has an inverted range`);
+ }
+ if (knob.step !== undefined) {
+ assert.isAbove(knob.step, 0, `Knob "${knob.name}" has a nonpositive step`);
+ }
+ if (knob.kind === 'enum') {
+ assert.isAbove(knob.values?.length ?? 0, 0, `Enum knob "${knob.name}" needs values`);
+ }
+ }
+ });
+
+ test('palette tier is exactly the letter hues', () => {
+ const palette_names = theme_knobs.filter((k) => k.tier === 'palette').map((k) => k.name);
+ assert.deepEqual(palette_names, [
+ 'hue_a',
+ 'hue_b',
+ 'hue_c',
+ 'hue_d',
+ 'hue_e',
+ 'hue_f',
+ 'hue_g',
+ 'hue_h',
+ 'hue_i',
+ 'hue_j',
+ ]);
+ });
+
+ test('theme_knob_by_name indexes every knob', () => {
+ assert.strictEqual(theme_knob_by_name.size, theme_knobs.length);
+ for (const knob of theme_knobs) {
+ assert.strictEqual(theme_knob_by_name.get(knob.name), knob);
+ }
+ });
+});
diff --git a/src/test/styles.test.ts b/src/test/styles.test.ts
index 9fbafd508..b29f759cd 100644
--- a/src/test/styles.test.ts
+++ b/src/test/styles.test.ts
@@ -2,6 +2,7 @@ import {test, assert} from 'vitest';
import {readFileSync} from 'node:fs';
import * as exported_variables from '$lib/variables.ts';
+import {theme_knob_hook_names} from '$lib/knobs.ts';
import css_classes_text from './fixtures/css_classes_fixture.json?raw';
// vitest replaces this with an empty string because CSS isn't opted into being processed,
@@ -52,8 +53,11 @@ test('variables in the CSS exist', () => {
/**
* These variables are known to be in the CSS but not in the exported variables.
* This means they can be contextually used when defined, but otherwise have a fallback.
+ * Hook knobs from the catalog (e.g. `heading_font_weight`) are included
+ * automatically — they're defined as CSS-consumed-but-undeclared.
*/
const known_without_variables = new Set([
+ ...theme_knob_hook_names,
'fill', // contextual variable set by button palette classes (e.g., .palette_a sets --fill: var(--palette_a_40))
'button_fill',
'button_fill_hover',
diff --git a/src/test/themes.test.ts b/src/test/themes.test.ts
index c72d33d7b..f685067d2 100644
--- a/src/test/themes.test.ts
+++ b/src/test/themes.test.ts
@@ -3,16 +3,20 @@ import {test, assert, describe} from 'vitest';
import {default_themes, DEFAULT_THEME} from '$lib/themes.ts';
import {necromancer_theme} from '$lib/themes/necromancer.ts';
import {sunset_ember_theme} from '$lib/themes/sunset_ember.ts';
-import {brutalist_theme} from '$lib/themes/brutalist.ts';
+import {brutalish_theme} from '$lib/themes/brutalish.ts';
import {create_terminal_theme, terminal_green_theme} from '$lib/themes/terminal.ts';
import {default_variables} from '$lib/variables.ts';
import {StyleVariable} from '$lib/variable.ts';
+import {theme_knob_hook_names} from '$lib/knobs.ts';
+
+// declared variables plus the hook knobs style.css consumes via fallbacks
+const known_names = new Set([...default_variables.map((v) => v.name), ...theme_knob_hook_names]);
/** Shipped exemplar themes that deliberately stay out of the registry. */
const exemplar_themes = [
necromancer_theme,
sunset_ember_theme,
- brutalist_theme,
+ brutalish_theme,
terminal_green_theme,
create_terminal_theme(70), // amber, exercises the factory
];
@@ -71,7 +75,6 @@ describe('default_themes', () => {
});
test('theme variable names exist in default_variables', () => {
- const known_names = new Set(default_variables.map((v) => v.name));
for (const theme of default_themes) {
for (const variable of theme.variables) {
assert.isTrue(
@@ -92,7 +95,6 @@ describe('exemplar themes', () => {
});
test('all exemplar variables validate and exist in default_variables', () => {
- const known_names = new Set(default_variables.map((v) => v.name));
for (const theme of exemplar_themes) {
assert.isAbove(theme.variables.length, 0);
for (const variable of theme.variables) {
From 990448e0702cb82cc73c60f6d09b7f6fc448f4e5 Mon Sep 17 00:00:00 2001
From: Ryan Atkinson
Date: Fri, 10 Jul 2026 14:03:01 -0400
Subject: [PATCH 05/11] wip
---
.changeset/twelve-shrimps-report.md | 12 +-
CLAUDE.md | 2 +-
package-lock.json | 8 +-
package.json | 2 +-
src/lib/css_class_definitions.ts | 54 ++++----
src/lib/ramps.ts | 12 +-
src/lib/variable_data.ts | 4 +-
src/routes/KnobControl.svelte | 119 ++++++++++--------
src/routes/ThemeEditor.svelte | 38 ++----
src/routes/docs/borders/+page.svelte | 8 +-
src/routes/docs/buttons/+page.svelte | 36 +++---
src/routes/docs/chips/+page.svelte | 12 +-
src/routes/docs/classes/+page.svelte | 17 +--
src/routes/docs/colors/+page.svelte | 12 +-
src/routes/docs/colors/ColorSwatch.svelte | 8 +-
src/routes/docs/colors/ColorSwatchItem.svelte | 7 +-
src/routes/docs/colors/HueSwatch.svelte | 8 +-
src/routes/docs/shadows/+page.svelte | 24 ++--
src/routes/theme_editor_state.svelte.ts | 16 +--
.../css_bundled_resolution.variables.test.ts | 2 +-
src/test/ramps.test.ts | 8 +-
21 files changed, 208 insertions(+), 201 deletions(-)
diff --git a/.changeset/twelve-shrimps-report.md b/.changeset/twelve-shrimps-report.md
index 9e5ff6f5f..e04918025 100644
--- a/.changeset/twelve-shrimps-report.md
+++ b/.changeset/twelve-shrimps-report.md
@@ -15,14 +15,17 @@ minimize the perceptual delta from the old HSL palette. Breaking changes:
`outline_color_X_NN` → `outline_palette_X_NN`, `shadow_color_X_NN` →
`shadow_palette_X_NN`. `bg_X_NN` classes keep their names. The
`border_color_NN` alpha-ramp family (no hue letter) keeps its names.
+ At the TS level, the letter list in `variable_data.ts` renames with the
+ family: `ColorVariant`/`color_variants` → `PaletteVariant`/`palette_variants`.
- **`--hue_a`…`--hue_j` values reinterpreted** as OKLCH hue angles (blue is
now `250`, not HSL `210`). Any consumer CSS doing `hsl(var(--hue_x) …)`
breaks — use `oklch( var(--hue_x))` or the palette/role stops.
- **`--tint_hue`/`--tint_saturation` removed** → `--hue_neutral` (defaults to
`var(--hue_f)`) + `--neutral_chroma` (peak chroma of the neutral scales).
-- **Absolute `_light`/`_dark` variants removed** (~260 generated variables
- like `--color_a_50_light`/`--shade_20_dark` and their classes, including
- `shade_XX_light/dark`). Write the literal color or define one custom
+- **Absolute `_light`/`_dark` variants removed**: the ~286 generated
+ variables (`--color_a_50_light`-style, including `--shade_XX_light/dark`)
+ and all their classes — `color_X_NN_light/dark`, `bg_X_NN_light/dark`, and
+ `shade_NN_light/dark`. 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
@@ -43,7 +46,8 @@ minimize the perceptual delta from the old HSL palette. Breaking changes:
`specificity` option (the `:root:root` hack) and gained
`layer?: string | null` (default `'fuz.theme'`); `generate_theme_css` lost
its specificity parameter; the `theme_specificity` generator option is
- removed.
+ removed. Custom `base_css` input is re-layered into `fuz.base` in bundled
+ output — its own `@layer` identities aren't preserved.
- **`color-mix()` interpolation moved from `in hsl` to `in oklab`** in button
fills/borders, composites, and shadow classes.
- **Themes**: one module per theme under `themes/`
diff --git a/CLAUDE.md b/CLAUDE.md
index c86cfb348..c5b1dd4fc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -184,7 +184,7 @@ 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.
+[variable_data.ts](src/lib/variable_data.ts) for size/palette variants.
**Colors (OKLCH, derived):**
diff --git a/package-lock.json b/package-lock.json
index 8977d1364..4b6d59c39 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
"@changesets/changelog-git": "^0.2.1",
"@fuzdev/blake3_wasm": "^0.1.1",
"@fuzdev/fuz_code": "^0.47.0",
- "@fuzdev/fuz_ui": "^0.206.6",
+ "@fuzdev/fuz_ui": "^0.206.7",
"@fuzdev/fuz_util": "^0.65.2",
"@fuzdev/gro": "^0.206.0",
"@fuzdev/mdz": "^0.3.0",
@@ -837,9 +837,9 @@
}
},
"node_modules/@fuzdev/fuz_ui": {
- "version": "0.206.6",
- "resolved": "https://registry.npmjs.org/@fuzdev/fuz_ui/-/fuz_ui-0.206.6.tgz",
- "integrity": "sha512-LJzI0GgqGYojwhpR/vjTX5CEY5XJN3Nz2Qg/akgB0foa7u8YMrwT3nXPbH3LmTkjvP6eGI6j4lELHUdEEouPnw==",
+ "version": "0.206.7",
+ "resolved": "https://registry.npmjs.org/@fuzdev/fuz_ui/-/fuz_ui-0.206.7.tgz",
+ "integrity": "sha512-UUO+6zVK6Ot9NmZLaKWASJ6odjjLOUDObIWOjSKtoPBnSu62UvzlGYdSGEHYk1hSR58wNj0qndhWNDzmf/WDdg==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/package.json b/package.json
index b9ba3a86d..c7b31b017 100644
--- a/package.json
+++ b/package.json
@@ -70,7 +70,7 @@
"@changesets/changelog-git": "^0.2.1",
"@fuzdev/blake3_wasm": "^0.1.1",
"@fuzdev/fuz_code": "^0.47.0",
- "@fuzdev/fuz_ui": "^0.206.6",
+ "@fuzdev/fuz_ui": "^0.206.7",
"@fuzdev/fuz_util": "^0.65.2",
"@fuzdev/gro": "^0.206.0",
"@fuzdev/mdz": "^0.3.0",
diff --git a/src/lib/css_class_definitions.ts b/src/lib/css_class_definitions.ts
index cf4259c42..cf270bb9e 100644
--- a/src/lib/css_class_definitions.ts
+++ b/src/lib/css_class_definitions.ts
@@ -19,7 +19,7 @@ import {
import {
space_variants,
distance_variants,
- color_variants,
+ palette_variants,
intensity_variants,
shade_variants,
shade_scale_variants,
@@ -96,30 +96,30 @@ export const css_class_definitions: Record ({
- name: `hue_${hue}`,
- css: `--hue: var(--hue_${hue});`,
+ (letter: string) => ({
+ name: `hue_${letter}`,
+ css: `--hue: var(--hue_${letter});`,
}),
- color_variants,
+ palette_variants,
),
// Palette intensity classes (text color)
...generate_classes(
- (hue: string, intensity: string) => ({
- name: `palette_${hue}_${intensity}`,
- css: `color: var(--palette_${hue}_${intensity}); --text_color: var(--palette_${hue}_${
+ (letter: string, intensity: string) => ({
+ name: `palette_${letter}_${intensity}`,
+ css: `color: var(--palette_${letter}_${intensity}); --text_color: var(--palette_${letter}_${
intensity
});`,
}),
- color_variants,
+ palette_variants,
intensity_variants,
),
// Palette intensity classes (background color)
...generate_classes(
- (hue: string, intensity: string) => ({
- name: `bg_${hue}_${intensity}`,
- css: `background-color: var(--palette_${hue}_${intensity});`,
+ (letter: string, intensity: string) => ({
+ name: `bg_${letter}_${intensity}`,
+ css: `background-color: var(--palette_${letter}_${intensity});`,
}),
- color_variants,
+ palette_variants,
intensity_variants,
),
// Darken/lighten overlays (non-adaptive, alpha-based)
@@ -164,26 +164,26 @@ export const css_class_definitions: Record ({
- name: `border_palette_${hue}_${intensity}`,
- css: `border-color: var(--palette_${hue}_${intensity}); --border_color: var(--palette_${
- hue
+ (letter: string, intensity: string) => ({
+ name: `border_palette_${letter}_${intensity}`,
+ css: `border-color: var(--palette_${letter}_${intensity}); --border_color: var(--palette_${
+ letter
}_${intensity});`,
}),
- color_variants,
+ palette_variants,
intensity_variants,
),
// Outline colors using shade scale
...generate_property_classes('outline-color', shade_variants, (v) => `var(--shade_${v})`),
// Outline colors using palette hue + intensity (sets both property and contextual variable)
...generate_classes(
- (hue: string, intensity: string) => ({
- name: `outline_palette_${hue}_${intensity}`,
- css: `outline-color: var(--palette_${hue}_${intensity}); --outline_color: var(--palette_${
- hue
+ (letter: string, intensity: string) => ({
+ name: `outline_palette_${letter}_${intensity}`,
+ css: `outline-color: var(--palette_${letter}_${intensity}); --outline_color: var(--palette_${
+ letter
}_${intensity});`,
}),
- color_variants,
+ palette_variants,
intensity_variants,
),
@@ -235,11 +235,11 @@ export const css_class_definitions: Record ({
- name: `shadow_palette_${hue}_${intensity}`,
- css: `--shadow_color: var(--palette_${hue}_${intensity});`,
+ (letter: string, intensity: string) => ({
+ name: `shadow_palette_${letter}_${intensity}`,
+ css: `--shadow_color: var(--palette_${letter}_${intensity});`,
}),
- color_variants,
+ palette_variants,
intensity_variants,
),
diff --git a/src/lib/ramps.ts b/src/lib/ramps.ts
index b68592200..35d5deff0 100644
--- a/src/lib/ramps.ts
+++ b/src/lib/ramps.ts
@@ -24,7 +24,7 @@
import {
numeric_scale_variants,
- type ColorVariant,
+ type PaletteVariant,
type ColorSchemeVariant,
type NumericScaleVariant,
} from './variable_data.ts';
@@ -69,7 +69,7 @@ export interface ChromaRampKnobs {
}
/** OKLCH hue angles for the 10 palette hues, fitted from the HSL palette. */
-export const PALETTE_HUES: Record = {
+export const PALETTE_HUES: Record = {
a: 250, // blue
b: 144, // green
c: 24, // red
@@ -215,7 +215,7 @@ export const ramp_chroma = (
* Computes the default OKLCH color of a palette stop (`--palette_X_NN`).
*/
export const palette_stop_oklch = (
- letter: ColorVariant,
+ letter: PaletteVariant,
stop: NumericScaleVariant,
scheme: ColorSchemeVariant,
): Oklch => [
@@ -318,8 +318,10 @@ export const render_hue_shift_offset_css = (
* Renders the derived default of a palette color stop, e.g. `--palette_a_50`.
* One definition serves both schemes — the scheme flip lives in the knobs.
*/
-export const render_palette_stop_css = (letter: ColorVariant, stop: NumericScaleVariant): string =>
- render_ramp_color_css(`var(--hue_${letter})`, stop);
+export const render_palette_stop_css = (
+ letter: PaletteVariant,
+ stop: NumericScaleVariant,
+): string => render_ramp_color_css(`var(--hue_${letter})`, stop);
/**
* Renders a color derived from the palette ramps at a stop for an arbitrary
diff --git a/src/lib/variable_data.ts b/src/lib/variable_data.ts
index 2d789e5f2..8e1bb1ed4 100644
--- a/src/lib/variable_data.ts
+++ b/src/lib/variable_data.ts
@@ -135,8 +135,8 @@ export const icon_sizes = {
icon_size_xl3: '256px',
};
-export type ColorVariant = ArrayElement;
-export const color_variants = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] as const;
+export type PaletteVariant = ArrayElement;
+export const palette_variants = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] as const;
export type IntensityVariant = NumericScaleVariant;
export const intensity_variants = numeric_scale_variants;
diff --git a/src/routes/KnobControl.svelte b/src/routes/KnobControl.svelte
index 907659a35..c0f2bfcb7 100644
--- a/src/routes/KnobControl.svelte
+++ b/src/routes/KnobControl.svelte
@@ -52,61 +52,67 @@
diff --git a/src/routes/ThemeEditor.svelte b/src/routes/ThemeEditor.svelte
index 97f588a72..e0d3b7c39 100644
--- a/src/routes/ThemeEditor.svelte
+++ b/src/routes/ThemeEditor.svelte
@@ -1,5 +1,5 @@
-
-
-
+
+
+
name
@@ -83,7 +83,7 @@
{/each}
-
+
{editor.is_palette_tier ? 'palette-tier' : 'semantic-tier'}
{#if editor.dirty}
-
editor.reset_all()}
+ editor.reset_all()}
>reset all ({editor.overrides.size})
{/if}
@@ -110,7 +110,7 @@
-
+
{#each quick_knobs as knob (knob.name)}
{title}
-
+
{#each knobs as knob (knob.name)}
(the letter slots - moving these makes the theme palette-tier)
-
+
{#each palette_knobs as knob (knob.name)}
The copyable Theme object, and the CSS it renders — the theme's own footprint.
-
+
-
+
diff --git a/src/routes/RampStrip.svelte b/src/routes/RampStrip.svelte
new file mode 100644
index 000000000..6132c95f9
--- /dev/null
+++ b/src/routes/RampStrip.svelte
@@ -0,0 +1,54 @@
+
+
+
+
+
{label}
+
+ {#each numeric_scale_variants as stop (stop)}
+
+ {/each}
+
+
+
+
diff --git a/src/routes/ThemeEditor.svelte b/src/routes/ThemeEditor.svelte
index c17b25502..a8efdd913 100644
--- a/src/routes/ThemeEditor.svelte
+++ b/src/routes/ThemeEditor.svelte
@@ -7,14 +7,21 @@
import type {ThemeState} from '@fuzdev/fuz_ui/theme_state.svelte.ts';
import {render_theme_style} from '$lib/theme.ts';
- import {theme_knobs, type KnobAxis, type ThemeKnob} from '$lib/knobs.ts';
- import type {ColorSchemeVariant} from '$lib/variable_data.ts';
+ import {theme_knobs, knob_axes, type KnobAxis, type ThemeKnob} from '$lib/knobs.ts';
+ import {PALETTE_HUES} from '$lib/ramps.ts';
+ import {
+ palette_variants,
+ intent_variants,
+ type ColorSchemeVariant,
+ type PaletteVariant,
+ } from '$lib/variable_data.ts';
import {
render_theme_ts,
UNSAVED_THEME_NAME,
type ThemeEditorState,
} from '$routes/theme_editor_state.svelte.ts';
import KnobControl from '$routes/KnobControl.svelte';
+ import RampStrip from '$routes/RampStrip.svelte';
// @fuz-classes sm md lg
@@ -37,21 +44,30 @@
: 'light';
});
- const axes: Array<{axis: KnobAxis; title: string}> = [
- {axis: 'color', title: 'Color'},
- {axis: 'shape', title: 'Shape'},
- {axis: 'density', title: 'Density'},
- {axis: 'depth', title: 'Depth'},
- {axis: 'typography', title: 'Typography'},
- {axis: 'motion', title: 'Motion'},
- {axis: 'decoration', title: 'Decoration'},
- ];
-
const semantic_knobs = (axis: KnobAxis): Array
=>
theme_knobs.filter((k) => k.axis === axis && k.tier === 'semantic');
const palette_knobs = theme_knobs.filter((k) => k.tier === 'palette');
- // the highest-leverage knobs duplicated compactly above the granular flow
- const quick_knobs = theme_knobs.filter((k) => k.leverage === 'lg');
+ // the design band: intent/neutral bindings plus the highest-leverage levers,
+ // duplicated compactly above the granular flow - same state, two densities
+ const binding_knobs = theme_knobs.filter((k) => k.bindable);
+ const lever_knobs = theme_knobs.filter((k) => k.leverage === 'lg' && !k.bindable);
+
+ // the sm escape-hatch walls fold behind a disclosure per axis so the levers
+ // stay the visual main flow
+ const sm_details_titles: Partial> = {
+ shape: 'border width & radius tokens',
+ density: 'space tokens',
+ typography: 'line height tokens',
+ motion: 'motion tokens',
+ };
+
+ // resolves a letter's current angle from the same merge the renderer uses,
+ // so binding chips and detachment track theme overrides
+ const resolve_hue = (letter: PaletteVariant): number => {
+ const v = editor.display_value(`hue_${letter}`, editing_scheme);
+ const n = Number(v);
+ return Number.isNaN(n) ? PALETTE_HUES[letter] : n;
+ };
const trimmed_name = $derived(editor.name.trim());
const name_collides = $derived(
@@ -62,6 +78,18 @@
const output_css = $derived(render_theme_style(editor.output));
+{#snippet knob_control(knob: ThemeKnob, compact: boolean)}
+ editor.set_value(knob.name, value, editing_scheme)}
+ onreset={() => editor.reset(knob.name)}
+ />
+{/snippet}
+
@@ -74,7 +102,19 @@
{
- const theme = editor.themes.find((t) => t.name === e.currentTarget.value);
+ const name = e.currentTarget.value;
+ if (
+ editor.dirty &&
+ !confirm(
+ `load "${name}" as the new base? ${
+ editor.overrides.size
+ } edited knob(s) will be discarded`,
+ )
+ ) {
+ e.currentTarget.value = editor.based_on;
+ return;
+ }
+ const theme = editor.themes.find((t) => t.name === name);
if (theme) editor.load_theme(theme);
}}
>
@@ -88,7 +128,7 @@
class="chip"
title={editor.is_palette_tier
? 'moves palette letter hues - exemplar tier, stays out of registries'
- : 'moves only role bindings and levers - safe for theme registries'}
+ : 'moves only intent bindings and levers - safe for theme registries'}
>{editor.is_palette_tier ? 'palette-tier' : 'semantic-tier'}
{#if editor.dirty}
@@ -109,37 +149,62 @@
-
-
- {#each quick_knobs as knob (knob.name)}
-
editor.set_value(knob.name, value, editing_scheme)}
- onreset={() => editor.reset(knob.name)}
- />
- {/each}
+
+
+
intents - what each meaning points at
+
+ {#each binding_knobs as knob (knob.name)}
+ {@render knob_control(knob, true)}
+ {/each}
+
+
+
+
levers - the highest-leverage knobs
+
+ {#each lever_knobs as knob (knob.name)}
+ {@render knob_control(knob, true)}
+ {/each}
+
- {#each axes as { axis, title } (axis)}
- {@const knobs = semantic_knobs(axis)}
- {#if knobs.length}
+ {#each knob_axes as { axis, title } (axis)}
+ {@const inline_knobs = semantic_knobs(axis).filter((k) => k.leverage !== 'sm')}
+ {@const sm_knobs = semantic_knobs(axis).filter((k) => k.leverage === 'sm')}
+ {#if inline_knobs.length || sm_knobs.length}
{title}
-
- {#each knobs as knob (knob.name)}
- editor.set_value(knob.name, value, editing_scheme)}
- onreset={() => editor.reset(knob.name)}
- />
- {/each}
-
+ {#if axis === 'color'}
+
+
+
+
+ {#each intent_variants as intent (intent)}
+
+ {/each}
+
+ {/if}
+ {#if inline_knobs.length}
+
+ {#each inline_knobs as knob (knob.name)}
+ {@render knob_control(knob, false)}
+ {/each}
+
+ {/if}
+ {#if sm_knobs.length}
+
+ {sm_details_titles[axis] ?? 'granular tokens'}
+ (escape hatches - pin individual tokens)
+
+ {#each sm_knobs as knob (knob.name)}
+ {@render knob_control(knob, false)}
+ {/each}
+
+
+ {/if}
{#if axis === 'color'}
(the letter slots - moving these makes the theme palette-tier)
+
+ {#each palette_variants as letter (letter)}
+
+ {/each}
+
{#each palette_knobs as knob (knob.name)}
- editor.set_value(knob.name, value, editing_scheme)}
- onreset={() => editor.reset(knob.name)}
- />
+ {@render knob_control(knob, false)}
{/each}
@@ -195,6 +259,11 @@
.knobs {
row-gap: var(--space_lg);
}
+ .ramp_strips {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: var(--space_sm) var(--space_lg);
+ }
.rendered {
position: relative; /* for the .copy button */
}
diff --git a/src/routes/docs/borders/+page.svelte b/src/routes/docs/borders/+page.svelte
index 1db01c5b3..fea96a2eb 100644
--- a/src/routes/docs/borders/+page.svelte
+++ b/src/routes/docs/borders/+page.svelte
@@ -190,7 +190,7 @@ border-color: var(--shade_30);
Each border utility class has a corresponding outline variant using the same border variables
- (like outline_palette_b_50, outline_width_4, and
+ (like outline_b_50, outline_width_4, and
outline-style:solid), and there are also two special outline variables:
diff --git a/src/routes/docs/classes/+page.svelte b/src/routes/docs/classes/+page.svelte
index 585747784..179557c46 100644
--- a/src/routes/docs/classes/+page.svelte
+++ b/src/routes/docs/classes/+page.svelte
@@ -11,7 +11,7 @@
import {
space_variants,
palette_variants,
- role_variants,
+ intent_variants,
intensity_variants,
shade_variants,
text_variants,
@@ -784,19 +784,27 @@ vite_plugin_fuz_css({
{#each role_variants as role (role)}.{role}_{@render variant_range(intensity_variants)}{#each intent_variants as intent (intent)}.{intent}_{@render variant_range(intensity_variants)}{/each}
{#each role_variants as role (role)}.bg_{role}_{@render variant_range(intensity_variants)}{#each intent_variants as intent (intent)}.bg_{intent}_{@render variant_range(intensity_variants)}{/each}
+
+ A bare scale class applies its family's dominant use — .palette_a_50,
+ .positive_50, and .text_70 set the text color while
+ .shade_50 sets the background — and the bg_ prefix selects the
+ background twin (.bg_a_50, .bg_positive_50). In compound families
+ a letter alone implies the palette: .border_a_50 is the palette family,
+ .border_color_50 the alpha ramp.
+
Color and text classes (.palette_a_50, .text_70, etc.) also set
--text_color, so nested elements like <code> that use
@@ -870,7 +878,7 @@ vite_plugin_fuz_css({
.border_palette_{@render variant_range(palette_variants)}_{@render variant_range(
+ >.border_{@render variant_range(palette_variants)}_{@render variant_range(
intensity_variants,
)}
@@ -927,7 +935,7 @@ vite_plugin_fuz_css({
.outline_palette_{@render variant_range(palette_variants)}_{@render variant_range(
+ >.outline_{@render variant_range(palette_variants)}_{@render variant_range(
intensity_variants,
)}
@@ -976,7 +984,7 @@ vite_plugin_fuz_css({
.shadow_palette_{@render variant_range(palette_variants)}_{@render variant_range(
+ >.shadow_{@render variant_range(palette_variants)}_{@render variant_range(
intensity_variants,
)}
diff --git a/src/routes/docs/colors/+page.svelte b/src/routes/docs/colors/+page.svelte
index 00a2547d5..93be8bc30 100644
--- a/src/routes/docs/colors/+page.svelte
+++ b/src/routes/docs/colors/+page.svelte
@@ -10,7 +10,7 @@
import HueSwatch from './HueSwatch.svelte';
import ColorSwatch from './ColorSwatch.svelte';
- import {palette_variants} from '$lib/variable_data.ts';
+ import {palette_variants, palette_glosses, type PaletteVariant} from '$lib/variable_data.ts';
const LIBRARY_ITEM_NAME = 'colors';
@@ -21,20 +21,11 @@
// TODO button to add an inline hue input for runtime modification of the theme
- // letter glosses: color name plus the default role binding where one exists
- // Note: This array must stay in sync with palette_variants (a-j = 10 elements)
- const descriptions = [
- 'blue · default accent',
- 'green · default positive',
- 'red · default negative',
- 'purple',
- 'yellow',
- 'brown · default neutral',
- 'pink',
- 'orange · default caution',
- 'cyan · default info',
- 'teal',
- ];
+ // letter glosses: color name plus the default intent binding where one exists
+ const describe_letter = (letter: PaletteVariant): string => {
+ const gloss = palette_glosses[letter];
+ return gloss.binding ? `${gloss.color} · default ${gloss.binding}` : gloss.color;
+ };
@@ -51,11 +42,11 @@
Hues use letters so themes can reassign colors without breaking semantics -- "a" is blue by
- default but could be any color. Meaning attaches through the role knobs layered on top:
+ default but could be any color. Meaning attaches through the intent knobs layered on top:
--hue_accent (links, focus, selection, selected states -- what other systems call
the "primary" color, named here for what it communicates) defaults to
--hue_a, --hue_negative to --hue_c, and so on. Retarget
- a role to move just that meaning; rotate a letter to move the palette.
+ an intent to move just that meaning; rotate a letter to move the palette.
@@ -67,7 +58,8 @@
--hue_neutral + --neutral_chroma -- the temperature and strength
- of every surface, text, border, and shadow tint
+ of every surface, text, border, and shadow tint (the neutral intent; its scales are the
+ shade and text ramps)
--chroma_scale -- one multiplier from grayscale (0) through calm (1) to vivid
@@ -78,7 +70,7 @@
warm-light/cool-shadow character (default 0)
- role hues -- --hue_accent, --hue_positive,
+ intent hues -- --hue_accent, --hue_positive,
--hue_negative, --hue_caution, --hue_info -- each
deriving a full 13-stop scale (--accent_00 … --accent_100) with
matching text and background classes (.positive_50,
@@ -125,8 +117,8 @@
Hue variables are the same in both light and dark modes (non-adaptive).
- {#each palette_variants as letter, i (letter)}
-
+ {#each palette_variants as letter (letter)}
+
{/each}
diff --git a/src/routes/docs/shadows/+page.svelte b/src/routes/docs/shadows/+page.svelte
index e55975a65..6d38a2821 100644
--- a/src/routes/docs/shadows/+page.svelte
+++ b/src/routes/docs/shadows/+page.svelte
@@ -88,9 +88,9 @@
- Use shadow_palette_{'{hue}'}_{'{intensity}'} classes to apply colored shadows.
- The intensity controls the color's prominence -- 60 is a fine starting point for visible
- colored shadows.
+ Use shadow_{'{letter}'}_{'{intensity}'} classes to apply colored shadows. The
+ intensity controls the color's prominence -- 60 is a fine starting point for visible colored
+ shadows.
{#each palette_variants as color_variant (color_variant)}
@@ -108,7 +108,7 @@
{@const is_hue =
color_variant && !['umbra', 'highlight', 'glow', 'shroud'].includes(color_variant)}
{@const shadow_color_name = is_hue
- ? `shadow_palette_${color_variant}_${intensity}`
+ ? `shadow_${color_variant}_${intensity}`
: color_variant
? `shadow_color_${color_variant}`
: 'shadow_color_umbra'}
@@ -162,8 +162,8 @@
{/snippet}
{#snippet intensity_selector(color_variant: PaletteVariant)}
- shadow_palette_{color_variant}_{selected_intensity} shadow_{color_variant}_{selected_intensity}
diff --git a/src/test/fixtures/css_classes_fixture.json b/src/test/fixtures/css_classes_fixture.json
index e6cf00147..ecf038180 100644
--- a/src/test/fixtures/css_classes_fixture.json
+++ b/src/test/fixtures/css_classes_fixture.json
@@ -1647,394 +1647,394 @@
"border_color_100": {
"declaration": "border-color: var(--border_color_100); --border_color: var(--border_color_100);"
},
- "border_palette_a_00": {
+ "border_a_00": {
"declaration": "border-color: var(--palette_a_00); --border_color: var(--palette_a_00);"
},
- "border_palette_a_05": {
+ "border_a_05": {
"declaration": "border-color: var(--palette_a_05); --border_color: var(--palette_a_05);"
},
- "border_palette_a_10": {
+ "border_a_10": {
"declaration": "border-color: var(--palette_a_10); --border_color: var(--palette_a_10);"
},
- "border_palette_a_20": {
+ "border_a_20": {
"declaration": "border-color: var(--palette_a_20); --border_color: var(--palette_a_20);"
},
- "border_palette_a_30": {
+ "border_a_30": {
"declaration": "border-color: var(--palette_a_30); --border_color: var(--palette_a_30);"
},
- "border_palette_a_40": {
+ "border_a_40": {
"declaration": "border-color: var(--palette_a_40); --border_color: var(--palette_a_40);"
},
- "border_palette_a_50": {
+ "border_a_50": {
"declaration": "border-color: var(--palette_a_50); --border_color: var(--palette_a_50);"
},
- "border_palette_a_60": {
+ "border_a_60": {
"declaration": "border-color: var(--palette_a_60); --border_color: var(--palette_a_60);"
},
- "border_palette_a_70": {
+ "border_a_70": {
"declaration": "border-color: var(--palette_a_70); --border_color: var(--palette_a_70);"
},
- "border_palette_a_80": {
+ "border_a_80": {
"declaration": "border-color: var(--palette_a_80); --border_color: var(--palette_a_80);"
},
- "border_palette_a_90": {
+ "border_a_90": {
"declaration": "border-color: var(--palette_a_90); --border_color: var(--palette_a_90);"
},
- "border_palette_a_95": {
+ "border_a_95": {
"declaration": "border-color: var(--palette_a_95); --border_color: var(--palette_a_95);"
},
- "border_palette_a_100": {
+ "border_a_100": {
"declaration": "border-color: var(--palette_a_100); --border_color: var(--palette_a_100);"
},
- "border_palette_b_00": {
+ "border_b_00": {
"declaration": "border-color: var(--palette_b_00); --border_color: var(--palette_b_00);"
},
- "border_palette_b_05": {
+ "border_b_05": {
"declaration": "border-color: var(--palette_b_05); --border_color: var(--palette_b_05);"
},
- "border_palette_b_10": {
+ "border_b_10": {
"declaration": "border-color: var(--palette_b_10); --border_color: var(--palette_b_10);"
},
- "border_palette_b_20": {
+ "border_b_20": {
"declaration": "border-color: var(--palette_b_20); --border_color: var(--palette_b_20);"
},
- "border_palette_b_30": {
+ "border_b_30": {
"declaration": "border-color: var(--palette_b_30); --border_color: var(--palette_b_30);"
},
- "border_palette_b_40": {
+ "border_b_40": {
"declaration": "border-color: var(--palette_b_40); --border_color: var(--palette_b_40);"
},
- "border_palette_b_50": {
+ "border_b_50": {
"declaration": "border-color: var(--palette_b_50); --border_color: var(--palette_b_50);"
},
- "border_palette_b_60": {
+ "border_b_60": {
"declaration": "border-color: var(--palette_b_60); --border_color: var(--palette_b_60);"
},
- "border_palette_b_70": {
+ "border_b_70": {
"declaration": "border-color: var(--palette_b_70); --border_color: var(--palette_b_70);"
},
- "border_palette_b_80": {
+ "border_b_80": {
"declaration": "border-color: var(--palette_b_80); --border_color: var(--palette_b_80);"
},
- "border_palette_b_90": {
+ "border_b_90": {
"declaration": "border-color: var(--palette_b_90); --border_color: var(--palette_b_90);"
},
- "border_palette_b_95": {
+ "border_b_95": {
"declaration": "border-color: var(--palette_b_95); --border_color: var(--palette_b_95);"
},
- "border_palette_b_100": {
+ "border_b_100": {
"declaration": "border-color: var(--palette_b_100); --border_color: var(--palette_b_100);"
},
- "border_palette_c_00": {
+ "border_c_00": {
"declaration": "border-color: var(--palette_c_00); --border_color: var(--palette_c_00);"
},
- "border_palette_c_05": {
+ "border_c_05": {
"declaration": "border-color: var(--palette_c_05); --border_color: var(--palette_c_05);"
},
- "border_palette_c_10": {
+ "border_c_10": {
"declaration": "border-color: var(--palette_c_10); --border_color: var(--palette_c_10);"
},
- "border_palette_c_20": {
+ "border_c_20": {
"declaration": "border-color: var(--palette_c_20); --border_color: var(--palette_c_20);"
},
- "border_palette_c_30": {
+ "border_c_30": {
"declaration": "border-color: var(--palette_c_30); --border_color: var(--palette_c_30);"
},
- "border_palette_c_40": {
+ "border_c_40": {
"declaration": "border-color: var(--palette_c_40); --border_color: var(--palette_c_40);"
},
- "border_palette_c_50": {
+ "border_c_50": {
"declaration": "border-color: var(--palette_c_50); --border_color: var(--palette_c_50);"
},
- "border_palette_c_60": {
+ "border_c_60": {
"declaration": "border-color: var(--palette_c_60); --border_color: var(--palette_c_60);"
},
- "border_palette_c_70": {
+ "border_c_70": {
"declaration": "border-color: var(--palette_c_70); --border_color: var(--palette_c_70);"
},
- "border_palette_c_80": {
+ "border_c_80": {
"declaration": "border-color: var(--palette_c_80); --border_color: var(--palette_c_80);"
},
- "border_palette_c_90": {
+ "border_c_90": {
"declaration": "border-color: var(--palette_c_90); --border_color: var(--palette_c_90);"
},
- "border_palette_c_95": {
+ "border_c_95": {
"declaration": "border-color: var(--palette_c_95); --border_color: var(--palette_c_95);"
},
- "border_palette_c_100": {
+ "border_c_100": {
"declaration": "border-color: var(--palette_c_100); --border_color: var(--palette_c_100);"
},
- "border_palette_d_00": {
+ "border_d_00": {
"declaration": "border-color: var(--palette_d_00); --border_color: var(--palette_d_00);"
},
- "border_palette_d_05": {
+ "border_d_05": {
"declaration": "border-color: var(--palette_d_05); --border_color: var(--palette_d_05);"
},
- "border_palette_d_10": {
+ "border_d_10": {
"declaration": "border-color: var(--palette_d_10); --border_color: var(--palette_d_10);"
},
- "border_palette_d_20": {
+ "border_d_20": {
"declaration": "border-color: var(--palette_d_20); --border_color: var(--palette_d_20);"
},
- "border_palette_d_30": {
+ "border_d_30": {
"declaration": "border-color: var(--palette_d_30); --border_color: var(--palette_d_30);"
},
- "border_palette_d_40": {
+ "border_d_40": {
"declaration": "border-color: var(--palette_d_40); --border_color: var(--palette_d_40);"
},
- "border_palette_d_50": {
+ "border_d_50": {
"declaration": "border-color: var(--palette_d_50); --border_color: var(--palette_d_50);"
},
- "border_palette_d_60": {
+ "border_d_60": {
"declaration": "border-color: var(--palette_d_60); --border_color: var(--palette_d_60);"
},
- "border_palette_d_70": {
+ "border_d_70": {
"declaration": "border-color: var(--palette_d_70); --border_color: var(--palette_d_70);"
},
- "border_palette_d_80": {
+ "border_d_80": {
"declaration": "border-color: var(--palette_d_80); --border_color: var(--palette_d_80);"
},
- "border_palette_d_90": {
+ "border_d_90": {
"declaration": "border-color: var(--palette_d_90); --border_color: var(--palette_d_90);"
},
- "border_palette_d_95": {
+ "border_d_95": {
"declaration": "border-color: var(--palette_d_95); --border_color: var(--palette_d_95);"
},
- "border_palette_d_100": {
+ "border_d_100": {
"declaration": "border-color: var(--palette_d_100); --border_color: var(--palette_d_100);"
},
- "border_palette_e_00": {
+ "border_e_00": {
"declaration": "border-color: var(--palette_e_00); --border_color: var(--palette_e_00);"
},
- "border_palette_e_05": {
+ "border_e_05": {
"declaration": "border-color: var(--palette_e_05); --border_color: var(--palette_e_05);"
},
- "border_palette_e_10": {
+ "border_e_10": {
"declaration": "border-color: var(--palette_e_10); --border_color: var(--palette_e_10);"
},
- "border_palette_e_20": {
+ "border_e_20": {
"declaration": "border-color: var(--palette_e_20); --border_color: var(--palette_e_20);"
},
- "border_palette_e_30": {
+ "border_e_30": {
"declaration": "border-color: var(--palette_e_30); --border_color: var(--palette_e_30);"
},
- "border_palette_e_40": {
+ "border_e_40": {
"declaration": "border-color: var(--palette_e_40); --border_color: var(--palette_e_40);"
},
- "border_palette_e_50": {
+ "border_e_50": {
"declaration": "border-color: var(--palette_e_50); --border_color: var(--palette_e_50);"
},
- "border_palette_e_60": {
+ "border_e_60": {
"declaration": "border-color: var(--palette_e_60); --border_color: var(--palette_e_60);"
},
- "border_palette_e_70": {
+ "border_e_70": {
"declaration": "border-color: var(--palette_e_70); --border_color: var(--palette_e_70);"
},
- "border_palette_e_80": {
+ "border_e_80": {
"declaration": "border-color: var(--palette_e_80); --border_color: var(--palette_e_80);"
},
- "border_palette_e_90": {
+ "border_e_90": {
"declaration": "border-color: var(--palette_e_90); --border_color: var(--palette_e_90);"
},
- "border_palette_e_95": {
+ "border_e_95": {
"declaration": "border-color: var(--palette_e_95); --border_color: var(--palette_e_95);"
},
- "border_palette_e_100": {
+ "border_e_100": {
"declaration": "border-color: var(--palette_e_100); --border_color: var(--palette_e_100);"
},
- "border_palette_f_00": {
+ "border_f_00": {
"declaration": "border-color: var(--palette_f_00); --border_color: var(--palette_f_00);"
},
- "border_palette_f_05": {
+ "border_f_05": {
"declaration": "border-color: var(--palette_f_05); --border_color: var(--palette_f_05);"
},
- "border_palette_f_10": {
+ "border_f_10": {
"declaration": "border-color: var(--palette_f_10); --border_color: var(--palette_f_10);"
},
- "border_palette_f_20": {
+ "border_f_20": {
"declaration": "border-color: var(--palette_f_20); --border_color: var(--palette_f_20);"
},
- "border_palette_f_30": {
+ "border_f_30": {
"declaration": "border-color: var(--palette_f_30); --border_color: var(--palette_f_30);"
},
- "border_palette_f_40": {
+ "border_f_40": {
"declaration": "border-color: var(--palette_f_40); --border_color: var(--palette_f_40);"
},
- "border_palette_f_50": {
+ "border_f_50": {
"declaration": "border-color: var(--palette_f_50); --border_color: var(--palette_f_50);"
},
- "border_palette_f_60": {
+ "border_f_60": {
"declaration": "border-color: var(--palette_f_60); --border_color: var(--palette_f_60);"
},
- "border_palette_f_70": {
+ "border_f_70": {
"declaration": "border-color: var(--palette_f_70); --border_color: var(--palette_f_70);"
},
- "border_palette_f_80": {
+ "border_f_80": {
"declaration": "border-color: var(--palette_f_80); --border_color: var(--palette_f_80);"
},
- "border_palette_f_90": {
+ "border_f_90": {
"declaration": "border-color: var(--palette_f_90); --border_color: var(--palette_f_90);"
},
- "border_palette_f_95": {
+ "border_f_95": {
"declaration": "border-color: var(--palette_f_95); --border_color: var(--palette_f_95);"
},
- "border_palette_f_100": {
+ "border_f_100": {
"declaration": "border-color: var(--palette_f_100); --border_color: var(--palette_f_100);"
},
- "border_palette_g_00": {
+ "border_g_00": {
"declaration": "border-color: var(--palette_g_00); --border_color: var(--palette_g_00);"
},
- "border_palette_g_05": {
+ "border_g_05": {
"declaration": "border-color: var(--palette_g_05); --border_color: var(--palette_g_05);"
},
- "border_palette_g_10": {
+ "border_g_10": {
"declaration": "border-color: var(--palette_g_10); --border_color: var(--palette_g_10);"
},
- "border_palette_g_20": {
+ "border_g_20": {
"declaration": "border-color: var(--palette_g_20); --border_color: var(--palette_g_20);"
},
- "border_palette_g_30": {
+ "border_g_30": {
"declaration": "border-color: var(--palette_g_30); --border_color: var(--palette_g_30);"
},
- "border_palette_g_40": {
+ "border_g_40": {
"declaration": "border-color: var(--palette_g_40); --border_color: var(--palette_g_40);"
},
- "border_palette_g_50": {
+ "border_g_50": {
"declaration": "border-color: var(--palette_g_50); --border_color: var(--palette_g_50);"
},
- "border_palette_g_60": {
+ "border_g_60": {
"declaration": "border-color: var(--palette_g_60); --border_color: var(--palette_g_60);"
},
- "border_palette_g_70": {
+ "border_g_70": {
"declaration": "border-color: var(--palette_g_70); --border_color: var(--palette_g_70);"
},
- "border_palette_g_80": {
+ "border_g_80": {
"declaration": "border-color: var(--palette_g_80); --border_color: var(--palette_g_80);"
},
- "border_palette_g_90": {
+ "border_g_90": {
"declaration": "border-color: var(--palette_g_90); --border_color: var(--palette_g_90);"
},
- "border_palette_g_95": {
+ "border_g_95": {
"declaration": "border-color: var(--palette_g_95); --border_color: var(--palette_g_95);"
},
- "border_palette_g_100": {
+ "border_g_100": {
"declaration": "border-color: var(--palette_g_100); --border_color: var(--palette_g_100);"
},
- "border_palette_h_00": {
+ "border_h_00": {
"declaration": "border-color: var(--palette_h_00); --border_color: var(--palette_h_00);"
},
- "border_palette_h_05": {
+ "border_h_05": {
"declaration": "border-color: var(--palette_h_05); --border_color: var(--palette_h_05);"
},
- "border_palette_h_10": {
+ "border_h_10": {
"declaration": "border-color: var(--palette_h_10); --border_color: var(--palette_h_10);"
},
- "border_palette_h_20": {
+ "border_h_20": {
"declaration": "border-color: var(--palette_h_20); --border_color: var(--palette_h_20);"
},
- "border_palette_h_30": {
+ "border_h_30": {
"declaration": "border-color: var(--palette_h_30); --border_color: var(--palette_h_30);"
},
- "border_palette_h_40": {
+ "border_h_40": {
"declaration": "border-color: var(--palette_h_40); --border_color: var(--palette_h_40);"
},
- "border_palette_h_50": {
+ "border_h_50": {
"declaration": "border-color: var(--palette_h_50); --border_color: var(--palette_h_50);"
},
- "border_palette_h_60": {
+ "border_h_60": {
"declaration": "border-color: var(--palette_h_60); --border_color: var(--palette_h_60);"
},
- "border_palette_h_70": {
+ "border_h_70": {
"declaration": "border-color: var(--palette_h_70); --border_color: var(--palette_h_70);"
},
- "border_palette_h_80": {
+ "border_h_80": {
"declaration": "border-color: var(--palette_h_80); --border_color: var(--palette_h_80);"
},
- "border_palette_h_90": {
+ "border_h_90": {
"declaration": "border-color: var(--palette_h_90); --border_color: var(--palette_h_90);"
},
- "border_palette_h_95": {
+ "border_h_95": {
"declaration": "border-color: var(--palette_h_95); --border_color: var(--palette_h_95);"
},
- "border_palette_h_100": {
+ "border_h_100": {
"declaration": "border-color: var(--palette_h_100); --border_color: var(--palette_h_100);"
},
- "border_palette_i_00": {
+ "border_i_00": {
"declaration": "border-color: var(--palette_i_00); --border_color: var(--palette_i_00);"
},
- "border_palette_i_05": {
+ "border_i_05": {
"declaration": "border-color: var(--palette_i_05); --border_color: var(--palette_i_05);"
},
- "border_palette_i_10": {
+ "border_i_10": {
"declaration": "border-color: var(--palette_i_10); --border_color: var(--palette_i_10);"
},
- "border_palette_i_20": {
+ "border_i_20": {
"declaration": "border-color: var(--palette_i_20); --border_color: var(--palette_i_20);"
},
- "border_palette_i_30": {
+ "border_i_30": {
"declaration": "border-color: var(--palette_i_30); --border_color: var(--palette_i_30);"
},
- "border_palette_i_40": {
+ "border_i_40": {
"declaration": "border-color: var(--palette_i_40); --border_color: var(--palette_i_40);"
},
- "border_palette_i_50": {
+ "border_i_50": {
"declaration": "border-color: var(--palette_i_50); --border_color: var(--palette_i_50);"
},
- "border_palette_i_60": {
+ "border_i_60": {
"declaration": "border-color: var(--palette_i_60); --border_color: var(--palette_i_60);"
},
- "border_palette_i_70": {
+ "border_i_70": {
"declaration": "border-color: var(--palette_i_70); --border_color: var(--palette_i_70);"
},
- "border_palette_i_80": {
+ "border_i_80": {
"declaration": "border-color: var(--palette_i_80); --border_color: var(--palette_i_80);"
},
- "border_palette_i_90": {
+ "border_i_90": {
"declaration": "border-color: var(--palette_i_90); --border_color: var(--palette_i_90);"
},
- "border_palette_i_95": {
+ "border_i_95": {
"declaration": "border-color: var(--palette_i_95); --border_color: var(--palette_i_95);"
},
- "border_palette_i_100": {
+ "border_i_100": {
"declaration": "border-color: var(--palette_i_100); --border_color: var(--palette_i_100);"
},
- "border_palette_j_00": {
+ "border_j_00": {
"declaration": "border-color: var(--palette_j_00); --border_color: var(--palette_j_00);"
},
- "border_palette_j_05": {
+ "border_j_05": {
"declaration": "border-color: var(--palette_j_05); --border_color: var(--palette_j_05);"
},
- "border_palette_j_10": {
+ "border_j_10": {
"declaration": "border-color: var(--palette_j_10); --border_color: var(--palette_j_10);"
},
- "border_palette_j_20": {
+ "border_j_20": {
"declaration": "border-color: var(--palette_j_20); --border_color: var(--palette_j_20);"
},
- "border_palette_j_30": {
+ "border_j_30": {
"declaration": "border-color: var(--palette_j_30); --border_color: var(--palette_j_30);"
},
- "border_palette_j_40": {
+ "border_j_40": {
"declaration": "border-color: var(--palette_j_40); --border_color: var(--palette_j_40);"
},
- "border_palette_j_50": {
+ "border_j_50": {
"declaration": "border-color: var(--palette_j_50); --border_color: var(--palette_j_50);"
},
- "border_palette_j_60": {
+ "border_j_60": {
"declaration": "border-color: var(--palette_j_60); --border_color: var(--palette_j_60);"
},
- "border_palette_j_70": {
+ "border_j_70": {
"declaration": "border-color: var(--palette_j_70); --border_color: var(--palette_j_70);"
},
- "border_palette_j_80": {
+ "border_j_80": {
"declaration": "border-color: var(--palette_j_80); --border_color: var(--palette_j_80);"
},
- "border_palette_j_90": {
+ "border_j_90": {
"declaration": "border-color: var(--palette_j_90); --border_color: var(--palette_j_90);"
},
- "border_palette_j_95": {
+ "border_j_95": {
"declaration": "border-color: var(--palette_j_95); --border_color: var(--palette_j_95);"
},
- "border_palette_j_100": {
+ "border_j_100": {
"declaration": "border-color: var(--palette_j_100); --border_color: var(--palette_j_100);"
},
"outline_color_00": {
@@ -2076,394 +2076,394 @@
"outline_color_100": {
"declaration": "outline-color: var(--shade_100);"
},
- "outline_palette_a_00": {
+ "outline_a_00": {
"declaration": "outline-color: var(--palette_a_00); --outline_color: var(--palette_a_00);"
},
- "outline_palette_a_05": {
+ "outline_a_05": {
"declaration": "outline-color: var(--palette_a_05); --outline_color: var(--palette_a_05);"
},
- "outline_palette_a_10": {
+ "outline_a_10": {
"declaration": "outline-color: var(--palette_a_10); --outline_color: var(--palette_a_10);"
},
- "outline_palette_a_20": {
+ "outline_a_20": {
"declaration": "outline-color: var(--palette_a_20); --outline_color: var(--palette_a_20);"
},
- "outline_palette_a_30": {
+ "outline_a_30": {
"declaration": "outline-color: var(--palette_a_30); --outline_color: var(--palette_a_30);"
},
- "outline_palette_a_40": {
+ "outline_a_40": {
"declaration": "outline-color: var(--palette_a_40); --outline_color: var(--palette_a_40);"
},
- "outline_palette_a_50": {
+ "outline_a_50": {
"declaration": "outline-color: var(--palette_a_50); --outline_color: var(--palette_a_50);"
},
- "outline_palette_a_60": {
+ "outline_a_60": {
"declaration": "outline-color: var(--palette_a_60); --outline_color: var(--palette_a_60);"
},
- "outline_palette_a_70": {
+ "outline_a_70": {
"declaration": "outline-color: var(--palette_a_70); --outline_color: var(--palette_a_70);"
},
- "outline_palette_a_80": {
+ "outline_a_80": {
"declaration": "outline-color: var(--palette_a_80); --outline_color: var(--palette_a_80);"
},
- "outline_palette_a_90": {
+ "outline_a_90": {
"declaration": "outline-color: var(--palette_a_90); --outline_color: var(--palette_a_90);"
},
- "outline_palette_a_95": {
+ "outline_a_95": {
"declaration": "outline-color: var(--palette_a_95); --outline_color: var(--palette_a_95);"
},
- "outline_palette_a_100": {
+ "outline_a_100": {
"declaration": "outline-color: var(--palette_a_100); --outline_color: var(--palette_a_100);"
},
- "outline_palette_b_00": {
+ "outline_b_00": {
"declaration": "outline-color: var(--palette_b_00); --outline_color: var(--palette_b_00);"
},
- "outline_palette_b_05": {
+ "outline_b_05": {
"declaration": "outline-color: var(--palette_b_05); --outline_color: var(--palette_b_05);"
},
- "outline_palette_b_10": {
+ "outline_b_10": {
"declaration": "outline-color: var(--palette_b_10); --outline_color: var(--palette_b_10);"
},
- "outline_palette_b_20": {
+ "outline_b_20": {
"declaration": "outline-color: var(--palette_b_20); --outline_color: var(--palette_b_20);"
},
- "outline_palette_b_30": {
+ "outline_b_30": {
"declaration": "outline-color: var(--palette_b_30); --outline_color: var(--palette_b_30);"
},
- "outline_palette_b_40": {
+ "outline_b_40": {
"declaration": "outline-color: var(--palette_b_40); --outline_color: var(--palette_b_40);"
},
- "outline_palette_b_50": {
+ "outline_b_50": {
"declaration": "outline-color: var(--palette_b_50); --outline_color: var(--palette_b_50);"
},
- "outline_palette_b_60": {
+ "outline_b_60": {
"declaration": "outline-color: var(--palette_b_60); --outline_color: var(--palette_b_60);"
},
- "outline_palette_b_70": {
+ "outline_b_70": {
"declaration": "outline-color: var(--palette_b_70); --outline_color: var(--palette_b_70);"
},
- "outline_palette_b_80": {
+ "outline_b_80": {
"declaration": "outline-color: var(--palette_b_80); --outline_color: var(--palette_b_80);"
},
- "outline_palette_b_90": {
+ "outline_b_90": {
"declaration": "outline-color: var(--palette_b_90); --outline_color: var(--palette_b_90);"
},
- "outline_palette_b_95": {
+ "outline_b_95": {
"declaration": "outline-color: var(--palette_b_95); --outline_color: var(--palette_b_95);"
},
- "outline_palette_b_100": {
+ "outline_b_100": {
"declaration": "outline-color: var(--palette_b_100); --outline_color: var(--palette_b_100);"
},
- "outline_palette_c_00": {
+ "outline_c_00": {
"declaration": "outline-color: var(--palette_c_00); --outline_color: var(--palette_c_00);"
},
- "outline_palette_c_05": {
+ "outline_c_05": {
"declaration": "outline-color: var(--palette_c_05); --outline_color: var(--palette_c_05);"
},
- "outline_palette_c_10": {
+ "outline_c_10": {
"declaration": "outline-color: var(--palette_c_10); --outline_color: var(--palette_c_10);"
},
- "outline_palette_c_20": {
+ "outline_c_20": {
"declaration": "outline-color: var(--palette_c_20); --outline_color: var(--palette_c_20);"
},
- "outline_palette_c_30": {
+ "outline_c_30": {
"declaration": "outline-color: var(--palette_c_30); --outline_color: var(--palette_c_30);"
},
- "outline_palette_c_40": {
+ "outline_c_40": {
"declaration": "outline-color: var(--palette_c_40); --outline_color: var(--palette_c_40);"
},
- "outline_palette_c_50": {
+ "outline_c_50": {
"declaration": "outline-color: var(--palette_c_50); --outline_color: var(--palette_c_50);"
},
- "outline_palette_c_60": {
+ "outline_c_60": {
"declaration": "outline-color: var(--palette_c_60); --outline_color: var(--palette_c_60);"
},
- "outline_palette_c_70": {
+ "outline_c_70": {
"declaration": "outline-color: var(--palette_c_70); --outline_color: var(--palette_c_70);"
},
- "outline_palette_c_80": {
+ "outline_c_80": {
"declaration": "outline-color: var(--palette_c_80); --outline_color: var(--palette_c_80);"
},
- "outline_palette_c_90": {
+ "outline_c_90": {
"declaration": "outline-color: var(--palette_c_90); --outline_color: var(--palette_c_90);"
},
- "outline_palette_c_95": {
+ "outline_c_95": {
"declaration": "outline-color: var(--palette_c_95); --outline_color: var(--palette_c_95);"
},
- "outline_palette_c_100": {
+ "outline_c_100": {
"declaration": "outline-color: var(--palette_c_100); --outline_color: var(--palette_c_100);"
},
- "outline_palette_d_00": {
+ "outline_d_00": {
"declaration": "outline-color: var(--palette_d_00); --outline_color: var(--palette_d_00);"
},
- "outline_palette_d_05": {
+ "outline_d_05": {
"declaration": "outline-color: var(--palette_d_05); --outline_color: var(--palette_d_05);"
},
- "outline_palette_d_10": {
+ "outline_d_10": {
"declaration": "outline-color: var(--palette_d_10); --outline_color: var(--palette_d_10);"
},
- "outline_palette_d_20": {
+ "outline_d_20": {
"declaration": "outline-color: var(--palette_d_20); --outline_color: var(--palette_d_20);"
},
- "outline_palette_d_30": {
+ "outline_d_30": {
"declaration": "outline-color: var(--palette_d_30); --outline_color: var(--palette_d_30);"
},
- "outline_palette_d_40": {
+ "outline_d_40": {
"declaration": "outline-color: var(--palette_d_40); --outline_color: var(--palette_d_40);"
},
- "outline_palette_d_50": {
+ "outline_d_50": {
"declaration": "outline-color: var(--palette_d_50); --outline_color: var(--palette_d_50);"
},
- "outline_palette_d_60": {
+ "outline_d_60": {
"declaration": "outline-color: var(--palette_d_60); --outline_color: var(--palette_d_60);"
},
- "outline_palette_d_70": {
+ "outline_d_70": {
"declaration": "outline-color: var(--palette_d_70); --outline_color: var(--palette_d_70);"
},
- "outline_palette_d_80": {
+ "outline_d_80": {
"declaration": "outline-color: var(--palette_d_80); --outline_color: var(--palette_d_80);"
},
- "outline_palette_d_90": {
+ "outline_d_90": {
"declaration": "outline-color: var(--palette_d_90); --outline_color: var(--palette_d_90);"
},
- "outline_palette_d_95": {
+ "outline_d_95": {
"declaration": "outline-color: var(--palette_d_95); --outline_color: var(--palette_d_95);"
},
- "outline_palette_d_100": {
+ "outline_d_100": {
"declaration": "outline-color: var(--palette_d_100); --outline_color: var(--palette_d_100);"
},
- "outline_palette_e_00": {
+ "outline_e_00": {
"declaration": "outline-color: var(--palette_e_00); --outline_color: var(--palette_e_00);"
},
- "outline_palette_e_05": {
+ "outline_e_05": {
"declaration": "outline-color: var(--palette_e_05); --outline_color: var(--palette_e_05);"
},
- "outline_palette_e_10": {
+ "outline_e_10": {
"declaration": "outline-color: var(--palette_e_10); --outline_color: var(--palette_e_10);"
},
- "outline_palette_e_20": {
+ "outline_e_20": {
"declaration": "outline-color: var(--palette_e_20); --outline_color: var(--palette_e_20);"
},
- "outline_palette_e_30": {
+ "outline_e_30": {
"declaration": "outline-color: var(--palette_e_30); --outline_color: var(--palette_e_30);"
},
- "outline_palette_e_40": {
+ "outline_e_40": {
"declaration": "outline-color: var(--palette_e_40); --outline_color: var(--palette_e_40);"
},
- "outline_palette_e_50": {
+ "outline_e_50": {
"declaration": "outline-color: var(--palette_e_50); --outline_color: var(--palette_e_50);"
},
- "outline_palette_e_60": {
+ "outline_e_60": {
"declaration": "outline-color: var(--palette_e_60); --outline_color: var(--palette_e_60);"
},
- "outline_palette_e_70": {
+ "outline_e_70": {
"declaration": "outline-color: var(--palette_e_70); --outline_color: var(--palette_e_70);"
},
- "outline_palette_e_80": {
+ "outline_e_80": {
"declaration": "outline-color: var(--palette_e_80); --outline_color: var(--palette_e_80);"
},
- "outline_palette_e_90": {
+ "outline_e_90": {
"declaration": "outline-color: var(--palette_e_90); --outline_color: var(--palette_e_90);"
},
- "outline_palette_e_95": {
+ "outline_e_95": {
"declaration": "outline-color: var(--palette_e_95); --outline_color: var(--palette_e_95);"
},
- "outline_palette_e_100": {
+ "outline_e_100": {
"declaration": "outline-color: var(--palette_e_100); --outline_color: var(--palette_e_100);"
},
- "outline_palette_f_00": {
+ "outline_f_00": {
"declaration": "outline-color: var(--palette_f_00); --outline_color: var(--palette_f_00);"
},
- "outline_palette_f_05": {
+ "outline_f_05": {
"declaration": "outline-color: var(--palette_f_05); --outline_color: var(--palette_f_05);"
},
- "outline_palette_f_10": {
+ "outline_f_10": {
"declaration": "outline-color: var(--palette_f_10); --outline_color: var(--palette_f_10);"
},
- "outline_palette_f_20": {
+ "outline_f_20": {
"declaration": "outline-color: var(--palette_f_20); --outline_color: var(--palette_f_20);"
},
- "outline_palette_f_30": {
+ "outline_f_30": {
"declaration": "outline-color: var(--palette_f_30); --outline_color: var(--palette_f_30);"
},
- "outline_palette_f_40": {
+ "outline_f_40": {
"declaration": "outline-color: var(--palette_f_40); --outline_color: var(--palette_f_40);"
},
- "outline_palette_f_50": {
+ "outline_f_50": {
"declaration": "outline-color: var(--palette_f_50); --outline_color: var(--palette_f_50);"
},
- "outline_palette_f_60": {
+ "outline_f_60": {
"declaration": "outline-color: var(--palette_f_60); --outline_color: var(--palette_f_60);"
},
- "outline_palette_f_70": {
+ "outline_f_70": {
"declaration": "outline-color: var(--palette_f_70); --outline_color: var(--palette_f_70);"
},
- "outline_palette_f_80": {
+ "outline_f_80": {
"declaration": "outline-color: var(--palette_f_80); --outline_color: var(--palette_f_80);"
},
- "outline_palette_f_90": {
+ "outline_f_90": {
"declaration": "outline-color: var(--palette_f_90); --outline_color: var(--palette_f_90);"
},
- "outline_palette_f_95": {
+ "outline_f_95": {
"declaration": "outline-color: var(--palette_f_95); --outline_color: var(--palette_f_95);"
},
- "outline_palette_f_100": {
+ "outline_f_100": {
"declaration": "outline-color: var(--palette_f_100); --outline_color: var(--palette_f_100);"
},
- "outline_palette_g_00": {
+ "outline_g_00": {
"declaration": "outline-color: var(--palette_g_00); --outline_color: var(--palette_g_00);"
},
- "outline_palette_g_05": {
+ "outline_g_05": {
"declaration": "outline-color: var(--palette_g_05); --outline_color: var(--palette_g_05);"
},
- "outline_palette_g_10": {
+ "outline_g_10": {
"declaration": "outline-color: var(--palette_g_10); --outline_color: var(--palette_g_10);"
},
- "outline_palette_g_20": {
+ "outline_g_20": {
"declaration": "outline-color: var(--palette_g_20); --outline_color: var(--palette_g_20);"
},
- "outline_palette_g_30": {
+ "outline_g_30": {
"declaration": "outline-color: var(--palette_g_30); --outline_color: var(--palette_g_30);"
},
- "outline_palette_g_40": {
+ "outline_g_40": {
"declaration": "outline-color: var(--palette_g_40); --outline_color: var(--palette_g_40);"
},
- "outline_palette_g_50": {
+ "outline_g_50": {
"declaration": "outline-color: var(--palette_g_50); --outline_color: var(--palette_g_50);"
},
- "outline_palette_g_60": {
+ "outline_g_60": {
"declaration": "outline-color: var(--palette_g_60); --outline_color: var(--palette_g_60);"
},
- "outline_palette_g_70": {
+ "outline_g_70": {
"declaration": "outline-color: var(--palette_g_70); --outline_color: var(--palette_g_70);"
},
- "outline_palette_g_80": {
+ "outline_g_80": {
"declaration": "outline-color: var(--palette_g_80); --outline_color: var(--palette_g_80);"
},
- "outline_palette_g_90": {
+ "outline_g_90": {
"declaration": "outline-color: var(--palette_g_90); --outline_color: var(--palette_g_90);"
},
- "outline_palette_g_95": {
+ "outline_g_95": {
"declaration": "outline-color: var(--palette_g_95); --outline_color: var(--palette_g_95);"
},
- "outline_palette_g_100": {
+ "outline_g_100": {
"declaration": "outline-color: var(--palette_g_100); --outline_color: var(--palette_g_100);"
},
- "outline_palette_h_00": {
+ "outline_h_00": {
"declaration": "outline-color: var(--palette_h_00); --outline_color: var(--palette_h_00);"
},
- "outline_palette_h_05": {
+ "outline_h_05": {
"declaration": "outline-color: var(--palette_h_05); --outline_color: var(--palette_h_05);"
},
- "outline_palette_h_10": {
+ "outline_h_10": {
"declaration": "outline-color: var(--palette_h_10); --outline_color: var(--palette_h_10);"
},
- "outline_palette_h_20": {
+ "outline_h_20": {
"declaration": "outline-color: var(--palette_h_20); --outline_color: var(--palette_h_20);"
},
- "outline_palette_h_30": {
+ "outline_h_30": {
"declaration": "outline-color: var(--palette_h_30); --outline_color: var(--palette_h_30);"
},
- "outline_palette_h_40": {
+ "outline_h_40": {
"declaration": "outline-color: var(--palette_h_40); --outline_color: var(--palette_h_40);"
},
- "outline_palette_h_50": {
+ "outline_h_50": {
"declaration": "outline-color: var(--palette_h_50); --outline_color: var(--palette_h_50);"
},
- "outline_palette_h_60": {
+ "outline_h_60": {
"declaration": "outline-color: var(--palette_h_60); --outline_color: var(--palette_h_60);"
},
- "outline_palette_h_70": {
+ "outline_h_70": {
"declaration": "outline-color: var(--palette_h_70); --outline_color: var(--palette_h_70);"
},
- "outline_palette_h_80": {
+ "outline_h_80": {
"declaration": "outline-color: var(--palette_h_80); --outline_color: var(--palette_h_80);"
},
- "outline_palette_h_90": {
+ "outline_h_90": {
"declaration": "outline-color: var(--palette_h_90); --outline_color: var(--palette_h_90);"
},
- "outline_palette_h_95": {
+ "outline_h_95": {
"declaration": "outline-color: var(--palette_h_95); --outline_color: var(--palette_h_95);"
},
- "outline_palette_h_100": {
+ "outline_h_100": {
"declaration": "outline-color: var(--palette_h_100); --outline_color: var(--palette_h_100);"
},
- "outline_palette_i_00": {
+ "outline_i_00": {
"declaration": "outline-color: var(--palette_i_00); --outline_color: var(--palette_i_00);"
},
- "outline_palette_i_05": {
+ "outline_i_05": {
"declaration": "outline-color: var(--palette_i_05); --outline_color: var(--palette_i_05);"
},
- "outline_palette_i_10": {
+ "outline_i_10": {
"declaration": "outline-color: var(--palette_i_10); --outline_color: var(--palette_i_10);"
},
- "outline_palette_i_20": {
+ "outline_i_20": {
"declaration": "outline-color: var(--palette_i_20); --outline_color: var(--palette_i_20);"
},
- "outline_palette_i_30": {
+ "outline_i_30": {
"declaration": "outline-color: var(--palette_i_30); --outline_color: var(--palette_i_30);"
},
- "outline_palette_i_40": {
+ "outline_i_40": {
"declaration": "outline-color: var(--palette_i_40); --outline_color: var(--palette_i_40);"
},
- "outline_palette_i_50": {
+ "outline_i_50": {
"declaration": "outline-color: var(--palette_i_50); --outline_color: var(--palette_i_50);"
},
- "outline_palette_i_60": {
+ "outline_i_60": {
"declaration": "outline-color: var(--palette_i_60); --outline_color: var(--palette_i_60);"
},
- "outline_palette_i_70": {
+ "outline_i_70": {
"declaration": "outline-color: var(--palette_i_70); --outline_color: var(--palette_i_70);"
},
- "outline_palette_i_80": {
+ "outline_i_80": {
"declaration": "outline-color: var(--palette_i_80); --outline_color: var(--palette_i_80);"
},
- "outline_palette_i_90": {
+ "outline_i_90": {
"declaration": "outline-color: var(--palette_i_90); --outline_color: var(--palette_i_90);"
},
- "outline_palette_i_95": {
+ "outline_i_95": {
"declaration": "outline-color: var(--palette_i_95); --outline_color: var(--palette_i_95);"
},
- "outline_palette_i_100": {
+ "outline_i_100": {
"declaration": "outline-color: var(--palette_i_100); --outline_color: var(--palette_i_100);"
},
- "outline_palette_j_00": {
+ "outline_j_00": {
"declaration": "outline-color: var(--palette_j_00); --outline_color: var(--palette_j_00);"
},
- "outline_palette_j_05": {
+ "outline_j_05": {
"declaration": "outline-color: var(--palette_j_05); --outline_color: var(--palette_j_05);"
},
- "outline_palette_j_10": {
+ "outline_j_10": {
"declaration": "outline-color: var(--palette_j_10); --outline_color: var(--palette_j_10);"
},
- "outline_palette_j_20": {
+ "outline_j_20": {
"declaration": "outline-color: var(--palette_j_20); --outline_color: var(--palette_j_20);"
},
- "outline_palette_j_30": {
+ "outline_j_30": {
"declaration": "outline-color: var(--palette_j_30); --outline_color: var(--palette_j_30);"
},
- "outline_palette_j_40": {
+ "outline_j_40": {
"declaration": "outline-color: var(--palette_j_40); --outline_color: var(--palette_j_40);"
},
- "outline_palette_j_50": {
+ "outline_j_50": {
"declaration": "outline-color: var(--palette_j_50); --outline_color: var(--palette_j_50);"
},
- "outline_palette_j_60": {
+ "outline_j_60": {
"declaration": "outline-color: var(--palette_j_60); --outline_color: var(--palette_j_60);"
},
- "outline_palette_j_70": {
+ "outline_j_70": {
"declaration": "outline-color: var(--palette_j_70); --outline_color: var(--palette_j_70);"
},
- "outline_palette_j_80": {
+ "outline_j_80": {
"declaration": "outline-color: var(--palette_j_80); --outline_color: var(--palette_j_80);"
},
- "outline_palette_j_90": {
+ "outline_j_90": {
"declaration": "outline-color: var(--palette_j_90); --outline_color: var(--palette_j_90);"
},
- "outline_palette_j_95": {
+ "outline_j_95": {
"declaration": "outline-color: var(--palette_j_95); --outline_color: var(--palette_j_95);"
},
- "outline_palette_j_100": {
+ "outline_j_100": {
"declaration": "outline-color: var(--palette_j_100); --outline_color: var(--palette_j_100);"
},
"border_width_1": {
@@ -2772,394 +2772,394 @@
"shadow_alpha_100": {
"declaration": "--shadow_alpha: var(--shadow_alpha_100);"
},
- "shadow_palette_a_00": {
+ "shadow_a_00": {
"declaration": "--shadow_color: var(--palette_a_00);"
},
- "shadow_palette_a_05": {
+ "shadow_a_05": {
"declaration": "--shadow_color: var(--palette_a_05);"
},
- "shadow_palette_a_10": {
+ "shadow_a_10": {
"declaration": "--shadow_color: var(--palette_a_10);"
},
- "shadow_palette_a_20": {
+ "shadow_a_20": {
"declaration": "--shadow_color: var(--palette_a_20);"
},
- "shadow_palette_a_30": {
+ "shadow_a_30": {
"declaration": "--shadow_color: var(--palette_a_30);"
},
- "shadow_palette_a_40": {
+ "shadow_a_40": {
"declaration": "--shadow_color: var(--palette_a_40);"
},
- "shadow_palette_a_50": {
+ "shadow_a_50": {
"declaration": "--shadow_color: var(--palette_a_50);"
},
- "shadow_palette_a_60": {
+ "shadow_a_60": {
"declaration": "--shadow_color: var(--palette_a_60);"
},
- "shadow_palette_a_70": {
+ "shadow_a_70": {
"declaration": "--shadow_color: var(--palette_a_70);"
},
- "shadow_palette_a_80": {
+ "shadow_a_80": {
"declaration": "--shadow_color: var(--palette_a_80);"
},
- "shadow_palette_a_90": {
+ "shadow_a_90": {
"declaration": "--shadow_color: var(--palette_a_90);"
},
- "shadow_palette_a_95": {
+ "shadow_a_95": {
"declaration": "--shadow_color: var(--palette_a_95);"
},
- "shadow_palette_a_100": {
+ "shadow_a_100": {
"declaration": "--shadow_color: var(--palette_a_100);"
},
- "shadow_palette_b_00": {
+ "shadow_b_00": {
"declaration": "--shadow_color: var(--palette_b_00);"
},
- "shadow_palette_b_05": {
+ "shadow_b_05": {
"declaration": "--shadow_color: var(--palette_b_05);"
},
- "shadow_palette_b_10": {
+ "shadow_b_10": {
"declaration": "--shadow_color: var(--palette_b_10);"
},
- "shadow_palette_b_20": {
+ "shadow_b_20": {
"declaration": "--shadow_color: var(--palette_b_20);"
},
- "shadow_palette_b_30": {
+ "shadow_b_30": {
"declaration": "--shadow_color: var(--palette_b_30);"
},
- "shadow_palette_b_40": {
+ "shadow_b_40": {
"declaration": "--shadow_color: var(--palette_b_40);"
},
- "shadow_palette_b_50": {
+ "shadow_b_50": {
"declaration": "--shadow_color: var(--palette_b_50);"
},
- "shadow_palette_b_60": {
+ "shadow_b_60": {
"declaration": "--shadow_color: var(--palette_b_60);"
},
- "shadow_palette_b_70": {
+ "shadow_b_70": {
"declaration": "--shadow_color: var(--palette_b_70);"
},
- "shadow_palette_b_80": {
+ "shadow_b_80": {
"declaration": "--shadow_color: var(--palette_b_80);"
},
- "shadow_palette_b_90": {
+ "shadow_b_90": {
"declaration": "--shadow_color: var(--palette_b_90);"
},
- "shadow_palette_b_95": {
+ "shadow_b_95": {
"declaration": "--shadow_color: var(--palette_b_95);"
},
- "shadow_palette_b_100": {
+ "shadow_b_100": {
"declaration": "--shadow_color: var(--palette_b_100);"
},
- "shadow_palette_c_00": {
+ "shadow_c_00": {
"declaration": "--shadow_color: var(--palette_c_00);"
},
- "shadow_palette_c_05": {
+ "shadow_c_05": {
"declaration": "--shadow_color: var(--palette_c_05);"
},
- "shadow_palette_c_10": {
+ "shadow_c_10": {
"declaration": "--shadow_color: var(--palette_c_10);"
},
- "shadow_palette_c_20": {
+ "shadow_c_20": {
"declaration": "--shadow_color: var(--palette_c_20);"
},
- "shadow_palette_c_30": {
+ "shadow_c_30": {
"declaration": "--shadow_color: var(--palette_c_30);"
},
- "shadow_palette_c_40": {
+ "shadow_c_40": {
"declaration": "--shadow_color: var(--palette_c_40);"
},
- "shadow_palette_c_50": {
+ "shadow_c_50": {
"declaration": "--shadow_color: var(--palette_c_50);"
},
- "shadow_palette_c_60": {
+ "shadow_c_60": {
"declaration": "--shadow_color: var(--palette_c_60);"
},
- "shadow_palette_c_70": {
+ "shadow_c_70": {
"declaration": "--shadow_color: var(--palette_c_70);"
},
- "shadow_palette_c_80": {
+ "shadow_c_80": {
"declaration": "--shadow_color: var(--palette_c_80);"
},
- "shadow_palette_c_90": {
+ "shadow_c_90": {
"declaration": "--shadow_color: var(--palette_c_90);"
},
- "shadow_palette_c_95": {
+ "shadow_c_95": {
"declaration": "--shadow_color: var(--palette_c_95);"
},
- "shadow_palette_c_100": {
+ "shadow_c_100": {
"declaration": "--shadow_color: var(--palette_c_100);"
},
- "shadow_palette_d_00": {
+ "shadow_d_00": {
"declaration": "--shadow_color: var(--palette_d_00);"
},
- "shadow_palette_d_05": {
+ "shadow_d_05": {
"declaration": "--shadow_color: var(--palette_d_05);"
},
- "shadow_palette_d_10": {
+ "shadow_d_10": {
"declaration": "--shadow_color: var(--palette_d_10);"
},
- "shadow_palette_d_20": {
+ "shadow_d_20": {
"declaration": "--shadow_color: var(--palette_d_20);"
},
- "shadow_palette_d_30": {
+ "shadow_d_30": {
"declaration": "--shadow_color: var(--palette_d_30);"
},
- "shadow_palette_d_40": {
+ "shadow_d_40": {
"declaration": "--shadow_color: var(--palette_d_40);"
},
- "shadow_palette_d_50": {
+ "shadow_d_50": {
"declaration": "--shadow_color: var(--palette_d_50);"
},
- "shadow_palette_d_60": {
+ "shadow_d_60": {
"declaration": "--shadow_color: var(--palette_d_60);"
},
- "shadow_palette_d_70": {
+ "shadow_d_70": {
"declaration": "--shadow_color: var(--palette_d_70);"
},
- "shadow_palette_d_80": {
+ "shadow_d_80": {
"declaration": "--shadow_color: var(--palette_d_80);"
},
- "shadow_palette_d_90": {
+ "shadow_d_90": {
"declaration": "--shadow_color: var(--palette_d_90);"
},
- "shadow_palette_d_95": {
+ "shadow_d_95": {
"declaration": "--shadow_color: var(--palette_d_95);"
},
- "shadow_palette_d_100": {
+ "shadow_d_100": {
"declaration": "--shadow_color: var(--palette_d_100);"
},
- "shadow_palette_e_00": {
+ "shadow_e_00": {
"declaration": "--shadow_color: var(--palette_e_00);"
},
- "shadow_palette_e_05": {
+ "shadow_e_05": {
"declaration": "--shadow_color: var(--palette_e_05);"
},
- "shadow_palette_e_10": {
+ "shadow_e_10": {
"declaration": "--shadow_color: var(--palette_e_10);"
},
- "shadow_palette_e_20": {
+ "shadow_e_20": {
"declaration": "--shadow_color: var(--palette_e_20);"
},
- "shadow_palette_e_30": {
+ "shadow_e_30": {
"declaration": "--shadow_color: var(--palette_e_30);"
},
- "shadow_palette_e_40": {
+ "shadow_e_40": {
"declaration": "--shadow_color: var(--palette_e_40);"
},
- "shadow_palette_e_50": {
+ "shadow_e_50": {
"declaration": "--shadow_color: var(--palette_e_50);"
},
- "shadow_palette_e_60": {
+ "shadow_e_60": {
"declaration": "--shadow_color: var(--palette_e_60);"
},
- "shadow_palette_e_70": {
+ "shadow_e_70": {
"declaration": "--shadow_color: var(--palette_e_70);"
},
- "shadow_palette_e_80": {
+ "shadow_e_80": {
"declaration": "--shadow_color: var(--palette_e_80);"
},
- "shadow_palette_e_90": {
+ "shadow_e_90": {
"declaration": "--shadow_color: var(--palette_e_90);"
},
- "shadow_palette_e_95": {
+ "shadow_e_95": {
"declaration": "--shadow_color: var(--palette_e_95);"
},
- "shadow_palette_e_100": {
+ "shadow_e_100": {
"declaration": "--shadow_color: var(--palette_e_100);"
},
- "shadow_palette_f_00": {
+ "shadow_f_00": {
"declaration": "--shadow_color: var(--palette_f_00);"
},
- "shadow_palette_f_05": {
+ "shadow_f_05": {
"declaration": "--shadow_color: var(--palette_f_05);"
},
- "shadow_palette_f_10": {
+ "shadow_f_10": {
"declaration": "--shadow_color: var(--palette_f_10);"
},
- "shadow_palette_f_20": {
+ "shadow_f_20": {
"declaration": "--shadow_color: var(--palette_f_20);"
},
- "shadow_palette_f_30": {
+ "shadow_f_30": {
"declaration": "--shadow_color: var(--palette_f_30);"
},
- "shadow_palette_f_40": {
+ "shadow_f_40": {
"declaration": "--shadow_color: var(--palette_f_40);"
},
- "shadow_palette_f_50": {
+ "shadow_f_50": {
"declaration": "--shadow_color: var(--palette_f_50);"
},
- "shadow_palette_f_60": {
+ "shadow_f_60": {
"declaration": "--shadow_color: var(--palette_f_60);"
},
- "shadow_palette_f_70": {
+ "shadow_f_70": {
"declaration": "--shadow_color: var(--palette_f_70);"
},
- "shadow_palette_f_80": {
+ "shadow_f_80": {
"declaration": "--shadow_color: var(--palette_f_80);"
},
- "shadow_palette_f_90": {
+ "shadow_f_90": {
"declaration": "--shadow_color: var(--palette_f_90);"
},
- "shadow_palette_f_95": {
+ "shadow_f_95": {
"declaration": "--shadow_color: var(--palette_f_95);"
},
- "shadow_palette_f_100": {
+ "shadow_f_100": {
"declaration": "--shadow_color: var(--palette_f_100);"
},
- "shadow_palette_g_00": {
+ "shadow_g_00": {
"declaration": "--shadow_color: var(--palette_g_00);"
},
- "shadow_palette_g_05": {
+ "shadow_g_05": {
"declaration": "--shadow_color: var(--palette_g_05);"
},
- "shadow_palette_g_10": {
+ "shadow_g_10": {
"declaration": "--shadow_color: var(--palette_g_10);"
},
- "shadow_palette_g_20": {
+ "shadow_g_20": {
"declaration": "--shadow_color: var(--palette_g_20);"
},
- "shadow_palette_g_30": {
+ "shadow_g_30": {
"declaration": "--shadow_color: var(--palette_g_30);"
},
- "shadow_palette_g_40": {
+ "shadow_g_40": {
"declaration": "--shadow_color: var(--palette_g_40);"
},
- "shadow_palette_g_50": {
+ "shadow_g_50": {
"declaration": "--shadow_color: var(--palette_g_50);"
},
- "shadow_palette_g_60": {
+ "shadow_g_60": {
"declaration": "--shadow_color: var(--palette_g_60);"
},
- "shadow_palette_g_70": {
+ "shadow_g_70": {
"declaration": "--shadow_color: var(--palette_g_70);"
},
- "shadow_palette_g_80": {
+ "shadow_g_80": {
"declaration": "--shadow_color: var(--palette_g_80);"
},
- "shadow_palette_g_90": {
+ "shadow_g_90": {
"declaration": "--shadow_color: var(--palette_g_90);"
},
- "shadow_palette_g_95": {
+ "shadow_g_95": {
"declaration": "--shadow_color: var(--palette_g_95);"
},
- "shadow_palette_g_100": {
+ "shadow_g_100": {
"declaration": "--shadow_color: var(--palette_g_100);"
},
- "shadow_palette_h_00": {
+ "shadow_h_00": {
"declaration": "--shadow_color: var(--palette_h_00);"
},
- "shadow_palette_h_05": {
+ "shadow_h_05": {
"declaration": "--shadow_color: var(--palette_h_05);"
},
- "shadow_palette_h_10": {
+ "shadow_h_10": {
"declaration": "--shadow_color: var(--palette_h_10);"
},
- "shadow_palette_h_20": {
+ "shadow_h_20": {
"declaration": "--shadow_color: var(--palette_h_20);"
},
- "shadow_palette_h_30": {
+ "shadow_h_30": {
"declaration": "--shadow_color: var(--palette_h_30);"
},
- "shadow_palette_h_40": {
+ "shadow_h_40": {
"declaration": "--shadow_color: var(--palette_h_40);"
},
- "shadow_palette_h_50": {
+ "shadow_h_50": {
"declaration": "--shadow_color: var(--palette_h_50);"
},
- "shadow_palette_h_60": {
+ "shadow_h_60": {
"declaration": "--shadow_color: var(--palette_h_60);"
},
- "shadow_palette_h_70": {
+ "shadow_h_70": {
"declaration": "--shadow_color: var(--palette_h_70);"
},
- "shadow_palette_h_80": {
+ "shadow_h_80": {
"declaration": "--shadow_color: var(--palette_h_80);"
},
- "shadow_palette_h_90": {
+ "shadow_h_90": {
"declaration": "--shadow_color: var(--palette_h_90);"
},
- "shadow_palette_h_95": {
+ "shadow_h_95": {
"declaration": "--shadow_color: var(--palette_h_95);"
},
- "shadow_palette_h_100": {
+ "shadow_h_100": {
"declaration": "--shadow_color: var(--palette_h_100);"
},
- "shadow_palette_i_00": {
+ "shadow_i_00": {
"declaration": "--shadow_color: var(--palette_i_00);"
},
- "shadow_palette_i_05": {
+ "shadow_i_05": {
"declaration": "--shadow_color: var(--palette_i_05);"
},
- "shadow_palette_i_10": {
+ "shadow_i_10": {
"declaration": "--shadow_color: var(--palette_i_10);"
},
- "shadow_palette_i_20": {
+ "shadow_i_20": {
"declaration": "--shadow_color: var(--palette_i_20);"
},
- "shadow_palette_i_30": {
+ "shadow_i_30": {
"declaration": "--shadow_color: var(--palette_i_30);"
},
- "shadow_palette_i_40": {
+ "shadow_i_40": {
"declaration": "--shadow_color: var(--palette_i_40);"
},
- "shadow_palette_i_50": {
+ "shadow_i_50": {
"declaration": "--shadow_color: var(--palette_i_50);"
},
- "shadow_palette_i_60": {
+ "shadow_i_60": {
"declaration": "--shadow_color: var(--palette_i_60);"
},
- "shadow_palette_i_70": {
+ "shadow_i_70": {
"declaration": "--shadow_color: var(--palette_i_70);"
},
- "shadow_palette_i_80": {
+ "shadow_i_80": {
"declaration": "--shadow_color: var(--palette_i_80);"
},
- "shadow_palette_i_90": {
+ "shadow_i_90": {
"declaration": "--shadow_color: var(--palette_i_90);"
},
- "shadow_palette_i_95": {
+ "shadow_i_95": {
"declaration": "--shadow_color: var(--palette_i_95);"
},
- "shadow_palette_i_100": {
+ "shadow_i_100": {
"declaration": "--shadow_color: var(--palette_i_100);"
},
- "shadow_palette_j_00": {
+ "shadow_j_00": {
"declaration": "--shadow_color: var(--palette_j_00);"
},
- "shadow_palette_j_05": {
+ "shadow_j_05": {
"declaration": "--shadow_color: var(--palette_j_05);"
},
- "shadow_palette_j_10": {
+ "shadow_j_10": {
"declaration": "--shadow_color: var(--palette_j_10);"
},
- "shadow_palette_j_20": {
+ "shadow_j_20": {
"declaration": "--shadow_color: var(--palette_j_20);"
},
- "shadow_palette_j_30": {
+ "shadow_j_30": {
"declaration": "--shadow_color: var(--palette_j_30);"
},
- "shadow_palette_j_40": {
+ "shadow_j_40": {
"declaration": "--shadow_color: var(--palette_j_40);"
},
- "shadow_palette_j_50": {
+ "shadow_j_50": {
"declaration": "--shadow_color: var(--palette_j_50);"
},
- "shadow_palette_j_60": {
+ "shadow_j_60": {
"declaration": "--shadow_color: var(--palette_j_60);"
},
- "shadow_palette_j_70": {
+ "shadow_j_70": {
"declaration": "--shadow_color: var(--palette_j_70);"
},
- "shadow_palette_j_80": {
+ "shadow_j_80": {
"declaration": "--shadow_color: var(--palette_j_80);"
},
- "shadow_palette_j_90": {
+ "shadow_j_90": {
"declaration": "--shadow_color: var(--palette_j_90);"
},
- "shadow_palette_j_95": {
+ "shadow_j_95": {
"declaration": "--shadow_color: var(--palette_j_95);"
},
- "shadow_palette_j_100": {
+ "shadow_j_100": {
"declaration": "--shadow_color: var(--palette_j_100);"
},
"width_xs5": {
diff --git a/src/test/modified_class_interpreter.test.ts b/src/test/modified_class_interpreter.test.ts
index 28d1af4fe..ae4355298 100644
--- a/src/test/modified_class_interpreter.test.ts
+++ b/src/test/modified_class_interpreter.test.ts
@@ -642,13 +642,13 @@ describe('modified_class_interpreter', () => {
describe('state modifier ordering for cascade', () => {
test('hover classes come before active classes in output (LVFHA order)', () => {
const result = generate_classes_css({
- class_names: ['active:border_palette_a_50', 'hover:border_palette_b_50'],
+ class_names: ['active:border_a_50', 'hover:border_b_50'],
class_definitions: css_class_definitions,
interpreters: [modified_class_interpreter],
css_properties: null,
});
- assert_css_order(result.css, '.hover\\:border_palette_b_50', '.active\\:border_palette_a_50');
+ assert_css_order(result.css, '.hover\\:border_b_50', '.active\\:border_a_50');
});
test('visited < focus < hover < active ordering', () => {
diff --git a/src/test/vite_plugin_examples.test.ts b/src/test/vite_plugin_examples.test.ts
index 2add9c002..8920b145c 100644
--- a/src/test/vite_plugin_examples.test.ts
+++ b/src/test/vite_plugin_examples.test.ts
@@ -105,14 +105,14 @@ const EXPECTED_CLASSES = [
'flex:1',
// From App - Interactive (hover/active state modifiers)
'row',
- 'hover:border_palette_b_50',
- 'hover:outline_palette_b_50',
- 'active:border_palette_d_50',
- 'active:outline_palette_d_50',
- 'hover:border_palette_g_50',
- 'hover:outline_palette_g_50',
- 'active:border_palette_h_50',
- 'active:outline_palette_h_50',
+ 'hover:border_b_50',
+ 'hover:outline_b_50',
+ 'active:border_d_50',
+ 'active:outline_d_50',
+ 'hover:border_g_50',
+ 'hover:outline_g_50',
+ 'active:border_h_50',
+ 'active:outline_h_50',
].sort();
/**
diff --git a/vite.config.ts b/vite.config.ts
index 1e14ee387..74f4d9d0a 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -29,7 +29,7 @@ const docs_classes: Array = [
...shadow_variant_prefixes.flatMap((p) => shadow_size_variants.map((s) => `${p}${s}`)),
...shadow_alpha_variants.map((v) => `shadow_alpha_${v}`),
...shadow_semantic_values.map((v) => `shadow_color_${v}`),
- ...palette_variants.flatMap((l) => intensity_variants.map((i) => `shadow_palette_${l}_${i}`)),
+ ...palette_variants.flatMap((l) => intensity_variants.map((i) => `shadow_${l}_${i}`)),
...palette_variants.map((l) => `palette_${l}_50`),
];
From 3a987f498b4375dff885f2ed71539b07539606c4 Mon Sep 17 00:00:00 2001
From: Ryan Atkinson
Date: Sat, 11 Jul 2026 13:09:14 -0400
Subject: [PATCH 08/11] wip
---
.changeset/dev-initial-load-prescan.md | 14 +
.changeset/quiet-otters-gather.md | 28 +
CLAUDE.md | 18 +-
src/lib/ramps.ts | 66 +-
src/lib/theme_check.ts | 860 ++++++++++++++++++
src/lib/vite_plugin_fuz_css.ts | 380 +++++---
src/routes/ThemeEditor.svelte | 38 +-
src/test/fixtures/vite_dev/extra/widgets.html | 1 +
src/test/fixtures/vite_dev/src/island.html | 1 +
src/test/fixtures/vite_dev/src/page.html | 1 +
src/test/ramps.test.ts | 44 +-
src/test/theme_check.compile.test.ts | 77 ++
src/test/theme_check.test.ts | 215 +++++
src/test/themes.test.ts | 22 +-
src/test/vite_plugin_fuz_css.dev.test.ts | 76 ++
15 files changed, 1670 insertions(+), 171 deletions(-)
create mode 100644 .changeset/dev-initial-load-prescan.md
create mode 100644 .changeset/quiet-otters-gather.md
create mode 100644 src/lib/theme_check.ts
create mode 100644 src/test/fixtures/vite_dev/extra/widgets.html
create mode 100644 src/test/fixtures/vite_dev/src/island.html
create mode 100644 src/test/fixtures/vite_dev/src/page.html
create mode 100644 src/test/theme_check.compile.test.ts
create mode 100644 src/test/theme_check.test.ts
create mode 100644 src/test/vite_plugin_fuz_css.dev.test.ts
diff --git a/.changeset/dev-initial-load-prescan.md b/.changeset/dev-initial-load-prescan.md
new file mode 100644
index 000000000..f90e43c00
--- /dev/null
+++ b/.changeset/dev-initial-load-prescan.md
@@ -0,0 +1,14 @@
+---
+'@fuzdev/fuz_css': patch
+---
+
+fix: complete utility CSS on the first dev page load
+
+The Vite plugin now eagerly 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:
+extraction state accumulated only from modules Vite had transformed so far,
+and the corrective HMR update was dropped when the browser hadn't connected
+yet.
diff --git a/.changeset/quiet-otters-gather.md b/.changeset/quiet-otters-gather.md
new file mode 100644
index 000000000..401196bff
--- /dev/null
+++ b/.changeset/quiet-otters-gather.md
@@ -0,0 +1,28 @@
+---
+'@fuzdev/fuz_css': minor
+---
+
+feat: add theme validation, numeric-twin accessibility gates, and a compile step
+
+New `theme_check.ts` module with three functions over a shared numeric
+resolution core that turns a theme's authored CSS back into numbers (knob
+defaults from the `ramps.ts` numeric twin, `var(--x)` bindings recursed with a
+cycle guard, and the machine-emitted compiled-cap `min(calc(...), )` form):
+
+- `validate_theme` — the structural lint: non-empty name, `StyleVariable`
+ shape, and known variable names as errors, plus advisory type/range warnings
+ for the knob-tier variables.
+- `check_theme` — evaluates the gamut, ramp-monotonicity, and contrast gates
+ (the same thresholds `ramps.test.ts` asserts for the defaults) against an
+ arbitrary theme, resolved through its bindings. Report-only, never throws.
+- `compile_theme` — recomputes each theme's per-stop worst-hue chroma caps
+ from its own hues, lightness ramp, and hue shift, then emits
+ `palette_chroma_NN` overrides where the baked caps no longer fit (a rotated,
+ monochrome, or dark-only theme), and re-checks the result.
+
+The gate thresholds (`GATE_BODY_TEXT`, `GATE_SUBTLE_TEXT`, `GATE_LINK`,
+`GATE_UI`, `GATE_FILL_TEXT`) move to `theme_check.ts` as exported constants.
+`ramps.ts` gains `ramp_hue_shift_offset` (the numeric twin of the hue-shift
+CSS emitter) and `compute_palette_chroma_caps` (the generalized worst-hue cap
+search behind the baked table and the compile step); `ramp_chroma` and
+`render_chroma_stop_css` take optional knob/cap parameters.
diff --git a/CLAUDE.md b/CLAUDE.md
index f51e87b14..69897bfd1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -114,6 +114,12 @@ combined and only used content is included. In utility-only mode, import
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
@@ -121,7 +127,10 @@ 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
@@ -301,6 +310,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
@@ -340,6 +352,10 @@ typography, borders, shading, shadows, layout. See
(kind/axis/leverage/tier/bindable/range) for the knob-tier variables, joined
against `default_variables` by name; includes hook knobs like
`heading_font_weight`
+- [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`
diff --git a/src/lib/ramps.ts b/src/lib/ramps.ts
index b7ba62ef8..bb746fceb 100644
--- a/src/lib/ramps.ts
+++ b/src/lib/ramps.ts
@@ -28,7 +28,7 @@ import {
type ColorSchemeVariant,
type NumericScaleVariant,
} from './variable_data.ts';
-import type {Oklch} from './oklch.ts';
+import {oklch_max_srgb_chroma, type Oklch} from './oklch.ts';
/** The 13 intensity stops in ramp order (00 → 100). */
export const RAMP_STOPS: ReadonlyArray = numeric_scale_variants;
@@ -199,16 +199,35 @@ export const ramp_chroma_shape = (stop: NumericScaleVariant, curve: number): num
/**
* Evaluates the palette chroma at a stop: the knob curve clamped by that
* stop's worst-hue cap. `chroma_scale` multiplies above the clamp.
+ *
+ * @param knobs - chroma-curve knobs; defaults to the fitted `PALETTE_CHROMA_KNOBS`
+ * @param cap - the per-stop gamut clamp; defaults to the baked `PALETTE_CHROMA_CAPS`
*/
export const ramp_chroma = (
scheme: ColorSchemeVariant,
stop: NumericScaleVariant,
chroma_scale = 1,
+ knobs: ChromaRampKnobs = PALETTE_CHROMA_KNOBS[scheme],
+ cap: number = PALETTE_CHROMA_CAPS[scheme][stop],
): number => {
- const knobs = PALETTE_CHROMA_KNOBS[scheme];
const requested =
knobs.chroma_min + (knobs.chroma_max - knobs.chroma_min) * ramp_chroma_shape(stop, knobs.curve);
- return Math.min(requested, PALETTE_CHROMA_CAPS[scheme][stop]) * chroma_scale;
+ return Math.min(requested, cap) * chroma_scale;
+};
+
+/**
+ * Evaluates a stop's hue-shift offset in degrees — the numeric twin of
+ * `render_hue_shift_offset_css`. `hue_shift` is the total rotation across a
+ * ramp, anchored at stop 50; the scheme flip is baked in so positive values
+ * always rotate hue upward toward the dark end.
+ */
+export const ramp_hue_shift_offset = (
+ stop: NumericScaleVariant,
+ scheme: ColorSchemeVariant,
+ hue_shift: number,
+): number => {
+ const centered = ramp_stop_t(stop) - 0.5;
+ return (scheme === 'light' ? centered : -centered) * hue_shift;
};
/**
@@ -242,6 +261,39 @@ export const text_stop_oklch = (stop: NumericScaleVariant, scheme: ColorSchemeVa
NEUTRAL_HUE,
];
+/**
+ * Recomputes the worst-hue safe chroma caps per stop for an arbitrary hue set
+ * and lightness ramp — the generalization of the baked `PALETTE_CHROMA_CAPS`.
+ * For each stop the lightness comes from `ramp_lightness`, each hue is offset
+ * by that stop's `ramp_hue_shift_offset`, and the cap is the minimum
+ * `oklch_max_srgb_chroma` across the hues, floored to 4 decimals to stay
+ * conservative (the browser clips anything past it). A theme's compile step
+ * feeds its own hues, lightness knobs, and hue shift to detect where the baked
+ * worst-hue envelope no longer fits.
+ *
+ * @param hues - OKLCH hue angles the ramp must stay in gamut for
+ * @param lightness_knobs - the palette lightness ramp knobs for this scheme
+ * @param hue_shift - total ramp hue rotation in degrees; defaults to 0
+ */
+export const compute_palette_chroma_caps = (
+ hues: ReadonlyArray,
+ lightness_knobs: LightnessRampKnobs,
+ scheme: ColorSchemeVariant,
+ hue_shift = 0,
+): Record => {
+ const caps = {} as Record;
+ for (const stop of numeric_scale_variants) {
+ const lightness = ramp_lightness(lightness_knobs, stop);
+ let cap = Infinity;
+ for (const hue of hues) {
+ const effective_hue = hue + ramp_hue_shift_offset(stop, scheme, hue_shift);
+ cap = Math.min(cap, oklch_max_srgb_chroma(lightness, effective_hue));
+ }
+ caps[stop] = Math.floor(cap * 1e4) / 1e4;
+ }
+ return caps;
+};
+
/*
CSS emitters — the string twins of the numeric evaluators above. These render
@@ -287,15 +339,19 @@ export const render_chroma_shape_css = (stop: NumericScaleVariant): string => {
* Renders the capped default of a palette chroma ramp stop: the knob curve
* clamped by that stop's worst-hue gamut cap. Per-scheme because the caps
* differ; `--chroma_scale` multiplies outside this clamp, at the color stops.
+ *
+ * @param cap - the clamp value; defaults to the baked `PALETTE_CHROMA_CAPS`,
+ * overridden by the theme compile step with a recomputed worst-hue cap
*/
export const render_chroma_stop_css = (
stop: NumericScaleVariant,
scheme: ColorSchemeVariant,
+ cap: number = PALETTE_CHROMA_CAPS[scheme][stop],
): string => {
- const cap = format_ramp_number(PALETTE_CHROMA_CAPS[scheme][stop]);
+ const cap_str = format_ramp_number(cap);
return `min(calc(var(--palette_chroma_min) + (var(--palette_chroma_max) - var(--palette_chroma_min)) * var(--chroma_shape_${
stop
- })), ${cap})`;
+ })), ${cap_str})`;
};
/**
diff --git a/src/lib/theme_check.ts b/src/lib/theme_check.ts
new file mode 100644
index 000000000..0e862ec5e
--- /dev/null
+++ b/src/lib/theme_check.ts
@@ -0,0 +1,860 @@
+/**
+ * Theme lint, numeric-twin accessibility gates, and the compile step for the
+ * derived OKLCH color system.
+ *
+ * Three functions sit over a shared numeric resolution core:
+ *
+ * - `validate_theme` is the structural lint — non-empty name, `StyleVariable`
+ * shape, known variable names, and advisory type/range warnings for the
+ * knob-tier variables.
+ * - `check_theme` evaluates the gamut, ramp-monotonicity, and contrast gates
+ * (the same ones `src/test/ramps.test.ts` asserts for the defaults) against
+ * an arbitrary theme, reusing the `ramps.ts` numeric twin, `oklch.ts`
+ * conversions, and `wcag.ts` ratios. It is report-only and never throws.
+ * - `compile_theme` recomputes the per-stop worst-hue chroma caps for a
+ * theme's own hues and lightness ramp and emits `palette_chroma_NN`
+ * overrides where the baked caps no longer fit, then re-checks.
+ *
+ * The resolution core turns the CSS strings themes author back into numbers so
+ * the gates can run. Its contract:
+ *
+ * - Knob-tier defaults come from the numeric-twin constants in `ramps.ts`, not
+ * from parsing `default_variables`; intent and neutral hues default to their
+ * palette-letter binding (`hue_accent` → `hue_a`, `hue_neutral` → `hue_f`,
+ * and so on) so they follow an overridden letter.
+ * - Theme-authored values parse as a numeric literal, an exact `var(--x)`
+ * reference (recursed through the same effective-value merge, with a
+ * visited-set cycle guard), or the machine-emitted compiled-cap
+ * `min(calc(...), )` form. Anything else is unresolvable and is
+ * recorded with its variable, value, and reason.
+ * - Derived ramp stops use a pinned numeric value when the theme pins one,
+ * fall back to the `ramps.ts` formulas with the resolved knobs otherwise,
+ * and mark the touching gates `unchecked` when a pin is unresolvable.
+ *
+ * The effective-value merge mirrors the renderer's cascade-layer semantics
+ * (`theme.ts` `render_theme_style`, `theme_editor_state.svelte.ts`
+ * `display_value`): light = `theme.light`; dark = `theme.dark ?? theme.light`,
+ * falling back to the numeric-twin default for the scheme.
+ *
+ * @module
+ */
+
+import {StyleVariable} from './variable.ts';
+import type {Theme} from './theme.ts';
+import {default_variables} from './variables.ts';
+import {theme_knob_by_name, theme_knob_hook_names, type ThemeKnob} from './knobs.ts';
+import {
+ PALETTE_HUES,
+ PALETTE_LIGHTNESS_KNOBS,
+ SHADE_LIGHTNESS_KNOBS,
+ TEXT_LIGHTNESS_KNOBS,
+ PALETTE_CHROMA_KNOBS,
+ PALETTE_CHROMA_CAPS,
+ NEUTRAL_CHROMA,
+ ramp_lightness,
+ ramp_chroma,
+ ramp_chroma_shape,
+ ramp_hue_shift_offset,
+ compute_palette_chroma_caps,
+ render_chroma_stop_css,
+ type LightnessRampKnobs,
+} from './ramps.ts';
+import {
+ numeric_scale_variants,
+ palette_variants,
+ intent_variants,
+ color_scheme_variants,
+ type NumericScaleVariant,
+ type ColorSchemeVariant,
+ type PaletteVariant,
+} from './variable_data.ts';
+import {oklch_to_srgb, oklch_in_srgb_gamut, type Oklch, type RgbUnit} from './oklch.ts';
+import {wcag_contrast_ratio} from './wcag.ts';
+
+//
+// Gate thresholds — the WCAG levels the derived palette is designed to clear.
+//
+
+/** AAA body text: `text_80` on `shade_00`/`05`/`10`. */
+export const GATE_BODY_TEXT = 7;
+/** Disabled/secondary floor: `text_50` on `shade_00`. */
+export const GATE_SUBTLE_TEXT = 3;
+/** AA link default: the accent hue at stop 60 on `shade_00`. */
+export const GATE_LINK = 4.5;
+/** WCAG 1.4.11 non-text: every hue at stop 50 vs `shade_00`. */
+export const GATE_UI = 3;
+/** Large-text floor: `text_max` on every stop-50 fill. */
+export const GATE_FILL_TEXT = 3;
+
+/**
+ * The variable names a theme may set: the declared defaults plus the hook
+ * knobs `style.css` consumes through `var()` fallbacks.
+ */
+export const known_theme_variable_names: Set = new Set([
+ ...default_variables.map((v) => v.name),
+ ...theme_knob_hook_names,
+]);
+
+//
+// Report types.
+//
+
+/**
+ * A structural lint finding. `error` marks a broken theme (bad shape, unknown
+ * variable); `warning` is advisory (value doesn't match the knob's kind or
+ * sits outside its safe range).
+ */
+export interface ThemeIssue {
+ level: 'error' | 'warning';
+ message: string;
+ variable?: string;
+}
+
+/** Which accessibility gate an entry belongs to. */
+export type ThemeGateId = 'gamut' | 'monotonicity' | 'contrast';
+
+/** A single gate measurement against a theme. */
+export interface ThemeGateEntry {
+ gate: ThemeGateId;
+ scheme: ColorSchemeVariant;
+ subject: string;
+ value: number;
+ threshold: number;
+ pass: boolean;
+}
+
+/** A gate input that couldn't be resolved to a number, so its gate was skipped. */
+export interface ThemeUncheckedEntry {
+ variable: string;
+ value: string;
+ reason: string;
+}
+
+/**
+ * The result of `check_theme`. `ok` is true only when every entry passes and
+ * nothing was left unchecked.
+ */
+export interface ThemeCheckReport {
+ ok: boolean;
+ entries: Array;
+ unchecked: Array;
+}
+
+/** The output of `compile_theme`: the emitted theme plus its lint and gate report. */
+export interface CompiledTheme {
+ theme: Theme;
+ report: ThemeCheckReport;
+ issues: Array;
+}
+
+//
+// Resolution core.
+//
+
+/** A resolved numeric value, or the offending variable/value/reason on failure. */
+type Resolved =
+ | {ok: true; value: number}
+ | {ok: false; variable: string; value: string; reason: string};
+
+/** Intent and neutral hues default to a palette-letter binding. */
+const INTENT_HUE_DEFAULT_BINDING: Record = {
+ hue_accent: 'hue_a',
+ hue_positive: 'hue_b',
+ hue_negative: 'hue_c',
+ hue_caution: 'hue_h',
+ hue_info: 'hue_i',
+ hue_neutral: 'hue_f',
+};
+
+const LIGHTNESS_KNOBS_BY_FAMILY: Record<
+ 'palette' | 'shade' | 'text',
+ Record
+> = {
+ palette: PALETTE_LIGHTNESS_KNOBS,
+ shade: SHADE_LIGHTNESS_KNOBS,
+ text: TEXT_LIGHTNESS_KNOBS,
+};
+
+const PALETTE_LETTER_MATCHER = /^hue_([a-j])$/u;
+const LIGHTNESS_KNOB_MATCHER = /^(palette|shade|text)_lightness_(00|100|curve)$/u;
+const LIGHTNESS_STOP_MATCHER =
+ /^(palette|shade|text)_lightness_(05|10|20|30|40|50|60|70|80|90|95)$/u;
+const PALETTE_CHROMA_STOP_MATCHER = /^palette_chroma_(00|05|10|20|30|40|50|60|70|80|90|95|100)$/u;
+const VAR_MATCHER = /^var\(\s*--([a-z][a-z0-9_]*)\s*\)$/u;
+
+/**
+ * The compiled worst-hue cap form emitted by `render_chroma_stop_css`:
+ * `min(calc(var(--palette_chroma_min) + (var(--palette_chroma_max) - var(--palette_chroma_min)) * var(--chroma_shape_NN)), )`.
+ * Recognizing it keeps compiled themes fully checkable.
+ */
+const COMPILED_CAP_MATCHER =
+ /^min\(\s*calc\(\s*var\(--palette_chroma_min\)\s*\+\s*\(\s*var\(--palette_chroma_max\)\s*-\s*var\(--palette_chroma_min\)\s*\)\s*\*\s*var\(--chroma_shape_(00|05|10|20|30|40|50|60|70|80|90|95|100)\)\s*\)\s*,\s*(-?\d*\.?\d+)\s*\)$/u;
+
+/**
+ * Resolves knob-tier and derived-stop variables of a single theme to numbers,
+ * mirroring the renderer's effective-value merge and the `ramps.ts` formulas.
+ */
+class ThemeResolver {
+ readonly #by_name: Map;
+ readonly #memo: Map = new Map();
+
+ constructor(theme: Theme) {
+ this.#by_name = new Map(theme.variables.map((v) => [v.name, v]));
+ }
+
+ /** Whether the theme authors a value for `name` (a pin). */
+ pinned(name: string): boolean {
+ return this.#by_name.has(name);
+ }
+
+ /** Resolves `name` for `scheme`, memoized per name+scheme. */
+ resolve(name: string, scheme: ColorSchemeVariant): Resolved {
+ const key = `${scheme}|${name}`;
+ const cached = this.#memo.get(key);
+ if (cached) return cached;
+ const result = this.#resolve(name, scheme, new Set());
+ this.#memo.set(key, result);
+ return result;
+ }
+
+ // the theme-authored value for a slot, honoring the dark → light fallback
+ #authored(name: string, scheme: ColorSchemeVariant): string | undefined {
+ const v = this.#by_name.get(name);
+ if (!v) return undefined;
+ return scheme === 'light' ? v.light : (v.dark ?? v.light);
+ }
+
+ #resolve(name: string, scheme: ColorSchemeVariant, visited: Set): Resolved {
+ if (visited.has(name)) {
+ return {ok: false, variable: name, value: `var(--${name})`, reason: 'cyclic var() reference'};
+ }
+ const next = new Set(visited);
+ next.add(name);
+ const authored = this.#authored(name, scheme);
+ if (authored !== undefined) return this.#parse(name, authored, scheme, next);
+ return this.#resolve_default(name, scheme, next);
+ }
+
+ #parse(name: string, value: string, scheme: ColorSchemeVariant, visited: Set): Resolved {
+ const trimmed = value.trim();
+ // numeric literal
+ if (trimmed !== '') {
+ const n = Number(trimmed);
+ if (Number.isFinite(n)) return {ok: true, value: n};
+ }
+ // exactly var(--x) — recurse through the same merge
+ const var_match = VAR_MATCHER.exec(trimmed);
+ if (var_match) return this.#resolve(var_match[1]!, scheme, visited);
+ // machine-emitted compiled cap form
+ const cap = this.#parse_compiled_cap(trimmed, scheme, visited);
+ if (cap) return cap;
+ return {ok: false, variable: name, value: trimmed, reason: 'unrecognized value expression'};
+ }
+
+ #parse_compiled_cap(
+ value: string,
+ scheme: ColorSchemeVariant,
+ visited: Set,
+ ): Resolved | null {
+ const m = COMPILED_CAP_MATCHER.exec(value);
+ if (!m) return null;
+ const stop = m[1] as NumericScaleVariant;
+ const literal = Number(m[2]);
+ const chroma_min = this.#resolve('palette_chroma_min', scheme, visited);
+ if (!chroma_min.ok) return chroma_min;
+ const chroma_max = this.#resolve('palette_chroma_max', scheme, visited);
+ if (!chroma_max.ok) return chroma_max;
+ const curve = this.#resolve('palette_chroma_curve', scheme, visited);
+ if (!curve.ok) return curve;
+ const shape = ramp_chroma_shape(stop, curve.value);
+ const requested = chroma_min.value + (chroma_max.value - chroma_min.value) * shape;
+ return {ok: true, value: Math.min(requested, literal)};
+ }
+
+ #resolve_default(name: string, scheme: ColorSchemeVariant, visited: Set): Resolved {
+ // palette letters
+ const letter_match = PALETTE_LETTER_MATCHER.exec(name);
+ if (letter_match) return {ok: true, value: PALETTE_HUES[letter_match[1] as PaletteVariant]};
+ // intent/neutral hues default to a palette-letter binding
+ const binding = INTENT_HUE_DEFAULT_BINDING[name];
+ if (binding) return this.#resolve(binding, scheme, visited);
+ // scalar knobs
+ if (name === 'chroma_scale') return {ok: true, value: 1};
+ if (name === 'hue_shift') return {ok: true, value: 0};
+ if (name === 'neutral_chroma') return {ok: true, value: NEUTRAL_CHROMA[scheme]};
+ if (name === 'palette_chroma_min') {
+ return {ok: true, value: PALETTE_CHROMA_KNOBS[scheme].chroma_min};
+ }
+ if (name === 'palette_chroma_max') {
+ return {ok: true, value: PALETTE_CHROMA_KNOBS[scheme].chroma_max};
+ }
+ if (name === 'palette_chroma_curve') {
+ return {ok: true, value: PALETTE_CHROMA_KNOBS[scheme].curve};
+ }
+ // lightness endpoints and curve
+ const knob_match = LIGHTNESS_KNOB_MATCHER.exec(name);
+ if (knob_match) {
+ const knobs =
+ LIGHTNESS_KNOBS_BY_FAMILY[knob_match[1] as 'palette' | 'shade' | 'text'][scheme];
+ const field = knob_match[2];
+ const value =
+ field === '00' ? knobs.lightness_00 : field === '100' ? knobs.lightness_100 : knobs.curve;
+ return {ok: true, value};
+ }
+ // derived lightness intermediates — compute from the resolved knobs
+ const stop_match = LIGHTNESS_STOP_MATCHER.exec(name);
+ if (stop_match) {
+ const family = stop_match[1] as 'palette' | 'shade' | 'text';
+ const stop = stop_match[2] as NumericScaleVariant;
+ const knobs = this.#lightness_knobs(family, scheme, visited);
+ if (!knobs.ok) return knobs.error;
+ return {ok: true, value: ramp_lightness(knobs.value, stop)};
+ }
+ // derived palette chroma stops — the capped knob curve
+ const chroma_match = PALETTE_CHROMA_STOP_MATCHER.exec(name);
+ if (chroma_match) {
+ const stop = chroma_match[1] as NumericScaleVariant;
+ const knobs = this.#chroma_knobs(scheme, visited);
+ if (!knobs.ok) return knobs.error;
+ const value = ramp_chroma(scheme, stop, 1, knobs.value, PALETTE_CHROMA_CAPS[scheme][stop]);
+ return {ok: true, value};
+ }
+ return {
+ ok: false,
+ variable: name,
+ value: '(default)',
+ reason: 'no numeric default for variable',
+ };
+ }
+
+ #lightness_knobs(
+ family: 'palette' | 'shade' | 'text',
+ scheme: ColorSchemeVariant,
+ visited: Set,
+ ): {ok: true; value: LightnessRampKnobs} | {ok: false; error: Resolved} {
+ const l00 = this.#resolve(`${family}_lightness_00`, scheme, visited);
+ if (!l00.ok) return {ok: false, error: l00};
+ const l100 = this.#resolve(`${family}_lightness_100`, scheme, visited);
+ if (!l100.ok) return {ok: false, error: l100};
+ const curve = this.#resolve(`${family}_lightness_curve`, scheme, visited);
+ if (!curve.ok) return {ok: false, error: curve};
+ return {
+ ok: true,
+ value: {lightness_00: l00.value, lightness_100: l100.value, curve: curve.value},
+ };
+ }
+
+ #chroma_knobs(
+ scheme: ColorSchemeVariant,
+ visited: Set,
+ ):
+ | {ok: true; value: {chroma_min: number; chroma_max: number; curve: number}}
+ | {ok: false; error: Resolved} {
+ const chroma_min = this.#resolve('palette_chroma_min', scheme, visited);
+ if (!chroma_min.ok) return {ok: false, error: chroma_min};
+ const chroma_max = this.#resolve('palette_chroma_max', scheme, visited);
+ if (!chroma_max.ok) return {ok: false, error: chroma_max};
+ const curve = this.#resolve('palette_chroma_curve', scheme, visited);
+ if (!curve.ok) return {ok: false, error: curve};
+ return {
+ ok: true,
+ value: {chroma_min: chroma_min.value, chroma_max: chroma_max.value, curve: curve.value},
+ };
+ }
+}
+
+/**
+ * Resolves a single knob-tier or derived-stop variable of `theme` to a number,
+ * or `null` when it can't be resolved. Exposed for direct tests of the
+ * resolution rules (binding chains, cycles, unresolvable expressions).
+ *
+ * @param name - the variable name (without the leading `--`)
+ */
+export const resolve_theme_knob = (
+ theme: Theme,
+ name: string,
+ scheme: ColorSchemeVariant,
+): number | null => {
+ const r = new ThemeResolver(theme).resolve(name, scheme);
+ return r.ok ? r.value : null;
+};
+
+//
+// validate_theme — the structural lint.
+//
+
+const validate_knob_value = (
+ knob: ThemeKnob,
+ value: string,
+ variable: string,
+ slot: string,
+): Array => {
+ const issues: Array = [];
+ const trimmed = value.trim();
+ const numeric = trimmed !== '' && Number.isFinite(Number(trimmed));
+ const check_range = (): void => {
+ if (knob.range && (Number(trimmed) < knob.range[0] || Number(trimmed) > knob.range[1])) {
+ issues.push({
+ level: 'warning',
+ message: `${variable} ${slot} ${trimmed} is outside the safe range [${knob.range[0]}, ${
+ knob.range[1]
+ }], the design envelope (knowingly exceedable)`,
+ variable,
+ });
+ }
+ };
+ switch (knob.kind) {
+ case 'number':
+ case 'percent': {
+ if (!numeric) {
+ issues.push({
+ level: 'warning',
+ message: `${variable} ${slot} "${value}" is not a numeric ${knob.kind} value`,
+ variable,
+ });
+ } else {
+ check_range();
+ }
+ break;
+ }
+ case 'hue': {
+ // a literal angle, or a var(--hue_X) binding (legal CSS regardless of `bindable`)
+ const is_binding = /^var\(--hue_[a-j]\)$/u.test(trimmed);
+ if (!numeric && !is_binding) {
+ issues.push({
+ level: 'warning',
+ message: `${variable} ${slot} "${value}" is not a hue angle or var(--hue_X) binding`,
+ variable,
+ });
+ } else if (numeric) {
+ check_range();
+ }
+ break;
+ }
+ case 'time': {
+ if (!/^-?\d*\.?\d+s$/u.test(trimmed)) {
+ issues.push({
+ level: 'warning',
+ message: `${variable} ${slot} "${value}" is not a CSS time value like 0.2s`,
+ variable,
+ });
+ }
+ break;
+ }
+ case 'enum': {
+ if (knob.values && !knob.values.includes(trimmed)) {
+ issues.push({
+ level: 'warning',
+ message: `${variable} ${slot} "${value}" is not one of ${knob.values.join(', ')}`,
+ variable,
+ });
+ }
+ break;
+ }
+ default:
+ // length, color, font_stack, shadow, text — freeform, advisory only
+ break;
+ }
+ return issues;
+};
+
+/**
+ * Lints a theme's structure: a non-empty name, valid `StyleVariable` shape and
+ * known name per variable (errors), and advisory type/range warnings for the
+ * knob-tier variables. Value validation is advisory and never an error. An
+ * empty array means the theme is structurally valid.
+ */
+export const validate_theme = (theme: Theme): Array => {
+ const issues: Array = [];
+ if (!theme.name) {
+ issues.push({level: 'error', message: 'theme name must be non-empty'});
+ }
+ for (const variable of theme.variables) {
+ const parsed = StyleVariable.safeParse(variable);
+ const name: unknown = (variable as {name?: unknown}).name;
+ const name_label = typeof name === 'string' ? name : undefined;
+ if (!parsed.success) {
+ for (const issue of parsed.error.issues) {
+ issues.push({
+ level: 'error',
+ message: `invalid variable${name_label ? ` "${name_label}"` : ''}: ${issue.message}`,
+ ...(name_label ? {variable: name_label} : null),
+ });
+ }
+ continue;
+ }
+ const valid = parsed.data;
+ if (!known_theme_variable_names.has(valid.name)) {
+ issues.push({
+ level: 'error',
+ message: `unknown variable "${valid.name}"`,
+ variable: valid.name,
+ });
+ continue;
+ }
+ const knob = theme_knob_by_name.get(valid.name);
+ if (!knob) continue;
+ if (valid.light !== undefined) {
+ issues.push(...validate_knob_value(knob, valid.light, valid.name, 'light'));
+ }
+ if (valid.dark !== undefined) {
+ issues.push(...validate_knob_value(knob, valid.dark, valid.name, 'dark'));
+ }
+ }
+ return issues;
+};
+
+//
+// check_theme — the numeric-twin accessibility gates.
+//
+
+const clamp01 = (n: number): number => Math.min(1, Math.max(0, n));
+
+const clamp_rgb = (rgb: RgbUnit): RgbUnit => [clamp01(rgb[0]), clamp01(rgb[1]), clamp01(rgb[2])];
+
+// max sRGB channel excess outside [0, 1] — 0 when in gamut
+const gamut_excess = ([r, g, b]: RgbUnit): number => Math.max(0, -r, r - 1, -g, g - 1, -b, b - 1);
+
+/**
+ * Runs the gamut, monotonicity, and contrast gates against a theme, resolving
+ * its authored CSS back to numbers through the resolution core. Report-only:
+ * failures land in `entries` (with `pass: false`), inputs that can't be
+ * resolved land in `unchecked`, and `ok` is true only when every entry passes
+ * and nothing is unchecked. Never throws.
+ */
+export const check_theme = (theme: Theme): ThemeCheckReport => {
+ const resolver = new ThemeResolver(theme);
+ const entries: Array = [];
+ const unchecked: Array = [];
+ const seen_unchecked: Set = new Set();
+
+ const record = (r: Extract): null => {
+ const key = `${r.variable}|${r.value}|${r.reason}`;
+ if (!seen_unchecked.has(key)) {
+ seen_unchecked.add(key);
+ unchecked.push({variable: r.variable, value: r.value, reason: r.reason});
+ }
+ return null;
+ };
+
+ const num = (name: string, scheme: ColorSchemeVariant): number | null => {
+ const r = resolver.resolve(name, scheme);
+ return r.ok ? r.value : record(r);
+ };
+
+ // a palette/intent ramp color at a stop for a given hue angle
+ const ramp_color = (
+ hue: number,
+ stop: NumericScaleVariant,
+ scheme: ColorSchemeVariant,
+ ): Oklch | null => {
+ const lightness = num(`palette_lightness_${stop}`, scheme);
+ const chroma_stop = num(`palette_chroma_${stop}`, scheme);
+ const chroma_scale = num('chroma_scale', scheme);
+ const hue_shift = num('hue_shift', scheme);
+ if (lightness === null || chroma_stop === null || chroma_scale === null || hue_shift === null) {
+ return null;
+ }
+ return [
+ lightness,
+ chroma_stop * chroma_scale,
+ hue + ramp_hue_shift_offset(stop, scheme, hue_shift),
+ ];
+ };
+
+ // a neutral (shade/text) ramp color at a stop
+ const neutral_color = (
+ family: 'shade' | 'text',
+ stop: NumericScaleVariant,
+ scheme: ColorSchemeVariant,
+ ): Oklch | null => {
+ const lightness = num(`${family}_lightness_${stop}`, scheme);
+ const neutral_c = num('neutral_chroma', scheme);
+ const curve = num('palette_chroma_curve', scheme);
+ const neutral_hue = num('hue_neutral', scheme);
+ const hue_shift = num('hue_shift', scheme);
+ if (
+ lightness === null ||
+ neutral_c === null ||
+ curve === null ||
+ neutral_hue === null ||
+ hue_shift === null
+ ) {
+ return null;
+ }
+ return [
+ lightness,
+ neutral_c * ramp_chroma_shape(stop, curve),
+ neutral_hue + ramp_hue_shift_offset(stop, scheme, hue_shift),
+ ];
+ };
+
+ const contrast = (a: RgbUnit, b: RgbUnit): number =>
+ wcag_contrast_ratio(clamp_rgb(a), clamp_rgb(b));
+
+ const push_gamut = (subject: string, color: Oklch, scheme: ColorSchemeVariant): void => {
+ entries.push({
+ gate: 'gamut',
+ scheme,
+ subject,
+ value: gamut_excess(oklch_to_srgb(color)),
+ threshold: 1e-4,
+ pass: oklch_in_srgb_gamut(color, 1e-4),
+ });
+ };
+
+ const push_monotonicity = (
+ family: 'palette' | 'shade' | 'text',
+ scheme: ColorSchemeVariant,
+ ): void => {
+ const lightnesses: Array = [];
+ for (const stop of numeric_scale_variants) {
+ const l = num(`${family}_lightness_${stop}`, scheme);
+ if (l === null) return; // input unchecked; skip this family+scheme
+ lightnesses.push(l);
+ }
+ const direction = Math.sign(lightnesses[lightnesses.length - 1]! - lightnesses[0]!);
+ let min_step = Infinity;
+ for (let i = 1; i < lightnesses.length; i++) {
+ min_step = Math.min(min_step, (lightnesses[i]! - lightnesses[i - 1]!) * direction);
+ }
+ // direction 0 (degenerate ramp) yields min_step 0 and fails, as it should
+ const value = direction === 0 ? 0 : min_step;
+ entries.push({
+ gate: 'monotonicity',
+ scheme,
+ subject: `${family}_lightness`,
+ value,
+ threshold: 0,
+ pass: value > 0,
+ });
+ };
+
+ for (const scheme of color_scheme_variants) {
+ // gamut: palette letters × 13 stops
+ const letter_hues: Array = [];
+ for (const letter of palette_variants) {
+ const hue = num(`hue_${letter}`, scheme);
+ if (hue === null) continue;
+ letter_hues.push(hue);
+ for (const stop of numeric_scale_variants) {
+ const color = ramp_color(hue, stop, scheme);
+ if (color) push_gamut(`palette_${letter}_${stop}`, color, scheme);
+ }
+ }
+ // gamut: each intent hue that resolves to a literal angle distinct from all letters
+ for (const intent of intent_variants) {
+ const hue = num(`hue_${intent}`, scheme);
+ if (hue === null) continue;
+ if (letter_hues.some((h) => Math.abs(h - hue) < 1e-9)) continue;
+ for (const stop of numeric_scale_variants) {
+ const color = ramp_color(hue, stop, scheme);
+ if (color) push_gamut(`${intent}_${stop}`, color, scheme);
+ }
+ }
+ // gamut: the neutral (shade/text) scales
+ for (const family of ['shade', 'text'] as const) {
+ for (const stop of numeric_scale_variants) {
+ const color = neutral_color(family, stop, scheme);
+ if (color) push_gamut(`${family}_${stop}`, color, scheme);
+ }
+ }
+
+ // monotonicity: each lightness family
+ for (const family of ['palette', 'shade', 'text'] as const) {
+ push_monotonicity(family, scheme);
+ }
+
+ // contrast: body text — text_80 on shade_00/05/10
+ const text_80 = neutral_color('text', '80', scheme);
+ for (const stop of ['00', '05', '10'] as const) {
+ const surface = neutral_color('shade', stop, scheme);
+ if (!text_80 || !surface) continue;
+ const ratio = contrast(oklch_to_srgb(text_80), oklch_to_srgb(surface));
+ entries.push({
+ gate: 'contrast',
+ scheme,
+ subject: `text_80 on shade_${stop}`,
+ value: ratio,
+ threshold: GATE_BODY_TEXT,
+ pass: ratio >= GATE_BODY_TEXT,
+ });
+ }
+
+ const shade_00 = neutral_color('shade', '00', scheme);
+
+ // contrast: subtle text — text_50 on shade_00
+ const text_50 = neutral_color('text', '50', scheme);
+ if (text_50 && shade_00) {
+ const ratio = contrast(oklch_to_srgb(text_50), oklch_to_srgb(shade_00));
+ entries.push({
+ gate: 'contrast',
+ scheme,
+ subject: 'text_50 on shade_00',
+ value: ratio,
+ threshold: GATE_SUBTLE_TEXT,
+ pass: ratio >= GATE_SUBTLE_TEXT,
+ });
+ }
+
+ // contrast: link — the accent hue at stop 60 on shade_00 (resolved through bindings)
+ const accent_hue = num('hue_accent', scheme);
+ if (accent_hue !== null && shade_00) {
+ const link = ramp_color(accent_hue, '60', scheme);
+ if (link) {
+ const ratio = contrast(oklch_to_srgb(link), oklch_to_srgb(shade_00));
+ entries.push({
+ gate: 'contrast',
+ scheme,
+ subject: 'accent_60 on shade_00',
+ value: ratio,
+ threshold: GATE_LINK,
+ pass: ratio >= GATE_LINK,
+ });
+ }
+ }
+
+ // contrast: UI affordances — every letter and intent fill at stop 50
+ const text_max: RgbUnit = scheme === 'light' ? [0, 0, 0] : [1, 1, 1];
+ const fills: Array<[string, number]> = [];
+ for (const letter of palette_variants) {
+ const hue = num(`hue_${letter}`, scheme);
+ if (hue !== null) fills.push([`palette_${letter}`, hue]);
+ }
+ for (const intent of intent_variants) {
+ const hue = num(`hue_${intent}`, scheme);
+ if (hue !== null) fills.push([intent, hue]);
+ }
+ if (shade_00) {
+ for (const [label, hue] of fills) {
+ const fill = ramp_color(hue, '50', scheme);
+ if (!fill) continue;
+ const fill_rgb = oklch_to_srgb(fill);
+ const ui = contrast(fill_rgb, oklch_to_srgb(shade_00));
+ entries.push({
+ gate: 'contrast',
+ scheme,
+ subject: `${label}_50 vs shade_00`,
+ value: ui,
+ threshold: GATE_UI,
+ pass: ui >= GATE_UI,
+ });
+ const on_fill = contrast(text_max, fill_rgb);
+ entries.push({
+ gate: 'contrast',
+ scheme,
+ subject: `text_max on ${label}_50`,
+ value: on_fill,
+ threshold: GATE_FILL_TEXT,
+ pass: on_fill >= GATE_FILL_TEXT,
+ });
+ }
+ }
+ }
+
+ const ok = unchecked.length === 0 && entries.every((e) => e.pass);
+ return {ok, entries, unchecked};
+};
+
+//
+// compile_theme — recompute worst-hue caps for the theme's own hues.
+//
+
+// the difference at which a recomputed cap is worth emitting; below this the
+// baked table's ~1e-3 search slack would produce no-op overrides, and sub-JND
+// chroma isn't worth an override
+const CAP_EMIT_EPSILON = 0.002;
+
+// the theme's effective hue set for cap recomputation: 10 letters plus any
+// intent that resolves to a literal angle distinct from every letter
+const collect_hues = (resolver: ThemeResolver, scheme: ColorSchemeVariant): Array => {
+ const hues: Array = [];
+ for (const letter of palette_variants) {
+ const r = resolver.resolve(`hue_${letter}`, scheme);
+ if (r.ok) hues.push(r.value);
+ }
+ for (const intent of intent_variants) {
+ const r = resolver.resolve(`hue_${intent}`, scheme);
+ if (r.ok && !hues.some((h) => Math.abs(h - r.value) < 1e-9)) hues.push(r.value);
+ }
+ return hues;
+};
+
+const resolve_or = (
+ resolver: ThemeResolver,
+ name: string,
+ scheme: ColorSchemeVariant,
+ fallback: number,
+): number => {
+ const r = resolver.resolve(name, scheme);
+ return r.ok ? r.value : fallback;
+};
+
+const resolve_lightness_knobs = (
+ resolver: ThemeResolver,
+ scheme: ColorSchemeVariant,
+): LightnessRampKnobs => {
+ const l00 = resolver.resolve('palette_lightness_00', scheme);
+ const l100 = resolver.resolve('palette_lightness_100', scheme);
+ const curve = resolver.resolve('palette_lightness_curve', scheme);
+ const fallback = PALETTE_LIGHTNESS_KNOBS[scheme];
+ return {
+ lightness_00: l00.ok ? l00.value : fallback.lightness_00,
+ lightness_100: l100.ok ? l100.value : fallback.lightness_100,
+ curve: curve.ok ? curve.value : fallback.curve,
+ };
+};
+
+/**
+ * Recomputes a theme's per-stop worst-hue chroma caps from its own hues,
+ * palette lightness ramp, and hue shift, then emits `palette_chroma_NN`
+ * overrides wherever the baked caps no longer fit — the fix for a theme
+ * (rotated hues, a monochrome collapse, a dark-only mirror) whose gamut
+ * headroom the baked worst-hue table misjudges.
+ *
+ * A stop is emitted only when either scheme's recomputed cap drifts from the
+ * baked value by more than the emit epsilon and the theme doesn't already pin
+ * that stop. Both slots are always emitted together so a one-scheme override
+ * can't silently kill the base default's other slot by cascade-layer order.
+ * The input theme is never mutated. The report re-checks the emitted theme,
+ * whose compiled-cap overrides the resolution core recognizes.
+ */
+export const compile_theme = (theme: Theme): CompiledTheme => {
+ const issues = validate_theme(theme);
+ const resolver = new ThemeResolver(theme);
+
+ const recomputed: Record> = {
+ light: compute_palette_chroma_caps(
+ collect_hues(resolver, 'light'),
+ resolve_lightness_knobs(resolver, 'light'),
+ 'light',
+ resolve_or(resolver, 'hue_shift', 'light', 0),
+ ),
+ dark: compute_palette_chroma_caps(
+ collect_hues(resolver, 'dark'),
+ resolve_lightness_knobs(resolver, 'dark'),
+ 'dark',
+ resolve_or(resolver, 'hue_shift', 'dark', 0),
+ ),
+ };
+
+ const cap_overrides: Array = [];
+ for (const stop of numeric_scale_variants) {
+ if (resolver.pinned(`palette_chroma_${stop}`)) continue; // respect the pin
+ const light_cap = recomputed.light[stop];
+ const dark_cap = recomputed.dark[stop];
+ const light_drift = Math.abs(light_cap - PALETTE_CHROMA_CAPS.light[stop]);
+ const dark_drift = Math.abs(dark_cap - PALETTE_CHROMA_CAPS.dark[stop]);
+ if (light_drift > CAP_EMIT_EPSILON || dark_drift > CAP_EMIT_EPSILON) {
+ cap_overrides.push({
+ name: `palette_chroma_${stop}`,
+ // always emit both slots — the dual-slot rule
+ light: render_chroma_stop_css(stop, 'light', light_cap),
+ dark: render_chroma_stop_css(stop, 'dark', dark_cap),
+ });
+ }
+ }
+
+ const compiled: Theme = {name: theme.name, variables: [...theme.variables, ...cap_overrides]};
+ return {theme: compiled, report: check_theme(compiled), issues};
+};
diff --git a/src/lib/vite_plugin_fuz_css.ts b/src/lib/vite_plugin_fuz_css.ts
index 01a2ad744..30ead7d4b 100644
--- a/src/lib/vite_plugin_fuz_css.ts
+++ b/src/lib/vite_plugin_fuz_css.ts
@@ -5,7 +5,10 @@
* as they're processed, including node_modules dependencies. In bundled
* mode (the default) it also resolves the base reset and theme variables,
* emitting only the rules, elements, and variables the source actually uses.
- * Generates CSS on-demand via virtual module with HMR support.
+ * Generates CSS on-demand via virtual module with HMR support. In dev it
+ * eagerly pre-scans project sources at server startup so the first served
+ * CSS is complete (see `prescan`), and resyncs clients whose HMR socket
+ * connects after a missed update.
*
* @example
* ```ts
@@ -26,9 +29,11 @@
* @module
*/
-import type {Logger as ViteLogger, Plugin, ViteDevServer} from 'vite';
-import {join} from 'node:path';
+import {normalizePath, type Logger as ViteLogger, type Plugin, type ViteDevServer} from 'vite';
+import {isAbsolute, join} from 'node:path';
import {hash_blake3} from '@fuzdev/fuz_util/hash_blake3.ts';
+import {fs_search} from '@fuzdev/fuz_util/fs.ts';
+import {each_concurrent} from '@fuzdev/fuz_util/async.ts';
import {format_diagnostic, CssGenerationError} from './diagnostics.ts';
import {generate_css} from './generate_css.ts';
@@ -113,11 +118,42 @@ const is_ci = !!process.env.CI;
const HMR_DEBOUNCE_MS = 10;
/**
- * Options for the fuz_css Vite plugin.
- * Extends the shared base options (no additional Vite-specific options currently needed).
+ * Concurrency for the dev pre-scan (file read + cache read + extract).
+ * Matches the Gro generator's default — the value controls I/O interleaving,
+ * not CPU parallelism (AST parsing is synchronous on the main thread).
*/
+const PRESCAN_CONCURRENCY = 8;
-export interface VitePluginFuzCssOptions extends CssGeneratorBaseOptions {}
+/** Default directories scanned by the dev pre-scan, relative to the Vite root. */
+const PRESCAN_ROOTS_DEFAULT = ['src'];
+
+/**
+ * Options for the fuz_css Vite plugin.
+ * Extends the shared base options with Vite-specific options.
+ */
+export interface VitePluginFuzCssOptions extends CssGeneratorBaseOptions {
+ /**
+ * Dev-only eager pre-scan of source files at server startup.
+ *
+ * Extraction state accumulates from Vite's transform hook, so without a
+ * pre-scan the first CSS served on a cold start includes only the files
+ * the module graph happened to visit first — classes from modules the SSR
+ * walk never reaches (client-only branches, dynamic imports) are missing
+ * until a corrective HMR update, which is dropped when the browser hasn't
+ * connected yet. The pre-scan extracts every file passing `filter_file`
+ * under the given directories before the virtual module is first served,
+ * so the initial CSS is complete. Warm starts are cheap via the extraction
+ * cache.
+ *
+ * `true` scans `src` under the Vite root; `false` disables; an array gives
+ * custom directories (resolved against the Vite root unless absolute).
+ * node_modules deps are never pre-scanned — they stream in via transform
+ * and are covered by the connect-time resync.
+ *
+ * @default true
+ */
+ prescan?: boolean | Array;
+}
/**
* Creates the fuz_css Vite plugin.
@@ -144,6 +180,7 @@ export const vite_plugin_fuz_css = (options: VitePluginFuzCssOptions = {}): Plug
exclude_elements,
exclude_variables,
deps = default_cache_deps,
+ prescan = true,
} = options;
// Derive include flags from null check
@@ -184,9 +221,13 @@ export const vite_plugin_fuz_css = (options: VitePluginFuzCssOptions = {}): Plug
let hmr_timeout: ReturnType | null = null;
let last_generated_css: string | null = null;
let pending_css: string | null = null; // CSS generated during HMR, reused by load()
+ let last_served_css: string | null = null; // last CSS returned by load(); connect-time resync diffs against it
+ let prescan_promise: Promise | null = null; // load() awaits this so the first served CSS is complete
+ /** Suppresses per-file HMR invalidation while the pre-scan runs (it invalidates once at the end). */
+ let prescan_active = false;
/**
- * Per-file epoch guarding against a deletion (or newer transform) racing an
- * in-flight transform. `transform()` awaits the disk cache read; an `unlink`
+ * Per-file epoch guarding against a deletion (or newer ingest) racing an
+ * in-flight ingest. `ingest_file` awaits the disk cache read; an `unlink`
* arriving during that await deletes the entry, but the resolved await would
* otherwise re-add it and resurrect the deleted file. Captured before the
* await, re-checked after.
@@ -199,8 +240,9 @@ export const vite_plugin_fuz_css = (options: VitePluginFuzCssOptions = {}): Plug
* `render_css` narrows to theme variables once `variable_graph` is loaded.
*
* Filtering here (the old behavior) silently dropped detections from any file
- * transformed before the graph loads — and the graph loads lazily on the first
- * `load()`. In SvelteKit dev, route nodes (e.g. `+page.svelte`) are resolved, and
+ * transformed before the graph loads — and the graph loads asynchronously
+ * (eagerly at `configureServer` in dev, on the first `load()` in build).
+ * In SvelteKit dev, route nodes (e.g. `+page.svelte`) are resolved, and
* thus transformed, during SSR *before* the layout's `virtual:fuz.css` import
* triggers that first `load()`; a theme `var()` used only there was dropped, and
* the cached content hash meant it never re-scanned until the file was edited.
@@ -214,7 +256,8 @@ export const vite_plugin_fuz_css = (options: VitePluginFuzCssOptions = {}): Plug
}
};
- // Bundled CSS resources (loaded lazily on first CSS generation when bundled mode is enabled)
+ // Bundled CSS resources (when bundled mode is enabled: started eagerly at
+ // configureServer in dev, lazily on first load() in build)
let style_rule_index: StyleRuleIndex | null = null;
let variable_graph: VariableDependencyGraph | null = null;
let class_variable_index: CssClassVariableIndex | null = null;
@@ -259,6 +302,14 @@ export const vite_plugin_fuz_css = (options: VitePluginFuzCssOptions = {}): Plug
}
};
+ const log_info = (msg: string): void => {
+ if (logger) {
+ logger.info(msg);
+ } else {
+ console.log(msg);
+ }
+ };
+
/**
* Generates banner-wrapped CSS from the current classes, dispatching
* diagnostics per the `on_error`/`on_warning` settings.
@@ -345,6 +396,48 @@ export const vite_plugin_fuz_css = (options: VitePluginFuzCssOptions = {}): Plug
return `${FUZ_CSS_BANNER}\n\n${final_css}\n\n${FUZ_CSS_BANNER}`;
};
+ /**
+ * Invalidates every served virtual-module variant and pushes the js-update
+ * that makes connected clients refetch. Shared by the debounced HMR path
+ * and the connect-time resync.
+ */
+ const invalidate_and_push = (new_css: string): void => {
+ last_generated_css = new_css;
+ pending_css = new_css; // Store for reuse in load() to avoid regenerating
+
+ // Invalidate every served variant, not just the bare id. The bare
+ // `/__fuz.css` backs the client `