diff --git a/.changeset/dev-initial-load-prescan.md b/.changeset/dev-initial-load-prescan.md new file mode 100644 index 000000000..4be74a2fb --- /dev/null +++ b/.changeset/dev-initial-load-prescan.md @@ -0,0 +1,16 @@ +--- +'@fuzdev/fuz_css': patch +--- + +fix: complete utility CSS on the first dev page load + +The Vite plugin now eagerly pre-scans project sources at dev-server startup +(new `prescan` option: `true` scans `src` under the Vite root, `false` +disables, or an array of directories) and resyncs clients whose HMR socket +connects after a missed CSS update. Previously the first cold-start page +load could render with incomplete utility classes until a manual refresh: +extraction state accumulated only from modules Vite had transformed so far, +and the corrective HMR update was dropped when the browser hadn't connected +yet. 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. diff --git a/.changeset/micro-surface-hooks.md b/.changeset/micro-surface-hooks.md new file mode 100644 index 000000000..82ef0e94a --- /dev/null +++ b/.changeset/micro-surface-hooks.md @@ -0,0 +1,26 @@ +--- +'@fuzdev/fuz_css': minor +--- + +feat: theme scrollbars, caret, dialog backdrop, and the OS contrast preference + +New micro-surface defaults in `style.css`, each themable through a `var()` +fallback hook (registered in the knob catalog so `validate_theme` accepts +them): + +- `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` +- `@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 + +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. diff --git a/.changeset/theme-knobs-and-checks.md b/.changeset/theme-knobs-and-checks.md new file mode 100644 index 000000000..b288ecc18 --- /dev/null +++ b/.changeset/theme-knobs-and-checks.md @@ -0,0 +1,50 @@ +--- +'@fuzdev/fuz_css': minor +--- + +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: + +- `--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` 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` 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` — 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. diff --git a/.changeset/theme-scheme-stance.md b/.changeset/theme-scheme-stance.md new file mode 100644 index 000000000..bd798db08 --- /dev/null +++ b/.changeset/theme-scheme-stance.md @@ -0,0 +1,19 @@ +--- +'@fuzdev/fuz_css': minor +--- + +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: +`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. + +`validate_theme` checks the field, 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. diff --git a/.changeset/twelve-shrimps-report.md b/.changeset/twelve-shrimps-report.md new file mode 100644 index 000000000..54672148e --- /dev/null +++ b/.changeset/twelve-shrimps-report.md @@ -0,0 +1,72 @@ +--- +'@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). diff --git a/CLAUDE.md b/CLAUDE.md index c6948b9c9..7b9a746fb 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/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 new file mode 100644 index 000000000..ee2ff7abb --- /dev/null +++ b/src/routes/ThemeEditor.svelte @@ -0,0 +1,281 @@ + + +{#snippet knob_control(knob: ThemeKnob, compact: boolean)} + editor.set_value(knob.name, value, editing_scheme)} + onreset={() => editor.reset(knob.name)} + /> +{/snippet} + +
+
+
+ + +
+ {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 +
+
+ + +
+
+ intents - what each meaning points at +
+ {#each binding_knobs as knob (knob.name)} + {@render knob_control(knob, true)} + {/each} +
+
+
+ levers - the highest-leverage knobs +
+ {#each lever_knobs as knob (knob.name)} + {@render knob_control(knob, true)} + {/each} +
+
+
+ + {#each knob_axes as { axis, title } (axis)} + {@const inline_knobs = semantic_knobs(axis).filter((k) => k.leverage !== 'sm')} + {@const sm_knobs = semantic_knobs(axis).filter((k) => k.leverage === 'sm')} + {#if inline_knobs.length || sm_knobs.length} +
+

{title}

+ {#if axis === 'color'} + +
+ + + {#each intent_variants as intent (intent)} + + {/each} +
+ {/if} + {#if inline_knobs.length} +
+ {#each inline_knobs as knob (knob.name)} + {@render knob_control(knob, false)} + {/each} +
+ {/if} + {#if sm_knobs.length} +
+ {sm_details_titles[axis] ?? 'granular tokens'} + (escape hatches - pin individual tokens) +
+ {#each sm_knobs as knob (knob.name)} + {@render knob_control(knob, false)} + {/each} +
+
+ {/if} + {#if axis === 'color'} +
+ palette hues (the letter slots - moving these makes the theme palette-tier) +
+ {#each palette_variants as letter (letter)} + + {/each} +
+
+ {#each palette_knobs as knob (knob.name)} + {@render knob_control(knob, false)} + {/each} +
+
+ {/if} +
+ {/if} + {/each} + +
+

Output

+

+ The copyable Theme object, and the CSS it renders — the theme's own footprint. +

+
+
+ +
+ +
+ {#if editor.output.variables.length} +
+
+ +
+ +
+ {:else} +

+ The theme is empty -- every variable keeps its base default, so it renders no CSS. Move a + knob to see its output. +

+ {/if} +
+
+ + diff --git a/src/routes/ThemeForm.svelte b/src/routes/ThemeForm.svelte deleted file mode 100644 index 0f56da08c..000000000 --- a/src/routes/ThemeForm.svelte +++ /dev/null @@ -1,162 +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/borders/+page.svelte b/src/routes/docs/borders/+page.svelte index b3773490f..fea96a2eb 100644 --- a/src/routes/docs/borders/+page.svelte +++ b/src/routes/docs/borders/+page.svelte @@ -10,7 +10,7 @@ import { border_width_variants, - color_variants, + palette_variants, outline_width_variants, border_radius_variants, alpha_variants, @@ -46,10 +46,6 @@ let selected_intensity: IntensityVariant = $state.raw('50'); - // @fuz-classes outline_width_focus outline_width_active - // @fuz-classes border_color_00 border_color_05 border_color_10 border_color_20 border_color_30 border_color_40 border_color_50 border_color_60 border_color_70 border_color_80 border_color_90 border_color_95 border_color_100 - // @fuz-classes color_a_50 color_b_50 color_c_50 color_d_50 color_e_50 color_f_50 color_g_50 color_h_50 color_i_50 color_j_50 - // TODO @many smoother gradient? esp on the low end? for both shadows and borders @@ -73,7 +69,7 @@

The border_color_NN variables provide tinted alpha borders that integrate with - the theme. They use tint_hue for cohesion. + the theme. They use hue_neutral for cohesion.

{#each alpha_variants as v (v)} @@ -124,8 +120,8 @@ border-color: var(--shade_30);

- Use color variables like color_a_{selected_intensity} for colored borders. The - intensity controls the color's prominence. + Use palette variables like palette_a_{selected_intensity} for colored borders. + The intensity controls the color's prominence.

@@ -145,9 +141,9 @@ border-color: var(--shade_30);
- {#each color_variants as color_variant (color_variant)} - {@const name = 'color_' + color_variant + '_' + selected_intensity} - {@const text_class = 'color_' + color_variant + '_50'} + {#each palette_variants as letter (letter)} + {@const name = 'palette_' + letter + '_' + selected_intensity} + {@const text_class = 'palette_' + letter + '_50'}

Each border utility class has a corresponding outline variant using the same border variables - (like outline_color_b_50, outline_width_4, and + (like outline_b_50, outline_width_4, and outline-style:solid), and there are also two special outline variables:

diff --git a/src/routes/docs/buttons/+page.svelte b/src/routes/docs/buttons/+page.svelte index 9359087f2..f38b89b01 100644 --- a/src/routes/docs/buttons/+page.svelte +++ b/src/routes/docs/buttons/+page.svelte @@ -8,7 +8,7 @@ import TomeSection from '@fuzdev/fuz_ui/TomeSection.svelte'; import TomeLink from '@fuzdev/fuz_ui/TomeLink.svelte'; - import {color_variants} from '$lib/variable_data.ts'; + import {palette_variants} from '$lib/variable_data.ts'; const LIBRARY_ITEM_NAME = 'buttons'; @@ -30,8 +30,8 @@

The <button> element is styled by default without adding classes. Classes - like .selected and .plain and .color_a modify the base - style. + like .selected and .plain and .palette_a modify the + base style.

Buttons have a .selected state that can be used for various UI purposes, like @@ -70,28 +70,30 @@ - {#each color_variants as c (c)} - {@const color_name = `color_${c}`} + {#each palette_variants as c (c)} + {@const palette_name = `palette_${c}`}

- `} /> - - `} /> + + - - .{palette_name}.plain - .{palette_name}.plain:disabled - .{palette_name}.selected - .{palette_name}.selected:disabled - .{palette_name}.selected.deselectable +
{/each} @@ -258,7 +260,7 @@
- +
diff --git a/src/routes/docs/chips/+page.svelte b/src/routes/docs/chips/+page.svelte index 4d6a48879..fb4a11ead 100644 --- a/src/routes/docs/chips/+page.svelte +++ b/src/routes/docs/chips/+page.svelte @@ -7,7 +7,7 @@ import TomeSection from '@fuzdev/fuz_ui/TomeSection.svelte'; import TomeLink from '@fuzdev/fuz_ui/TomeLink.svelte'; - import {color_variants} from '$lib/variable_data.ts'; + import {palette_variants} from '$lib/variable_data.ts'; const LIBRARY_ITEM_NAME = 'chips'; @@ -26,8 +26,8 @@ with <span> and <a>.

- Chips have color variants (.color_a through .color_j) that tint both - the text and background. Links (a.chip) have slightly bolder text. + Chips have color variants (.palette_a through .palette_j) that tint + both the text and background. Links (a.chip) have slightly bolder text.

a chip`} /> @@ -48,13 +48,13 @@ - {#each color_variants as c (c)} - {@const color_name = `color_${c}`} + {#each palette_variants as c (c)} + {@const palette_name = `palette_${c}`}
- `} /> - .chip.{color_name} + `} /> + .chip.{palette_name} - a.chip.{color_name} + a.chip.{palette_name}
{/each}
@@ -83,9 +83,9 @@ ...
`} />
one - two + two - three + three
diff --git a/src/routes/docs/classes/+page.svelte b/src/routes/docs/classes/+page.svelte index f442fbadf..179557c46 100644 --- a/src/routes/docs/classes/+page.svelte +++ b/src/routes/docs/classes/+page.svelte @@ -7,9 +7,11 @@ import TomeLink from '@fuzdev/fuz_ui/TomeLink.svelte'; import DeclarationLink from '@fuzdev/fuz_ui/DeclarationLink.svelte'; import ModuleLink from '@fuzdev/fuz_ui/ModuleLink.svelte'; + import MdnLink from '@fuzdev/fuz_ui/MdnLink.svelte'; import { space_variants, - color_variants, + palette_variants, + intent_variants, intensity_variants, shade_variants, text_variants, @@ -320,7 +322,7 @@ import './fuz.css';`}
-`} +`} />

Usage tracking works for variables inside clsx(), arrays, ternaries, and @@ -367,7 +369,7 @@ const turtle_class_name = 'turtle';`} content={`// @fuz-classes opacity:50% opacity:75% opacity:100% const opacity_classes = [50, 75, 100].map((n) => \`opacity:\${n}%\`); -/* @fuz-classes color_a_50 color_b_50 color_c_50 */ +/* @fuz-classes palette_a_50 palette_b_50 palette_c_50 */ const color = get_dynamic_color();`} />

@@ -398,7 +400,7 @@ const color = get_dynamic_color();`} Classes annotated with @fuz-classes and configured with additional_classes produce errors if they can't be resolved. This helps catch typos like - @fuz-classes color_a_55 instead of color_a_50. + @fuz-classes palette_a_55 instead of palette_a_50.

Alternatively, use the used utility classes (always generated on demand; there's no full version)

- Layers are emitted in that order, so variable definitions precede their use and utilities land - last to win over the low-specificity :where() reset. + Sections are emitted in that order inside cascade layers: + defaults in fuz.base, theme overrides in fuz.theme, and utilities in + fuz.utilities, so utilities beat the reset by layer order (not specificity) and + your own unlayered styles beat everything.

Base styles

@@ -598,8 +602,8 @@ vite_plugin_fuz_css({ use literal classes instead.

- -

some token classes

+ +

some token classes

Token classes use snake_case because style variables are designed for optional use in JS (imported from , but costing nothing @@ -763,7 +767,7 @@ vite_plugin_fuz_css({

  • .color_{@render variant_range(color_variants)}_{@render variant_range( + >.palette_{@render variant_range(palette_variants)}_{@render variant_range( intensity_variants, )} @@ -772,15 +776,37 @@ vite_plugin_fuz_css({
  • .bg_{@render variant_range(color_variants)}_{@render variant_range( + >.bg_{@render variant_range(palette_variants)}_{@render variant_range( intensity_variants, )}
  • +
  • + {#each intent_variants as intent (intent)}.{intent}_{@render variant_range(intensity_variants)}{/each} + +
  • +
  • + {#each intent_variants as intent (intent)}.bg_{intent}_{@render variant_range(intensity_variants)}{/each} + +
  • + @@ -800,7 +826,8 @@ vite_plugin_fuz_css({
  • - .hue_{@render variant_range(color_variants)} + .hue_{@render variant_range(palette_variants)}
  • .border_color_{@render variant_range(color_variants)}_{@render variant_range( + >.border_{@render variant_range(palette_variants)}_{@render variant_range( intensity_variants, )} @@ -908,7 +935,7 @@ vite_plugin_fuz_css({
  • .outline_color_{@render variant_range(color_variants)}_{@render variant_range( + >.outline_{@render variant_range(palette_variants)}_{@render variant_range( intensity_variants, )} @@ -957,7 +984,7 @@ vite_plugin_fuz_css({
  • .shadow_color_{@render variant_range(color_variants)}_{@render variant_range( + >.shadow_{@render variant_range(palette_variants)}_{@render variant_range( intensity_variants, )} @@ -1065,7 +1092,7 @@ export const custom_composites: Record = {

    • - token classes (p_lg, color_a_50) - resolved to their + token classes (p_lg, palette_a_50) - resolved to their declarations
    • @@ -1475,7 +1502,7 @@ export const gen = gen_fuz_css({
    • .title - see
    • .row - see ,
    • - .color_a through .color_j - see , + .palette_a through .palette_j - see ,
    • @@ -1573,8 +1600,8 @@ export const gen = gen_fuz_css({
    • expressions: logical (&&, ||, ??), ternaries, template literals (complete tokens only -- - `color_a_50 ${base}` extracts color_a_50, but - `color_${hue}_50` cannot be extracted; use + `palette_a_50 ${base}` extracts palette_a_50, but + `palette_${hue}_50` cannot be extracted; use @fuz-classes or additional_classes) diff --git a/src/routes/docs/colors/+page.svelte b/src/routes/docs/colors/+page.svelte index b342ce5ce..93be8bc30 100644 --- a/src/routes/docs/colors/+page.svelte +++ b/src/routes/docs/colors/+page.svelte @@ -6,10 +6,11 @@ import ColorSchemeInput from '@fuzdev/fuz_ui/ColorSchemeInput.svelte'; import TomeSectionHeader from '@fuzdev/fuz_ui/TomeSectionHeader.svelte'; import TomeSection from '@fuzdev/fuz_ui/TomeSection.svelte'; + import Code from '@fuzdev/fuz_code/Code.svelte'; import HueSwatch from './HueSwatch.svelte'; import ColorSwatch from './ColorSwatch.svelte'; - import {color_variants} from '$lib/variable_data.ts'; + import {palette_variants, palette_glosses, type PaletteVariant} from '$lib/variable_data.ts'; const LIBRARY_ITEM_NAME = 'colors'; @@ -20,57 +21,104 @@ // TODO button to add an inline hue input for runtime modification of the theme - // TODO maybe add this to the variable data as comments - // Note: This array must stay in sync with color_variants (a-j = 10 elements) - const descriptions = [ - 'primary', - 'success', - 'error/danger', - 'accent/secondary', - 'highlight/tertiary', - 'muted/quaternary', - 'decorative/quinary', - 'caution/senary', - 'info/septenary', - 'flourish/octonary', - ]; + // letter glosses: color name plus the default intent binding where one exists + const describe_letter = (letter: PaletteVariant): string => { + const gloss = palette_glosses[letter]; + return gloss.binding ? `${gloss.color} · default ${gloss.binding}` : gloss.color; + };

      - fuz_css provides color that adapt to the - , working naturally in both light and dark modes. Each - theme can customize the 10 hues (a-j) and their intensity - variants (00-100). + fuz_css's colors are derived: around a dozen knobs produce every color in pure CSS, in the + colorspace, adapting to the + automatically. OKLCH lightness is perceptually uniform + -- equal lightness reads equally light in every hue -- so rotating a hue knob is safe: + contrast and visual weight hold, which is what makes themes a small set of knob values instead of hundreds of hand-tuned stops.

      Hues use letters so themes can reassign colors without breaking semantics -- "a" is blue by - default but could be any color. Each hue has 13 intensity variants tuned independently for - visual balance across color schemes. + default but could be any color. Meaning attaches through the intent knobs layered on top: + --hue_accent (links, focus, selection, selected states -- what other systems call + the "primary" color, named here for what it communicates) defaults to + --hue_a, --hue_negative to --hue_c, and so on. Retarget + an intent to move just that meaning; rotate a letter to move the palette.

      - + +

      The knobs are the theme API, from highest leverage down:

      +
        +
      • + --hue_a--hue_j -- OKLCH hue angles for the 10 palette slots +
      • +
      • + --hue_neutral + --neutral_chroma -- the temperature and strength + of every surface, text, border, and shadow tint (the neutral intent; its scales are the + shade and text ramps) +
      • +
      • + --chroma_scale -- one multiplier from grayscale (0) through calm (1) to vivid + (above 1, knowingly clipping the weakest hues) +
      • +
      • + --hue_shift -- degrees of hue rotation across each ramp for painterly + warm-light/cool-shadow character (default 0) +
      • +
      • + intent hues -- --hue_accent, --hue_positive, + --hue_negative, --hue_caution, --hue_info -- each + deriving a full 13-stop scale (--accent_00--accent_100) with + matching text and background classes (.positive_50, + .bg_caution_10) +
      • +
      • + lightness ramps -- --palette_lightness_00/_100/_curve + (and the same trio for shade_ and text_): the endpoint stops plus + a curve exponent bending the ramp between them, per color scheme +
      • +
      • + chroma curve -- --palette_chroma_min/_max/_curve: a + mid-peaked curve, clamped per stop by gamut caps computed from the worst hue +
      • +

      - Hue variables contain a single number. Each color variable - combines a hue variable with saturation and lightness values for light and dark modes. + Every intermediate value these produce is also its own variable (--palette_lightness_30, --palette_chroma_50, …), so a theme can pin any individual stop as a surgical + escape hatch -- but the knobs come first.

      + +
      + +

      - Hue variables therefore provide a single source of truth that's easy to theme, but to achieve - pleasing results, setting the hue alone is not always sufficient. Custom colors generally need - tuning for saturation and lightness. + Hue variables contain a single OKLCH angle. Because lightness + and chroma are shared across all hues at each stop, the scales are interchangeable -- setting + a hue alone is enough, no per-hue tuning required.

      - Hue variables are also useful to construct custom colors not covered by the color variables. - For example, fuz_css's base stylesheet uses hue_a for the semi-transparent - ::selection. (try selecting some text -- - same hue!) + Hue variables are also useful to construct custom colors not covered by the palette. For + example, fuz_css's selection color derives from --hue_accent (try selecting some + text -- + same hue!)

      Hue variables are the same in both light and dark modes (non-adaptive).

        - {#each color_variants as color_name, i (color_name)} - + {#each palette_variants as letter (letter)} + {/each}
      @@ -78,7 +126,7 @@
  • - +

    There are 13 intensity variants per hue (00, 05, 10, 20, ..., 80, 90, 95, 100), from subtle to bold. The 50 variant of each color is used as the base for things like @@ -87,58 +135,20 @@

    Unlike the shade and text scales (which are separate), - color variables can be used for both text and backgrounds via utility classes: - .color_a_50 sets text color, .bg_a_50 sets background color. + palette variables can be used for both text and backgrounds via utility classes: + .palette_a_50 sets text color, .bg_a_50 sets background color.

    -

    Each color exists in two forms:

    -
      -
    • - Adaptive (color_a_50) -- switches between light and dark - values based on color scheme. Use for most UI work. -
    • -
    • - Absolute (color_a_50_light, color_a_50_dark) -- - stable values that never change. Use when you need a pinned color. -
    • +

      + Palette stops are adaptive: they switch between light and dark ramps based on color scheme. + For a color that doesn't adapt, write the literal color or define one custom property -- the + old generated absolute variants (color_a_50_light-style) were removed with the + OKLCH migration. +

      +
        + {#each palette_variants as letter (letter)} + + {/each}
      - - -

      - The colors you'll use most often. They automatically adjust to maintain visual consistency - across color schemes. Note that these values differ between light and dark modes! See the - discussion above for why. -

      -
        - {#each color_variants as color_name (color_name)} - - {/each} -
      -
      -
      - -
      - - -

      - Sometimes you need a color that doesn't adapt, like logos, charts, color-coded - data, or elements that must match across screenshots. Every adaptive color has two absolute - variants: -

      -
        -
      • color_a_50_light - the value used in light mode
      • -
      • color_a_50_dark - the value used in dark mode
      • -
      -

      - These are stable regardless of color scheme. Light and dark variants are tuned independently - for visual balance -- achieving equivalent appearance across color schemes requires - different saturation and lightness values. -

      -
        - {#each color_variants as color_name (color_name)} - - {/each} -
      -
      diff --git a/src/routes/docs/colors/ColorSwatch.svelte b/src/routes/docs/colors/ColorSwatch.svelte index 478cd1481..4e4242f1f 100644 --- a/src/routes/docs/colors/ColorSwatch.svelte +++ b/src/routes/docs/colors/ColorSwatch.svelte @@ -1,38 +1,19 @@ -{#if absolute} -
        - {#each intensity_variants as intensity (intensity)} - - {/each} -
      -
        - {#each intensity_variants as intensity (intensity)} - - {/each} -
      -{:else} -
        - {#each intensity_variants as intensity (intensity)} - - {/each} -
      -{/if} +
        + {#each intensity_variants as intensity (intensity)} + + {/each} +