From 1aa9180641e79c1616fffd7f895ce62b63314d4e Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Tue, 7 Jul 2026 11:49:45 -0400 Subject: [PATCH 01/15] 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/15] 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/15] 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); - }} - > + +
{#if editor.dirty} reset all{editor.overrides.size ? ` (${editor.overrides.size})` : ''} {/if}
@@ -143,7 +155,15 @@ {/if}
- edits write to the {editing_scheme} scheme's slots + {#if editor.stance} + single-scheme theme - edits write to the base slots and the {editor.stance} appearance renders in both color schemes + {:else} + edits write to the {editing_scheme} scheme's slots + {/if}
diff --git a/src/routes/docs/themes/+page.svelte b/src/routes/docs/themes/+page.svelte index e8537d3b0..a40bc81e6 100644 --- a/src/routes/docs/themes/+page.svelte +++ b/src/routes/docs/themes/+page.svelte @@ -158,6 +158,13 @@ lang="ts" content={`import {necromancer_theme} from '@fuzdev/fuz_css/themes/necromancer.ts';`} /> +

+ A theme can declare a single-scheme stance with scheme: 'light' | 'dark' -- the + renderer then mirrors every scheme-adaptive default the theme doesn't override, so its one + appearance renders in both color schemes, and pins + to match so form controls and scrollbars agree. The + necromancer and terminal exemplars are dark-only this way, without hand-mirrored knob values. +

@@ -168,11 +175,12 @@ 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. The top band holds the semantic-tier moves: - assign each intent (accent, neutral, positive, negative, caution, info) to a palette letter -- - or a custom angle -- and pull the high-leverage levers. Below it, each axis section carries - the granular knobs, with per-token escape hatches folded away; the ramp strips repaint live as - the derived scales move. + the Theme object below to keep it. The scheme selector sets the theme's stance -- + a single-scheme theme renders its one appearance in both color schemes, so edits write the + base slots. The top band holds the semantic-tier moves: assign each intent (accent, neutral, + positive, negative, caution, info) to a palette letter -- or a custom angle -- and pull the + high-leverage levers. Below it, each axis section carries the granular knobs, with per-token + escape hatches folded away; the ramp strips repaint live as the derived scales move.

diff --git a/src/routes/docs/variables/+page.svelte b/src/routes/docs/variables/+page.svelte index 3ce723abb..0c8fa899d 100644 --- a/src/routes/docs/variables/+page.svelte +++ b/src/routes/docs/variables/+page.svelte @@ -55,6 +55,16 @@ The result is a flexible system that aligns with modern CSS to deliver high-capability UX and DX with low overhead.

+

+ Most color variables are derived: curve knobs feed ramp stops, ramp stops feed color + stops, all computed in pure CSS (calc()/pow()/oklch()). + A theme is a set of knob values, not a stylesheet -- it usually moves a handful of + high-leverage variables (intent bindings like hue_accent, levers like + chroma_scale and radius_scale, the lightness curve knobs) and + everything downstream re-derives, while any individual variable stays pinnable as the escape + hatch. See for the color system and for + theming. +

In bundled mode, only the variables your code uses are emitted, along with any they depend on. The full @@ -71,6 +81,8 @@ content={`export interface Theme { name: string; variables: StyleVariable[]; + /** Single-scheme themes render one appearance in both color schemes. */ + scheme?: 'dual' | 'light' | 'dark'; } export interface StyleVariable { diff --git a/src/routes/theme_editor_state.svelte.ts b/src/routes/theme_editor_state.svelte.ts index edd9a7862..ff6cd34bf 100644 --- a/src/routes/theme_editor_state.svelte.ts +++ b/src/routes/theme_editor_state.svelte.ts @@ -1,6 +1,6 @@ import {SvelteMap} from 'svelte/reactivity'; -import type {Theme} from '$lib/theme.ts'; +import type {Theme, ThemeScheme} 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'; @@ -28,6 +28,7 @@ export interface SlotOverride { export interface ThemeEditorSnapshotData { name: string; based_on: string; + scheme: ThemeScheme; overrides: Array<[string, SlotOverride]>; } @@ -44,12 +45,20 @@ export interface ThemeEditorSnapshotData { * 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. + * + * A single-scheme stance (`Theme.scheme`) changes both halves: edits always + * write the light/base slot (the stance renders that one appearance in both + * color schemes, so dual slots are meaningless), and the merge skips the + * dark-slot preservation (the renderer's stance mirror handles untouched + * defaults). Switching into a stance re-slots existing overrides so the + * stanced scheme's edited values become the base slots. */ export class ThemeEditorState { readonly themes: Array = []; name: string = $state.raw('new theme'); based_on: string = $state.raw('base'); + scheme: ThemeScheme = $state.raw('dual'); readonly overrides: SvelteMap = new SvelteMap(); constructor(themes: Array) { @@ -60,11 +69,18 @@ export class ThemeEditorState { this.themes.find((t) => t.name === this.based_on) ?? this.themes[0]!, ); + readonly base_scheme: ThemeScheme = $derived(this.base_theme.scheme ?? 'dual'); + + /** The single-scheme stance, `null` for dual themes. */ + readonly stance: 'light' | 'dark' | null = $derived( + this.scheme === 'light' || this.scheme === 'dark' ? this.scheme : null, + ); + 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); + readonly dirty: boolean = $derived(this.overrides.size > 0 || this.scheme !== this.base_scheme); /** * True when the draft (or its base) moves palette-tier knobs — the letter @@ -102,8 +118,9 @@ export class ThemeEditorState { 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) { + // overrides - see the class comment for the cascade-layer rationale; + // under a stance the renderer's mirror owns the cross-scheme story + if (o?.light !== undefined && dark === undefined && !b && !this.stance) { dark = default_variable_by_name.get(name)?.dark; } if (dark !== undefined && dark === light) dark = undefined; @@ -115,23 +132,35 @@ export class ThemeEditorState { } /** The live-applied theme, stably named so pickers key it consistently. */ - readonly draft: Theme = $derived({name: UNSAVED_THEME_NAME, variables: this.merged_variables}); + readonly draft: Theme = $derived({ + name: UNSAVED_THEME_NAME, + variables: this.merged_variables, + ...(this.stance ? {scheme: this.stance} : {}), + }); /** The copyable theme, carrying the user's chosen name. */ - readonly output: Theme = $derived({name: this.name, variables: this.merged_variables}); + readonly output: Theme = $derived({ + name: this.name, + variables: this.merged_variables, + ...(this.stance ? {scheme: this.stance} : {}), + }); /** * The value a scheme currently renders for a variable, derived from the * same merge the renderer uses so the two can't disagree — including the - * theme layer's light slots beating the base defaults' dark slots, and the + * theme layer's light slots beating the base defaults' dark slots, the * merge preserving a scheme-adaptive default's dark slot under fresh - * light-only overrides. + * light-only overrides, and a single-scheme stance mirroring untouched + * scheme-adaptive defaults so both schemes show the stanced appearance. */ display_value(name: string, scheme: ColorSchemeVariant): string | undefined { const merged = this.#merge_variable(name); const d = default_variable_by_name.get(name); - if (scheme === 'light') return merged?.light ?? d?.light; - return merged?.dark ?? merged?.light ?? d?.dark ?? d?.light; + // the renderer's stance mirror applies only to defaults the theme + // doesn't touch, re-slotted so the stanced value wins in both schemes + const mirrored = !merged && this.stance ? d?.[this.stance] : undefined; + if (scheme === 'light') return merged?.light ?? mirrored ?? d?.light; + return merged?.dark ?? mirrored ?? merged?.light ?? d?.dark ?? d?.light; } changed(name: string): boolean { @@ -143,21 +172,47 @@ export class ThemeEditorState { 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'; + // under a stance edits always write the base slot - dual slots are + // meaningless when one appearance renders in both color schemes + const slot = !this.stance && adaptive ? scheme : 'light'; this.overrides.set(name, {...o, [slot]: value}); } + /** + * Sets the scheme stance. Entering a single-scheme stance re-slots existing + * overrides so the stanced scheme's edited values become base slots (dark + * slots would otherwise shadow later stanced edits in dark mode); a + * light-stanced theme drops dark-only overrides since that appearance never + * renders. + * + * @mutates `this` + */ + set_scheme(scheme: ThemeScheme): void { + this.scheme = scheme; + if (scheme !== 'light' && scheme !== 'dark') return; + for (const [name, o] of this.overrides) { + const value = scheme === 'dark' ? (o.dark ?? o.light) : o.light; + if (value === undefined) { + this.overrides.delete(name); + } else { + this.overrides.set(name, {light: value}); + } + } + } + reset(name: string): void { this.overrides.delete(name); } reset_all(): void { this.overrides.clear(); + this.scheme = this.base_scheme; } /** * Loads a theme as the new base: overrides clear and the editor edits on - * top of its flattened variables (flatten-on-load composition). + * top of its flattened variables (flatten-on-load composition), carrying + * the theme's scheme stance. * * @mutates `this` */ @@ -165,6 +220,7 @@ export class ThemeEditorState { if (theme.name === UNSAVED_THEME_NAME) return; this.based_on = theme.name; this.overrides.clear(); + this.scheme = theme.scheme ?? 'dual'; this.name = theme.name === 'base' ? 'new theme' : `custom ${theme.name}`; } @@ -172,6 +228,7 @@ export class ThemeEditorState { return { name: this.name, based_on: this.based_on, + scheme: this.scheme, overrides: Array.from(this.overrides.entries()).map(([name, o]) => [name, {...o}]), }; } @@ -182,6 +239,7 @@ export class ThemeEditorState { restore_snapshot(data: ThemeEditorSnapshotData): void { this.name = data.name; this.based_on = data.based_on; + this.scheme = data.scheme ?? this.base_scheme; this.overrides.clear(); for (const [name, o] of data.overrides) { this.overrides.set(name, {...o}); @@ -213,10 +271,14 @@ export const render_theme_ts = (theme: Theme): string => { const variables_ts = theme.variables.length ? `[\n${variables}\n\t],` : '[], // empty - every variable keeps its base default'; + const scheme_ts = + theme.scheme === 'light' || theme.scheme === 'dark' + ? `\n\tscheme: '${theme.scheme}', // renders this appearance in both color schemes` + : ''; return `import type {Theme} from '@fuzdev/fuz_css/theme.ts'; export const ${identifier}_theme: Theme = { - name: '${escape_single_quotes(theme.name)}', + name: '${escape_single_quotes(theme.name)}',${scheme_ts} variables: ${variables_ts} }; `; diff --git a/src/test/theme_check.test.ts b/src/test/theme_check.test.ts index 52cc144ff..4a2b0428e 100644 --- a/src/test/theme_check.test.ts +++ b/src/test/theme_check.test.ts @@ -85,6 +85,34 @@ describe('validate_theme', () => { const issues = validate_theme({name: 't', variables: [], scheme: 'dusk' as 'dark'}); assert.isTrue(issues.some((i) => i.level === 'error')); }); + + test('a dark slot under a single-scheme stance is a warning, not an error', () => { + for (const scheme of ['light', 'dark'] as const) { + const issues = validate_theme({ + name: 't', + scheme, + variables: [{name: 'neutral_chroma', light: '0.02', dark: '0.03'}], + }); + assert.isTrue(issues.some((i) => i.level === 'warning' && i.variable === 'neutral_chroma')); + assert.isFalse(issues.some((i) => i.level === 'error')); + } + // single-slot stanced and dual-slot unstanced themes stay clean + assert.deepEqual( + validate_theme({ + name: 't', + scheme: 'dark', + variables: [{name: 'neutral_chroma', light: '0.02'}], + }), + [], + ); + assert.deepEqual( + validate_theme({ + name: 't', + variables: [{name: 'neutral_chroma', light: '0.02', dark: '0.03'}], + }), + [], + ); + }); }); describe('resolution', () => { From 1b876c6c7d9df84c786347b9eda97e4f6eb97af7 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Thu, 16 Jul 2026 10:55:49 -0400 Subject: [PATCH 13/15] wip --- .changeset/dev-initial-load-prescan.md | 15 ++---- .changeset/micro-surface-hooks.md | 25 ++++----- .changeset/oklch-color-system.md | 60 +++++++++++++++++++++ .changeset/theme-knobs-and-checks.md | 63 ++++++++-------------- .changeset/theme-scheme-stance.md | 20 ++++--- .changeset/twelve-shrimps-report.md | 72 -------------------------- 6 files changed, 107 insertions(+), 148 deletions(-) create mode 100644 .changeset/oklch-color-system.md delete mode 100644 .changeset/twelve-shrimps-report.md diff --git a/.changeset/dev-initial-load-prescan.md b/.changeset/dev-initial-load-prescan.md index 4be74a2fb..45bd69c24 100644 --- a/.changeset/dev-initial-load-prescan.md +++ b/.changeset/dev-initial-load-prescan.md @@ -4,13 +4,8 @@ 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. Bundled resources (base-CSS rule index, variable graph) also load -eagerly at dev-server startup now, overlapping their parse with the -pre-scan for faster cold starts. +The Vite plugin now pre-scans project sources at dev-server startup (new +`prescan` option: `true` scans `src` under the Vite root, `false` disables, +or an array of directories) and resyncs clients whose HMR socket connects +after a missed CSS update. Previously the first cold-start page load could +render with incomplete utility classes until a manual refresh. diff --git a/.changeset/micro-surface-hooks.md b/.changeset/micro-surface-hooks.md index 82ef0e94a..0ebc9f6c3 100644 --- a/.changeset/micro-surface-hooks.md +++ b/.changeset/micro-surface-hooks.md @@ -5,22 +5,17 @@ feat: theme scrollbars, caret, dialog backdrop, and the OS contrast preference New micro-surface defaults in `style.css`, each themable through a `var()` -fallback hook (registered in the knob catalog so `validate_theme` accepts -them): +fallback hook (all registered theme variables): - `scrollbar-color` on `:root` — thumb defaults to `var(--shade_40)` on a - transparent track, so scrollbars follow the theme's neutral; hooks - `--scrollbar_thumb_color` / `--scrollbar_track_color` -- `caret-color` on text inputs — defaults to `var(--accent_50)`, matching - selection and focus; hook `--caret_color` -- `dialog::backdrop` — defaults to `var(--darken_60)`, the same dim fuz_ui's - `Dialog` uses; hook `--backdrop_color` + transparent track; hooks `--scrollbar_thumb_color` / + `--scrollbar_track_color` +- `caret-color` on text inputs — defaults to `var(--accent_50)`; hook + `--caret_color` +- `dialog::backdrop` — defaults to `var(--darken_60)`; hook + `--backdrop_color` - `@media (prefers-contrast: more)` maps the OS preference onto the curve - knobs, mirroring the `'high contrast'` theme's moves - (`--shade_lightness_00`, `--text_lightness_curve`); theme overrides beat - it from the `fuz.theme` layer + knobs, mirroring the `'high contrast'` theme; theme overrides beat it -Also tunes the `'low contrast'` registry theme's shade compression -(`shade_lightness_00` 0.92 light / 0.245 dark) to the softest values that -pass every `check_theme` WCAG gate, so the whole registry passes its own -gates. +Also tunes the `'low contrast'` theme's shade compression to the softest +values that pass every `check_theme` contrast gate. diff --git a/.changeset/oklch-color-system.md b/.changeset/oklch-color-system.md new file mode 100644 index 000000000..625902baf --- /dev/null +++ b/.changeset/oklch-color-system.md @@ -0,0 +1,60 @@ +--- +'@fuzdev/fuz_css': minor +--- + +feat: rework the color system to derived OKLCH, add semantic intents, cascade layers, and themes + +Colors are now derived — curve knobs → ramp stops → color stops — in pure +CSS (`calc()`/`pow()`/`oklch()`), fitted to minimize the perceptual delta +from the old HSL palette. Breaking changes: + +- **`color_` renamed to `palette_`**: `--color_a_50` → `--palette_a_50`, + `.color_a_50` → `.palette_a_50`, `.color_a`–`.color_j` → + `.palette_a`–`.palette_j`. In compound families the letter alone implies + the palette: `border_color_X_NN` → `border_X_NN`, `outline_color_X_NN` → + `outline_X_NN`, `shadow_color_X_NN` → `shadow_X_NN` (`bg_X_NN` and the + letterless families — `border_color_NN`, `outline_color_NN`, + `shadow_color_umbra` — keep their names). In TS: + `ColorVariant`/`color_variants` → `PaletteVariant`/`palette_variants`. +- **`--hue_a`…`--hue_j` are now OKLCH hue angles** (blue is `250`, not HSL + `210`). Consumer CSS doing `hsl(var(--hue_x) …)` breaks — use + `oklch( var(--hue_x))` or the palette/intent stops. +- **`--tint_hue`/`--tint_saturation` removed** → `--hue_neutral` (defaults + to `var(--hue_f)`) + `--neutral_chroma`. +- **Absolute `_light`/`_dark` variants removed**: the ~286 generated + variables (`--color_a_50_light`-style, `--shade_XX_light/dark`) and all + their classes. Write the literal color or define one custom property + instead. +- **New curve knobs** (the promoted theme API): `--chroma_scale`, + `--hue_shift`, `--palette_lightness_00/_100/_curve` (same trio for + `shade_`/`text_`), `--palette_chroma_min/_max/_curve` (clamped per stop by + baked worst-hue sRGB gamut caps), plus per-stop derived variables themes + can pin individually (`--palette_lightness_NN`, `--palette_chroma_NN`, + `--chroma_shape_NN`, `--hue_shift_NN`). +- **New semantic intent knobs**: `--hue_accent`, `--hue_positive`, + `--hue_negative`, `--hue_caution`, `--hue_info`, each deriving a 13-stop + scale through the shared ramps (`--accent_00`…`--accent_100`, etc.) with + matching token classes (`.positive_50`, `.bg_caution_10`), plus + `--selection_color` and `intent_variants`/`IntentVariant` in + `variable_data.ts`. Links, focus, selection, `accent-color`, and + disabled-active feedback route through them; focus follows the element + color (via `--outline_color`) with the accent as fallback. +- **Cascade layers**: all shipped CSS is layered `fuz.base` < `fuz.theme` < + `fuz.utilities`; consumers' unlayered styles beat everything. + `render_theme_style` loses `specificity` (the `:root:root` hack) and gains + `layer?: string | null` (default `'fuz.theme'`); `generate_theme_css` + loses its specificity parameter; the `theme_specificity` generator option + is removed. The default-theme special case now keys on empty `variables` + rather than the `'base'` name. Custom `base_css` is re-layered into + `fuz.base` in bundled output. +- **`color-mix()` interpolation moved from `in hsl` to `in oklab`** in + button fills/borders, composites, and shadow classes. +- **Themes**: `themes.ts` exports the curated `default_themes` registry + (base, low contrast, high contrast — the contrast themes are rewritten as + curve-knob overrides), one module per theme under `themes/`, plus + unregistered exemplars (necromancer, sunset ember, brutalish, terminal + green + the `create_terminal_theme(hue)` factory). +- **New design-time modules**: `ramps.ts` (fitted knob constants, numeric + evaluators, CSS emitters), `oklch.ts` (OKLCH↔sRGB + gamut math), `wcag.ts` + (luminance/contrast), with tests gating every default stop for gamut, + monotonicity, and contrast. diff --git a/.changeset/theme-knobs-and-checks.md b/.changeset/theme-knobs-and-checks.md index b288ecc18..39ff32431 100644 --- a/.changeset/theme-knobs-and-checks.md +++ b/.changeset/theme-knobs-and-checks.md @@ -4,47 +4,30 @@ feat: add theme scale knobs, the knob catalog, and theme validation/gates/compile -New high-leverage theme knobs, each derived into existing token defaults so -one knob move reshapes a whole family while individual tokens stay pinnable: +New theme knobs, each derived into existing token defaults so one knob move +reshapes a whole family while individual tokens stay pinnable: +`--shadow_alpha_scale` (multiplies the `shadow_alpha_*` ramp, including +button shadows), `--radius_scale` (multiplies the `border_radius_*` tiers), +`--scale_factor` (multiplies the `space_*` scale), `--font_weight` (body), +`--heading_font_weight` (a hook with per-tier fallbacks — setting it +flattens the heading ladder), `--heading_font_family`, and +`--background_image` (decoration hook on `:root`). -- `--shadow_alpha_scale` — multiplies the `shadow_alpha_*` ramp (0 flattens - all shadows including button shadows, which reference the ramp) -- `--radius_scale` — multiplies the `border_radius_*` tiers (0 is sharp, - above 1 is rounder; per-element tiers survive) -- `--scale_factor` — multiplies the `space_*` scale (tight ↔ spacious) -- `--font_weight` — base body font weight (applied on `body`) -- `--heading_font_weight` — a hook with per-tier fallbacks (h1 300 … h5 900); - setting it flattens the heading weight ladder deliberately -- `--heading_font_family` — headings' font family (defaults to - `var(--font_family_serif)`) -- `--background_image` — decoration hook on `:root` for gradient skies, - vignettes, and textures (defaults to `none`) +New `knobs.ts`: `theme_knobs`, a typed catalog of the theme-facing knobs +(`kind`, `axis`, `leverage`, `tier`, `bindable`, ranges), which powers the +inline theme editor on the themes docs page. `variable_data.ts` gains +`palette_glosses`, the letter → color/default-intent display data. -New `knobs.ts` module: `theme_knobs`, a typed catalog of the theme-facing -knobs (`kind`, `axis`, `leverage`, `tier`, `bindable`, ranges, and the -`knob_axes` display order), which powers the inline theme editor on the -themes docs page. `variable_data.ts` gains `palette_glosses`, the letter → -color/default-intent-binding display data. +New `theme_check.ts`, resolving a theme's authored values back to numbers +(literals, `var(--hue_x)` binding chains, compiled-cap overrides): -New `theme_check.ts` module with three functions that resolve a theme's -authored values back to numbers — numeric literals, `var(--hue_x)` binding -chains, and compiled-cap overrides — with knob defaults falling through to -the numeric twin in `ramps.ts`: +- `validate_theme` — structural lint: shape and unknown-name errors, plus + advisory type/range warnings for the knob-tier variables +- `check_theme` — report-only gamut, ramp-monotonicity, and contrast gates + (the same thresholds the repo's tests assert, exported as the `GATE_*` + constants) +- `compile_theme` — recomputes per-stop worst-hue chroma caps from a theme's + own hues, lightness ramp, and hue shift, emits `palette_chroma_NN` + overrides where the baked caps no longer fit, and re-checks the result -- `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 the repo's tests assert for the defaults) against an - arbitrary theme. 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 are exported constants (`GATE_BODY_TEXT`, -`GATE_SUBTLE_TEXT`, `GATE_LINK`, `GATE_UI`, `GATE_FILL_TEXT`). `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. +`ramps.ts` gains `ramp_hue_shift_offset` and `compute_palette_chroma_caps`. diff --git a/.changeset/theme-scheme-stance.md b/.changeset/theme-scheme-stance.md index c4fb83f25..3a546a2f7 100644 --- a/.changeset/theme-scheme-stance.md +++ b/.changeset/theme-scheme-stance.md @@ -5,16 +5,14 @@ feat: add the theme scheme stance `Theme` gains `scheme?: 'dual' | 'light' | 'dark'` (default `'dual'`). A -single-scheme theme renders that appearance in both color schemes: +single-scheme theme renders its one appearance in both color schemes: `render_theme_style` mirrors every scheme-adaptive default the theme doesn't -override (including the `palette_chroma_NN` gamut-cap stops, so the mirrored -scheme gets correct caps) and pins `color-scheme` on the scope so form -controls and native scrollbars agree. The mirror is exported as -`scheme_stance_variables`. A stanced theme's own variables are best authored -single-slot in the light/base position. +override (exported as `scheme_stance_variables`, including the +`palette_chroma_NN` gamut-cap stops) and pins `color-scheme` on the scope so +form controls and native scrollbars agree. Author a stanced theme's own +variables single-slot in the light/base position. -`validate_theme` checks the field and warns on dark slots a single-scheme -stance makes meaningless, and `check_theme`/`compile_theme` resolve -through the same mirror so the gates and cap recomputation evaluate the -stanced reality in both schemes. The necromancer and terminal exemplars use -the stance instead of hand-mirrored ramp knobs. +`validate_theme` warns on dark slots a single-scheme stance makes +meaningless; `check_theme`/`compile_theme` resolve through the same mirror +so the gates evaluate the stanced reality in both schemes. The necromancer +and terminal exemplars use the stance instead of hand-mirrored ramp knobs. diff --git a/.changeset/twelve-shrimps-report.md b/.changeset/twelve-shrimps-report.md deleted file mode 100644 index 54672148e..000000000 --- a/.changeset/twelve-shrimps-report.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -'@fuzdev/fuz_css': minor ---- - -feat: rework the color system to derived OKLCH, add semantic intents, 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`, and semantic - component classes `.color_a`–`.color_j` → `.palette_a`–`.palette_j`. In - compound class families the letter alone implies the palette: - `border_color_X_NN` → `border_X_NN`, `outline_color_X_NN` → `outline_X_NN`, - `shadow_color_X_NN` → `shadow_X_NN`, and `bg_X_NN` keeps its name. The - letterless families are unchanged (`border_color_NN` alpha ramp, - `outline_color_NN` shade outlines, `shadow_color_umbra` semantic colors). - 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/intent 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**: 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 - `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 intent knobs**: `--hue_accent`, `--hue_positive`, - `--hue_negative`, `--hue_caution`, `--hue_info`, each deriving a full - 13-stop scale through the shared ramps (`--accent_00`…`--accent_100`, same - for positive/negative/caution/info) with matching lazily-generated text and - background token classes (`.positive_50`, `.bg_caution_10`), plus the - `--selection_color` site variable and the `intent_variants`/`IntentVariant` - list in `variable_data.ts`. 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. `render_theme_style`'s default-theme special case now keys on - empty `variables` rather than the `'base'` name — a theme that carries - variables always renders them regardless of its name; an empty theme still - renders nothing by default and the full `default_variables` set under - `empty_default_theme: false`. 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/` - (`@fuzdev/fuz_css/themes/necromancer.ts` etc.); `themes.ts` exports the - curated `default_themes` registry (base, low contrast, high contrast) - with unregistered expressive exemplars (necromancer, sunset ember, - brutalish, terminal green + the `create_terminal_theme(hue)` factory). - 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). From a3cc9e2253ce36327a8190b1fb1019dff3259233 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Thu, 16 Jul 2026 12:40:57 -0400 Subject: [PATCH 14/15] add hue shift and remove an unwanted terminal theme --- src/lib/themes/necromancer.ts | 18 +++++++++--------- src/lib/themes/terminal.ts | 21 +++++++++++++++++---- src/routes/KnobControl.svelte | 19 +++++++++---------- src/routes/docs/themes/+page.svelte | 9 +-------- 4 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/lib/themes/necromancer.ts b/src/lib/themes/necromancer.ts index 99dcbdef9..f2fb91eb4 100644 --- a/src/lib/themes/necromancer.ts +++ b/src/lib/themes/necromancer.ts @@ -2,9 +2,9 @@ import type {Theme} from '../theme.ts'; /** * A fantasy exemplar theme built entirely from semantic bindings and levers — - * the palette letters keep their default hues. Violet surfaces come from - * binding the neutral to purple, the ectoplasm-green accent from binding the - * accent intent to green, and the lit-from-below character from hue-shifted + * the palette letters keep their default hues. Ectoplasm-green surfaces come + * from binding the neutral to green, the violet accent from binding the accent + * intent to purple, and the lit-from-below character from hue-shifted * ramps (highlights warm, shadows cool) with glow-colored depth. Dark-only * via the `scheme` stance, vivid past the gamut caps on purpose. * @@ -19,13 +19,13 @@ export const necromancer_theme: Theme = { name: 'necromancer', scheme: 'dark', variables: [ - // grave-violet surfaces and text: the neutral binds to the purple slot - {name: 'hue_neutral', light: 'var(--hue_d)'}, + // ectoplasm-green surfaces and text: the neutral binds to the green slot + {name: 'hue_neutral', light: 'var(--hue_b)'}, {name: 'neutral_chroma', light: '0.04'}, - // ectoplasm accent: links/focus/selection glow green over the violet world - {name: 'hue_accent', light: 'var(--hue_b)'}, - // painterly ramps: shadows cool toward blue-violet, highlights warm toward magenta - {name: 'hue_shift', light: '-14'}, + // violet accent: links/focus/selection glow purple over the green world + {name: 'hue_accent', light: 'var(--hue_d)'}, + // painterly ramps: shadows cool toward teal, highlights warm toward yellow-green + {name: 'hue_shift', light: '14'}, // vivid, knowingly clipping the weak hues {name: 'chroma_scale', light: '1.15'}, // modestly compact - crypt density diff --git a/src/lib/themes/terminal.ts b/src/lib/themes/terminal.ts index 4a75a887b..a963f680c 100644 --- a/src/lib/themes/terminal.ts +++ b/src/lib/themes/terminal.ts @@ -5,13 +5,23 @@ import type {Theme} from '../theme.ts'; * 145, amber at 70, cool blue at 250. Every palette slot and the neutral * collapse onto the one hue (a palette-tier move, which is why terminal * themes live outside the semantic-only registry); expressiveness comes from - * the ramps doing the work. Dark-only via the `scheme` stance, mono type, - * sharp corners, flat depth. + * the ramps doing the work — a slight hue slant blooms the bright end warm and + * the dim end cool, phosphor-style, so the monochrome reads lit rather than + * flat. Dark-only via the `scheme` stance, mono type, sharp corners, no shadows. * * Declared subversions: negative/caution/info all render in the terminal hue * — status legibility is traded for the monochrome premise. + * + * @param hue - the OKLCH hue every slot collapses onto + * @param options - `name` labels the theme; `hue_shift` tunes the phosphor + * bloom, whose ideal warm/cool slant differs by hue, `0` for a dead-flat + * monochrome. */ -export const create_terminal_theme = (hue: number, name = `terminal ${hue}`): Theme => { +export const create_terminal_theme = ( + hue: number, + options: {name?: string; hue_shift?: number} = {}, +): Theme => { + const {name = `terminal ${hue}`, hue_shift = 15} = options; const hue_value = String(hue); return { name, @@ -31,6 +41,9 @@ export const create_terminal_theme = (hue: number, name = `terminal ${hue}`): Th {name: 'hue_neutral', light: hue_value}, // tinted surfaces and text {name: 'neutral_chroma', light: '0.05'}, + // phosphor bloom: the ramps warm the bright end and cool the dim end so + // the monochrome isn't a flat fill, staying unmistakably one hue + {name: 'hue_shift', light: String(hue_shift)}, // mono type everywhere {name: 'font_family_sans', light: 'var(--font_family_mono)'}, // sharp: one knob zeroes every radius tier @@ -44,4 +57,4 @@ export const create_terminal_theme = (hue: number, name = `terminal ${hue}`): Th /** * The classic green phosphor terminal. */ -export const terminal_green_theme: Theme = create_terminal_theme(145, 'terminal green'); +export const terminal_green_theme: Theme = create_terminal_theme(145, {name: 'terminal green'}); diff --git a/src/routes/KnobControl.svelte b/src/routes/KnobControl.svelte index 4ed7cbef2..d5675626c 100644 --- a/src/routes/KnobControl.svelte +++ b/src/routes/KnobControl.svelte @@ -58,6 +58,9 @@ }; const numeric_value = $derived(resolve_numeric(value)); + // a live swatch of the current angle for the detach ("custom") button, so it + // tracks the hue the way the letter buttons track their palette slot + const custom_color = $derived(`oklch(0.65 0.14 ${numeric_value ?? 0})`); const scalar = $derived( knob.kind === 'hue' || knob.kind === 'number' || @@ -90,19 +93,22 @@ {#each palette_variants as letter (letter)} {/each} @@ -208,11 +214,4 @@ font-size: var(--font_size_sm); font-family: var(--font_family_mono); } - .letter_swatch { - display: inline-block; - width: 0.8em; - height: 0.8em; - margin-right: var(--space_xs2); - border-radius: var(--border_radius_xs2); - } diff --git a/src/routes/docs/themes/+page.svelte b/src/routes/docs/themes/+page.svelte index a40bc81e6..60a890720 100644 --- a/src/routes/docs/themes/+page.svelte +++ b/src/routes/docs/themes/+page.svelte @@ -15,7 +15,7 @@ import {necromancer_theme} from '$lib/themes/necromancer.ts'; import {sunset_ember_theme} from '$lib/themes/sunset_ember.ts'; import {brutalish_theme} from '$lib/themes/brutalish.ts'; - import {create_terminal_theme, terminal_green_theme} from '$lib/themes/terminal.ts'; + import {terminal_green_theme} from '$lib/themes/terminal.ts'; import type {Theme} from '$lib/theme.ts'; import UnfinishedImplementationWarning from '$routes/docs/UnfinishedImplementationWarning.svelte'; import ThemeEditor from '$routes/ThemeEditor.svelte'; @@ -34,18 +34,11 @@ const theme_state = get_theme_state(); const themes = default_themes.slice(); - // amber runs modestly denser than green phosphor - plain data composition - const terminal_amber_base = create_terminal_theme(70, 'terminal amber'); - const terminal_amber_theme: Theme = { - ...terminal_amber_base, - variables: [...terminal_amber_base.variables, {name: 'scale_factor', light: '0.9'}], - }; const exemplar_themes = [ necromancer_theme, sunset_ember_theme, brutalish_theme, terminal_green_theme, - terminal_amber_theme, ]; const editor = new ThemeEditorState([...themes, ...exemplar_themes]); From 9263f076e8b23c64564a2236b886279d1b42d626 Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Thu, 16 Jul 2026 13:08:09 -0400 Subject: [PATCH 15/15] wip --- CLAUDE.md | 6 ++-- src/lib/themes/terminal.ts | 51 +++++++++++++++++++++-------- src/routes/docs/themes/+page.svelte | 9 ++--- src/test/theme_check.test.ts | 10 +++--- src/test/themes.test.ts | 2 +- 5 files changed, 50 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 15c0a3739..08b78c7b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -353,8 +353,10 @@ typography, borders, shading, shadows, layout. See - `src/lib/themes/` - One module per theme. The registry (base, low/high contrast) is semantic-tier: intent bindings + levers only, palette hues untouched. Unregistered exemplars: necromancer, sunset ember, brutalish, - and `terminal.ts` (a `create_terminal_theme(hue)` factory; terminal green - = 145); necromancer and terminal are dark-only via `scheme: 'dark'` + and `terminal.ts` (the `terminal_theme` flagship — dark mono terminal chrome + with a green cast but functional palette — plus a `create_terminal_theme(hue)` + factory for pure single-phosphor monochrome terminals); necromancer and + terminal are dark-only via `scheme: 'dark'` - [knobs.ts](src/lib/knobs.ts) - The theme knob catalog: typed metadata (kind/axis/leverage/tier/bindable/range) for the knob-tier variables, joined against `default_variables` by name; includes hook knobs like diff --git a/src/lib/themes/terminal.ts b/src/lib/themes/terminal.ts index a963f680c..ee34fef86 100644 --- a/src/lib/themes/terminal.ts +++ b/src/lib/themes/terminal.ts @@ -1,13 +1,43 @@ import type {Theme} from '../theme.ts'; /** - * Creates a monochrome terminal theme at any OKLCH hue — green phosphor at - * 145, amber at 70, cool blue at 250. Every palette slot and the neutral - * collapse onto the one hue (a palette-tier move, which is why terminal - * themes live outside the semantic-only registry); expressiveness comes from - * the ramps doing the work — a slight hue slant blooms the bright end warm and - * the dim end cool, phosphor-style, so the monochrome reads lit rather than - * flat. Dark-only via the `scheme` stance, mono type, sharp corners, no shadows. + * A practical terminal theme: the dark, monospace, sharp-cornered, flat-depth + * terminal chrome with a green-phosphor cast in the surfaces and accent, but + * the palette slots keep their own hues so status colors still read — negative + * stays red, caution amber, info cyan. The usable twist on the pure + * single-phosphor collapse of `create_terminal_theme`. Dark-only via the + * `scheme` stance. + */ +export const terminal_theme: Theme = { + name: 'terminal', + scheme: 'dark', + variables: [ + // green-phosphor surfaces and text — the terminal cast, kept low-chroma so + // the palette colors read over it + {name: 'hue_neutral', light: 'var(--hue_b)'}, + {name: 'neutral_chroma', light: '0.05'}, + // links, focus, selection glow phosphor green + {name: 'hue_accent', light: 'var(--hue_b)'}, + // a whisper of phosphor bloom across every ramp + {name: 'hue_shift', light: '12'}, + // mono type everywhere + {name: 'font_family_sans', light: 'var(--font_family_mono)'}, + // sharp: one knob zeroes every radius tier + {name: 'radius_scale', light: '0'}, + // flat: one knob zeroes the whole alpha ramp, button shadows included + {name: 'shadow_alpha_scale', light: '0'}, + ], +}; + +/** + * Creates a pure single-phosphor terminal at any OKLCH hue — green at 145, + * amber at 70, cool blue at 250. Every palette slot and the neutral collapse + * onto the one hue (a palette-tier move, which is why terminal themes live + * outside the semantic-only registry); expressiveness comes from the ramps + * doing the work — a hue slant blooms the bright end warm and the dim end cool, + * phosphor-style, so the monochrome reads lit rather than flat. The strict + * monochrome counterpart to `terminal_theme`. Dark-only via the `scheme` + * stance, mono type, sharp corners, no shadows. * * Declared subversions: negative/caution/info all render in the terminal hue * — status legibility is traded for the monochrome premise. @@ -21,7 +51,7 @@ export const create_terminal_theme = ( hue: number, options: {name?: string; hue_shift?: number} = {}, ): Theme => { - const {name = `terminal ${hue}`, hue_shift = 15} = options; + const {name = `terminal ${hue}`, hue_shift = 45} = options; const hue_value = String(hue); return { name, @@ -53,8 +83,3 @@ export const create_terminal_theme = ( ], }; }; - -/** - * The classic green phosphor terminal. - */ -export const terminal_green_theme: Theme = create_terminal_theme(145, {name: 'terminal green'}); diff --git a/src/routes/docs/themes/+page.svelte b/src/routes/docs/themes/+page.svelte index 60a890720..58dd0d64c 100644 --- a/src/routes/docs/themes/+page.svelte +++ b/src/routes/docs/themes/+page.svelte @@ -15,7 +15,7 @@ import {necromancer_theme} from '$lib/themes/necromancer.ts'; import {sunset_ember_theme} from '$lib/themes/sunset_ember.ts'; import {brutalish_theme} from '$lib/themes/brutalish.ts'; - import {terminal_green_theme} from '$lib/themes/terminal.ts'; + import {terminal_theme} from '$lib/themes/terminal.ts'; import type {Theme} from '$lib/theme.ts'; import UnfinishedImplementationWarning from '$routes/docs/UnfinishedImplementationWarning.svelte'; import ThemeEditor from '$routes/ThemeEditor.svelte'; @@ -34,12 +34,7 @@ const theme_state = get_theme_state(); const themes = default_themes.slice(); - const exemplar_themes = [ - necromancer_theme, - sunset_ember_theme, - brutalish_theme, - terminal_green_theme, - ]; + const exemplar_themes = [necromancer_theme, sunset_ember_theme, brutalish_theme, terminal_theme]; const editor = new ThemeEditorState([...themes, ...exemplar_themes]); diff --git a/src/test/theme_check.test.ts b/src/test/theme_check.test.ts index 4a2b0428e..e0f0c77e4 100644 --- a/src/test/theme_check.test.ts +++ b/src/test/theme_check.test.ts @@ -8,7 +8,7 @@ import {high_contrast_theme} from '$lib/themes/high_contrast.ts'; import {necromancer_theme} from '$lib/themes/necromancer.ts'; import {sunset_ember_theme} from '$lib/themes/sunset_ember.ts'; import {brutalish_theme} from '$lib/themes/brutalish.ts'; -import {terminal_green_theme, create_terminal_theme} from '$lib/themes/terminal.ts'; +import {terminal_theme, create_terminal_theme} from '$lib/themes/terminal.ts'; import { PALETTE_HUES, PALETTE_CHROMA_KNOBS, @@ -28,7 +28,7 @@ describe('validate_theme', () => { necromancer_theme, sunset_ember_theme, brutalish_theme, - terminal_green_theme, + terminal_theme, create_terminal_theme(70), // amber, exercises the factory ]; for (const theme of themes) { @@ -213,8 +213,8 @@ describe('check_theme', () => { assert.isTrue(check_theme(brutalish_theme).ok); }); - test('terminal green keeps its contrast gates', () => { - const contrast = check_theme(terminal_green_theme).entries.filter((e) => e.gate === 'contrast'); + test('terminal keeps its contrast gates', () => { + const contrast = check_theme(terminal_theme).entries.filter((e) => e.gate === 'contrast'); assert.isTrue(contrast.every((e) => e.pass)); }); @@ -287,7 +287,7 @@ describe('scheme stance', () => { }); test('the dark-stanced exemplars pass their contrast gates in both schemes', () => { - for (const theme of [necromancer_theme, terminal_green_theme]) { + for (const theme of [necromancer_theme, terminal_theme]) { const contrast = check_theme(theme).entries.filter((e) => e.gate === 'contrast'); assert.isTrue( contrast.every((e) => e.pass), diff --git a/src/test/themes.test.ts b/src/test/themes.test.ts index 2b96194c6..5e18f5839 100644 --- a/src/test/themes.test.ts +++ b/src/test/themes.test.ts @@ -100,7 +100,7 @@ describe('shipped themes', () => { assert.include(names, 'necromancer'); assert.include(names, 'sunset ember'); assert.include(names, 'brutalish'); - assert.include(names, 'terminal green'); + assert.include(names, 'terminal'); }); test('all exemplar variables validate and exist in default_variables', () => {