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 @@ + + +
+
+
+ + +
+ {editor.is_palette_tier ? 'palette-tier' : 'semantic-tier'} + {#if editor.dirty} + + {/if} +
+
+ {#if name_collides} + + {/if} +
+ + edits write to the {editing_scheme} scheme's slots +
+
+ + +
+
+ {#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 editing}edit{:else}create{/if} theme -

- -
-
-

variables: {light_count} light, {dark_count} dark

- -
-
- - -
-
-
-
- {#each new_variables as variable (variable.name)} - - {/each} -
-
- {#if code} -
- -
- - {/if} -
-
-
-{#if selected_variable} - (selected_variable = null)}> - - {#snippet children({close})} -
- - - -
- {/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 @@
-
- --{knob.name} - {#if changed} - - {/if} -
- {#if knob.kind === 'enum'} - - {:else if knob.kind === 'hue' && numeric_value !== null} - numeric_value ?? 0, (v) => emit_numeric(String(v))} /> - {:else if scalar && numeric_value !== null} -
- - emit_numeric(e.currentTarget.value)} - /> - emit_numeric(e.currentTarget.value)} - /> -
+ {#if knob.kind === 'hue' && numeric_value !== null} + + numeric_value ?? 0, (v) => emit_numeric(String(v))}> + --{knob.name} + {:else} - onchange(e.currentTarget.value)} - /> + + {/if} + {#if changed} + + {/if}
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 @@ -
-
-
+
+
+
-
+
{editor.is_palette_tier ? 'palette-tier' : 'semantic-tier'} {#if editor.dirty} - {/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.

-
+
-
+
@@ -185,27 +185,11 @@
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 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); - }} - > +