From 59d46700bc34a49c369a270cfc4d941520763698 Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Wed, 11 Mar 2026 12:04:33 +0100 Subject: [PATCH] refactor(theme): switch color generation from HSL to OKLCH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OKLCH is a perceptually uniform color space — equal lightness values produce equal perceived brightness across all hues, unlike HSL where blue at 50% looks much darker than yellow at 50%. Color scales now output oklch() CSS values directly: --gray-500: oklch(0.530 0.035 285); --accent-500: oklch(0.595 0.230 286); The browser handles gamut mapping natively. Scale definitions in tokens.edn use [label lightness chroma] tuples where L is 0-1 perceptual lightness, C is chroma (colorfulness), H is hue degrees. Theme adapter updated: sliders now control OKLCH hue/chroma, swatches render with oklch() CSS, Copy EDN outputs OKLCH config. gen.clj includes oklch->srgb and oklch->hex for validation/tools. --- AGENTS.md | 24 ++++---- README.md | 54 ++++++++--------- dev/theme-adapter.js | 109 ++++++++++++++++++---------------- src/theme/tokens.edn | 131 +++++++++++++++++++++-------------------- src/ui/css/gen.clj | 56 +++++++++++++++--- test/ui/theme_test.clj | 23 +++++--- 6 files changed, 222 insertions(+), 175 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7966b98..3e054f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -254,28 +254,28 @@ Produces `--size-1: 0.25rem` through `--size-16: 4rem`. Use for all spacing, pad Produces `--font-xs: 0.64rem` through `--font-3xl: 3.052rem`. Use for all font-size values. -**Color scales** — HSL-based, generated via `jon.color-tools`: +**Color scales** — OKLCH-based for perceptual uniformity, via `jon.color-tools`: ```edn -:color {:gray {:hue 240 :saturation 18 - :steps [[50 97 14] [100 95 14] ... [950 5 18]]}} +:color {:gray {:hue 285 :chroma 0.025 + :steps [[50 0.975 0.003] [100 0.955 0.005] ... [950 0.145 0.011]]}} ``` -Each step is `[label lightness]` (uses default saturation) or `[label lightness saturation]` (per-step override). Produces `--gray-50: #hex` through `--gray-950: #hex`. +Each step is `[label lightness]` (uses default chroma) or `[label lightness chroma]` (per-step override). OKLCH ensures equal lightness steps = equal perceived brightness across hues. Produces `--gray-50: oklch(0.975 0.003 285)` through `--gray-950: oklch(...)`. Available color scales: `gray`, `accent`, `danger`, `success`, `warning`. Each generates 11 stops (50, 100, 200–900, 950). **To change the gray tone** (e.g. warm gray, cool blue-gray, purplish), change `hue`: -- `240` → purplish gray (current, inspired by activity-tracker) -- `220` → blue-gray -- `0` → warm gray (pinkish) -- `0` with saturation `0` → pure neutral gray +- `285` → purplish gray (current, inspired by activity-tracker) +- `255` → blue-gray +- `60` → warm/sandy gray +- any hue with chroma `0` → pure neutral gray **To change the accent color**, change `hue` in the accent scale: -- `252` → purple (current, matches activity-tracker) -- `220` → blue -- `142` → green -- `0` → red +- `286` → purple (current, matches activity-tracker) +- `255` → blue +- `165` → green +- `25` → red Semantic tokens reference scale variables: `var(--gray-50)`, `var(--accent-500)`, etc. Dark theme overrides switch which stop is used (e.g. `bg-0` goes from `gray-50` → `gray-950`). diff --git a/README.md b/README.md index 887f15e..03f1ce7 100644 --- a/README.md +++ b/README.md @@ -27,37 +27,31 @@ Each component is a `.cljc` file in `src/ui/` with a matching `.css` file. The C ## Color System -Colors are generated from HSL parameters defined in `src/theme/tokens.edn`, using [jon/color-tools](https://github.com/jramosg/color-tools) for conversion. Instead of picking individual hex values, you define a hue and saturation, and the generator produces an 11-stop scale (50–950) for each color. +Colors are generated from OKLCH parameters defined in `src/theme/tokens.edn`. OKLCH is a perceptually uniform color space — equal lightness steps produce equal perceived brightness across all hues, unlike HSL. You define a hue and chroma, and the generator produces an 11-stop scale (50–950) for each color, output as `oklch()` CSS values. Five scales ship by default: `gray`, `accent`, `danger`, `success`, `warning`. ### How it works -Each scale is defined by a hue, a default saturation, and a list of lightness steps: +Each scale is defined by a hue, a default chroma, and a list of lightness steps: ```edn -:gray {:hue 240 :saturation 18 - :steps [[50 97 14] ;; [label lightness saturation] - [100 95 14] - [200 90 12] +:gray {:hue 285 :chroma 0.025 + :steps [[50 0.975 0.003] ;; [label lightness chroma] + [100 0.955 0.005] + [200 0.915 0.010] ... - [950 5 18]]} + [950 0.145 0.011]]} ``` This generates CSS variables in `:root`: ```css ---gray-50: #f6f6f8; ---gray-100: #f0f0f4; ---gray-200: #e2e2e9; ---gray-300: #cdcdd6; ---gray-400: #9a9aac; ---gray-500: #6a6a81; ---gray-600: #4c4c61; ---gray-700: #38384d; ---gray-800: #1f1f2d; ---gray-900: #13131b; ---gray-950: #0a0a0f; +--gray-50: oklch(0.975 0.003 285); +--gray-100: oklch(0.955 0.005 285); +--gray-200: oklch(0.915 0.010 285); +... +--gray-950: oklch(0.145 0.011 285); ``` Semantic tokens reference these scales. Light theme points at the light end, dark theme at the dark end: @@ -76,33 +70,33 @@ The scale variables are generated once and never change between themes. Only the ### Changing the palette -The gray hue controls the tint of all neutral surfaces, borders, and text. Change it to shift the entire feel: +The gray hue controls the tint of all neutral surfaces, borders, and text. OKLCH hues differ from HSL — change it to shift the entire feel: | Hue | Result | |-----|--------| -| `240` | Purplish gray (default) | -| `220` | Blue-gray | -| `30` | Warm/sandy | -| `0` sat `0` | Pure neutral | +| `285` | Purplish gray (default) | +| `255` | Blue-gray | +| `60` | Warm/sandy | +| any, chroma `0` | Pure neutral | The accent hue controls buttons, focus rings, links: | Hue | Result | |-----|--------| -| `252` | Purple (default) | -| `220` | Blue | -| `0` | Red | -| `142` | Green | +| `286` | Purple (default) | +| `255` | Blue | +| `25` | Red | +| `165` | Green | After changing values, run `bb build-theme` to regenerate `dist/theme.css`. -### Per-step saturation +### Per-step chroma -Steps can be `[label lightness]` to use the scale's default saturation, or `[label lightness saturation]` to override it. This is useful for chromatic colors where high saturation looks wrong at the extremes — the accent scale uses ~100% saturation for light tints and drops to ~70% for dark shades. +Steps can be `[label lightness]` to use the scale's default chroma, or `[label lightness chroma]` to override it. This is important for chromatic colors where high chroma looks wrong at the extremes — the accent scale peaks at chroma 0.255 in the mid-range and tapers to 0.020 for light tints and 0.130 for dark shades. The gray scale uses very low chroma (0.003–0.035) that peaks in the mid-range for a subtle tint. ### Current palette -The default ships with a purplish gray (hue 240) and a vivid purple accent (hue 252, `--accent-500: #7a5afc`), inspired by the [activity-tracker](https://github.com/user/piui) app. The purple tint is strongest in dark backgrounds and fades to near-neutral in light ones, achieved by tapering saturation from 18% at the dark end to 14% at the light end. +The default ships with a purplish gray (hue 285) and a vivid purple accent (hue 286, `--accent-500: oklch(0.595 0.23 286)`), inspired by the [activity-tracker](https://github.com/user/piui) app. OKLCH ensures the lightness steps are perceptually even — a gray at L=0.5 looks equally bright regardless of hue, unlike HSL where different hues have wildly different perceived brightness. ## Theme Tokens diff --git a/dev/theme-adapter.js b/dev/theme-adapter.js index 6699b4d..215eea3 100644 --- a/dev/theme-adapter.js +++ b/dev/theme-adapter.js @@ -3,52 +3,55 @@ (function() { 'use strict'; - // ── HSL → Hex ────────────────────────────────────────────────── - function hslToHex(h, s, l) { - h = ((h % 360) + 360) % 360; - s = Math.max(0, Math.min(100, s)) / 100; - l = Math.max(0, Math.min(100, l)) / 100; - var c = (1 - Math.abs(2 * l - 1)) * s; - var x = c * (1 - Math.abs(((h / 60) % 2) - 1)); - var m = l - c / 2; - var r, g, b; - if (h < 60) { r = c; g = x; b = 0; } - else if (h < 120) { r = x; g = c; b = 0; } - else if (h < 180) { r = 0; g = c; b = x; } - else if (h < 240) { r = 0; g = x; b = c; } - else if (h < 300) { r = x; g = 0; b = c; } - else { r = c; g = 0; b = x; } - function toHex(v) { - var n = Math.round((v + m) * 255); - var hex = n.toString(16); - return hex.length === 1 ? '0' + hex : hex; - } - return '#' + toHex(r) + toHex(g) + toHex(b); + // ── OKLCH helpers ─────────────────────────────────────────────── + // CSS oklch() is used directly — no hex conversion needed. + // oklch(L C H) where L: 0-1, C: 0-~0.4, H: 0-360 degrees. + function oklchCSS(l, c, h) { + return 'oklch(' + l.toFixed(3) + ' ' + c.toFixed(4) + ' ' + h.toFixed(1) + ')'; } - // ── Scale step definitions [label, lightness, baseSaturation] ── + // OKLCH → sRGB hex for swatch preview (canvas-free fallback) + function oklchToHex(L, C, H) { + var hRad = H * Math.PI / 180; + var a = C * Math.cos(hRad), b = C * Math.sin(hRad); + var l_ = L + 0.3963377774 * a + 0.2158037573 * b; + var m_ = L - 0.1055613458 * a - 0.0638541728 * b; + var s_ = L - 0.0894841775 * a - 1.2914855480 * b; + var l3 = l_*l_*l_, m3 = m_*m_*m_, s3 = s_*s_*s_; + var rLin = 4.0767416621*l3 - 3.3077115913*m3 + 0.2309699292*s3; + var gLin = -1.2684380046*l3 + 2.6097574011*m3 - 0.3413193965*s3; + var bLin = -0.0041960863*l3 - 0.7034186147*m3 + 1.7076147010*s3; + function gamma(x) { return x <= 0.0031308 ? 12.92*x : 1.055*Math.pow(Math.max(0,x),1/2.4)-0.055; } + function clamp(x) { return Math.max(0, Math.min(255, Math.round(gamma(Math.max(0,x))*255))); } + var r = clamp(rLin), g = clamp(gLin), bl = clamp(bLin); + return '#' + [r,g,bl].map(function(v){ var h = v.toString(16); return h.length<2?'0'+h:h; }).join(''); + } + + // ── Scale step definitions [label, lightness, baseChroma] ────── + // OKLCH: L is 0-1 (perceptual lightness), C is chroma (colorfulness) var GRAY_STEPS = [ - [50,97,14],[100,95,14],[200,90,12],[300,82,10],[400,64,10], - [500,46,10],[600,34,12],[700,26,16],[800,15,18],[900,9,18],[950,5,18] + [50,0.975,0.003],[100,0.955,0.005],[200,0.915,0.010],[300,0.850,0.012],[400,0.690,0.025], + [500,0.530,0.035],[600,0.425,0.035],[700,0.350,0.035],[800,0.245,0.025],[900,0.190,0.016],[950,0.145,0.011] ]; var ACCENT_STEPS = [ - [50,97,100],[100,94,95],[200,89,95],[300,82,95],[400,75,93], - [500,67,96],[600,57,84],[700,49,72],[800,41,69],[900,34,67],[950,22,73] + [50,0.965,0.020],[100,0.925,0.040],[200,0.860,0.075],[300,0.770,0.125],[400,0.690,0.170], + [500,0.595,0.230],[600,0.505,0.255],[700,0.450,0.245],[800,0.395,0.210],[900,0.350,0.175],[950,0.260,0.130] ]; // ── Presets ──────────────────────────────────────────────────── + // Presets use OKLCH hue (0-360) and chroma multiplier (0-2) var PRESETS = [ - { name: 'Purple', grayHue: 240, graySat: 1.0, accentHue: 252, accentSat: 1.0 }, - { name: 'Blue', grayHue: 215, graySat: 0.4, accentHue: 221, accentSat: 0.87 }, - { name: 'Neutral', grayHue: 0, graySat: 0.0, accentHue: 221, accentSat: 0.87 }, - { name: 'Warm', grayHue: 30, graySat: 0.7, accentHue: 24, accentSat: 1.0 }, - { name: 'Rose', grayHue: 340, graySat: 0.5, accentHue: 340, accentSat: 0.85 }, - { name: 'Emerald', grayHue: 160, graySat: 0.5, accentHue: 160, accentSat: 0.75 }, + { name: 'Purple', grayHue: 285, graySat: 1.0, accentHue: 286, accentSat: 1.0 }, + { name: 'Blue', grayHue: 255, graySat: 0.5, accentHue: 255, accentSat: 0.87 }, + { name: 'Neutral', grayHue: 0, graySat: 0.0, accentHue: 255, accentSat: 0.87 }, + { name: 'Warm', grayHue: 60, graySat: 0.7, accentHue: 50, accentSat: 0.85 }, + { name: 'Rose', grayHue: 0, graySat: 0.5, accentHue: 350, accentSat: 0.85 }, + { name: 'Emerald', grayHue: 165, graySat: 0.5, accentHue: 165, accentSat: 0.75 }, ]; // ── State ────────────────────────────────────────────────────── - var STORAGE_KEY = 'ui-fw-theme-adapter'; - var DEFAULT = { grayHue: 240, graySat: 1.0, accentHue: 252, accentSat: 1.0, open: false }; + var STORAGE_KEY = 'ui-fw-theme-adapter-v2'; + var DEFAULT = { grayHue: 285, graySat: 1.0, accentHue: 286, accentSat: 1.0, open: false }; var state = assign({}, DEFAULT); try { var saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); @@ -68,12 +71,12 @@ } // ── Apply colors to :root ───────────────────────────────────── - function applyScale(name, hue, steps, satMul) { + function applyScale(name, hue, steps, chromaMul) { var root = document.documentElement.style; for (var i = 0; i < steps.length; i++) { - var label = steps[i][0], l = steps[i][1], baseSat = steps[i][2]; - var sat = Math.round(Math.min(100, baseSat * satMul)); - root.setProperty('--' + name + '-' + label, hslToHex(hue, sat, l)); + var label = steps[i][0], l = steps[i][1], baseChroma = steps[i][2]; + var c = Math.min(0.4, baseChroma * chromaMul); + root.setProperty('--' + name + '-' + label, oklchCSS(l, c, hue)); } } @@ -96,16 +99,18 @@ // ── Generate EDN for tokens.edn ─────────────────────────────── function generateEDN() { - function fmtSteps(steps, satMul) { + function fmtSteps(steps, chromaMul) { return steps.map(function(s) { - var sat = Math.round(Math.min(100, s[2] * satMul)); - return ' [' + String(s[0]).padStart(3) + ' ' + String(s[1]).padStart(2) + ' ' + sat + ']'; + var c = Math.min(0.4, s[2] * chromaMul); + return ' [' + String(s[0]).padStart(3) + ' ' + s[1].toFixed(3) + ' ' + c.toFixed(3) + ']'; }).join('\n'); } + var grayChroma = Math.min(0.4, 0.025 * state.graySat); + var accentChroma = Math.min(0.4, 0.23 * state.accentSat); return ':color\n' + - ' {:gray {:hue ' + state.grayHue + ' :saturation ' + Math.round(18 * state.graySat) + '\n' + + ' {:gray {:hue ' + state.grayHue + ' :chroma ' + grayChroma.toFixed(3) + '\n' + ' :steps [' + fmtSteps(GRAY_STEPS, state.graySat).trimStart() + ']}\n\n' + - ' :accent {:hue ' + state.accentHue + ' :saturation ' + Math.round(96 * state.accentSat) + '\n' + + ' :accent {:hue ' + state.accentHue + ' :chroma ' + accentChroma.toFixed(2) + '\n' + ' :steps [' + fmtSteps(ACCENT_STEPS, state.accentSat).trimStart() + ']}}'; } @@ -188,7 +193,7 @@ } function satGradient(hue) { - return 'linear-gradient(to right, hsl(' + hue + ',0%,50%), hsl(' + hue + ',100%,50%))'; + return 'linear-gradient(to right, oklch(0.5 0 ' + hue + '), oklch(0.5 0.15 ' + hue + '))'; } function makeSlider(id, label, min, max, step, gradient) { @@ -265,7 +270,7 @@ // Small color dot var dot = el('span'); dot.style.cssText = 'display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:4px;vertical-align:middle;'; - dot.style.background = hslToHex(p.accentHue, Math.round(96 * p.accentSat), 67); + dot.style.background = oklchToHex(0.595, 0.23 * p.accentSat, p.accentHue); btn.insertBefore(dot, btn.firstChild); presetRow.appendChild(btn); }); @@ -277,7 +282,7 @@ applyStyle(grayLabel, S.label); graySection.appendChild(grayLabel); graySection.appendChild(makeSlider('grayHue', 'Hue', 0, 360, 1, hueGradient())); - graySection.appendChild(makeSlider('graySat', 'Sat', 0, 200, 1)); + graySection.appendChild(makeSlider('graySat', 'Chr', 0, 200, 1)); var graySwatches = makeSwatches(); swatchGray = graySwatches.items; graySection.appendChild(graySwatches.row); @@ -289,7 +294,7 @@ applyStyle(accentLabel, S.label); accentSection.appendChild(accentLabel); accentSection.appendChild(makeSlider('accentHue', 'Hue', 0, 360, 1, hueGradient())); - accentSection.appendChild(makeSlider('accentSat', 'Sat', 0, 200, 1)); + accentSection.appendChild(makeSlider('accentSat', 'Chr', 0, 200, 1)); var accentSwatches = makeSwatches(); swatchAccent = accentSwatches.items; accentSection.appendChild(accentSwatches.row); @@ -379,17 +384,17 @@ inputs.accentSat.value.textContent = Math.round(state.accentSat * 100) + '%'; inputs.accentSat.input.style.background = satGradient(state.accentHue); - // Swatches + // Swatches — use oklch() CSS for accurate preview if (swatchGray) { GRAY_STEPS.forEach(function(s, i) { - var sat = Math.round(Math.min(100, s[2] * state.graySat)); - swatchGray[i].style.background = hslToHex(state.grayHue, sat, s[1]); + var c = Math.min(0.4, s[2] * state.graySat); + swatchGray[i].style.background = oklchCSS(s[1], c, state.grayHue); }); } if (swatchAccent) { ACCENT_STEPS.forEach(function(s, i) { - var sat = Math.round(Math.min(100, s[2] * state.accentSat)); - swatchAccent[i].style.background = hslToHex(state.accentHue, sat, s[1]); + var c = Math.min(0.4, s[2] * state.accentSat); + swatchAccent[i].style.background = oklchCSS(s[1], c, state.accentHue); }); } } diff --git a/src/theme/tokens.edn b/src/theme/tokens.edn index 8f6b04f..2e6d391 100644 --- a/src/theme/tokens.edn +++ b/src/theme/tokens.edn @@ -1,7 +1,8 @@ {;; ─── Scales ──────────────────────────────────────────────────────── ;; Generated into :root only. Not duplicated in dark theme blocks. - ;; Color scales use HSL: [label lightness] or [label lightness saturation]. - ;; Change hue/saturation to shift the entire palette (e.g. purplish gray). + ;; Color scales use OKLCH: [label lightness] or [label lightness chroma]. + ;; OKLCH is perceptually uniform — equal L steps = equal perceived brightness. + ;; Change hue/chroma to shift the entire palette (e.g. purplish gray). :scales {:size {:base 0.25 :unit "rem" :steps 16} @@ -17,76 +18,76 @@ [5 "3xl"]]} :color - {;; Gray — purplish tint (hue 240). Change hue for warm/cool grays. - ;; Saturation tapers at lighter end for subtlety. - :gray {:hue 240 :saturation 18 - :steps [[50 97 14] - [100 95 14] - [200 90 12] - [300 82 10] - [400 64 10] - [500 46 10] - [600 34 12] - [700 26 16] - [800 15 18] - [900 9 18] - [950 5 18]]} + {;; Gray — purplish tint (hue 285 in OKLCH). Change hue for warm/cool grays. + ;; Chroma tapers at light/dark extremes for subtlety, peaks in mid-range. + :gray {:hue 285 :chroma 0.025 + :steps [[50 0.975 0.003] + [100 0.955 0.005] + [200 0.915 0.010] + [300 0.850 0.012] + [400 0.690 0.025] + [500 0.530 0.035] + [600 0.425 0.035] + [700 0.350 0.035] + [800 0.245 0.025] + [900 0.190 0.016] + [950 0.145 0.011]]} - ;; Accent — vivid purple, matching activity-tracker (#7c5cfc ≈ hsl 252 96 67) - :accent {:hue 252 :saturation 96 - :steps [[50 97 100] - [100 94 95] - [200 89 95] - [300 82 95] - [400 75 93] - [500 67 96] - [600 57 84] - [700 49 72] - [800 41 69] - [900 34 67] - [950 22 73]]} + ;; Accent — vivid purple, matching activity-tracker (#7c5cfc ≈ oklch 0.60 0.23 286) + :accent {:hue 286 :chroma 0.23 + :steps [[50 0.965 0.020] + [100 0.925 0.040] + [200 0.860 0.075] + [300 0.770 0.125] + [400 0.690 0.170] + [500 0.595 0.230] + [600 0.505 0.255] + [700 0.450 0.245] + [800 0.395 0.210] + [900 0.350 0.175] + [950 0.260 0.130]]} ;; Danger — red - :danger {:hue 0 :saturation 84 - :steps [[50 97 86] - [100 94 93] - [200 87 95] - [300 77 90] - [400 66 86] - [500 55 84] - [600 47 80] - [700 40 74] - [800 33 70] - [900 27 65] - [950 15 75]]} + :danger {:hue 25 :chroma 0.22 + :steps [[50 0.970 0.014] + [100 0.935 0.032] + [200 0.860 0.073] + [300 0.765 0.127] + [400 0.675 0.184] + [500 0.610 0.226] + [600 0.560 0.220] + [700 0.490 0.184] + [800 0.425 0.153] + [900 0.365 0.124] + [950 0.255 0.086]]} ;; Success — green - :success {:hue 142 :saturation 71 - :steps [[50 97 78] - [100 93 77] - [200 87 73] - [300 77 72] - [400 61 72] - [500 44 69] - [600 36 64] - [700 30 57] - [800 24 51] - [900 20 46] - [950 12 50]]} + :success {:hue 152 :chroma 0.18 + :steps [[50 0.980 0.016] + [100 0.960 0.038] + [200 0.930 0.065] + [300 0.885 0.112] + [400 0.815 0.178] + [500 0.705 0.185] + [600 0.595 0.150] + [700 0.510 0.119] + [800 0.425 0.090] + [900 0.370 0.071] + [950 0.270 0.051]]} ;; Warning — amber - :warning {:hue 38 :saturation 92 - :steps [[50 97 89] - [100 93 95] - [200 87 96] - [300 78 95] - [400 68 93] - [500 55 92] - [600 47 88] - [700 40 80] - [800 33 75] - [900 27 70] - [950 18 72]]}}} + :warning {:hue 76 :chroma 0.16 + :steps [[50 0.985 0.012] + [100 0.965 0.032] + [200 0.935 0.058] + [300 0.890 0.095] + [400 0.845 0.129] + [500 0.790 0.159] + [600 0.725 0.153] + [700 0.625 0.130] + [800 0.540 0.107] + [900 0.465 0.088] + [950 0.355 0.065]]}}} ;; ─── Semantic tokens (light theme) ──────────────────────────────── ;; Reference color scale variables with var(--scale-step). diff --git a/src/ui/css/gen.clj b/src/ui/css/gen.clj index 5a10ed0..bd2cff3 100644 --- a/src/ui/css/gen.clj +++ b/src/ui/css/gen.clj @@ -30,6 +30,43 @@ (str/replace #"0+$" "") (str/replace #"\.$" "")))) +;; ── OKLCH ──────────────────────────────────────────────────────── + +(defn oklch->srgb + "Convert OKLCH [L C H] to sRGB [r g b] (0-255 clamped). + L: 0-1, C: 0-~0.4, H: 0-360 degrees." + [[l c h]] + (let [h-rad (* h (/ Math/PI 180)) + ;; OKLCH → OKLab + a (* c (Math/cos h-rad)) + b (* c (Math/sin h-rad)) + ;; OKLab → LMS (cube roots) + l_ (+ l (* 0.3963377774 a) (* 0.2158037573 b)) + m_ (+ l (* -0.1055613458 a) (* -0.0638541728 b)) + s_ (+ l (* -0.0894841775 a) (* -1.2914855480 b)) + ;; Cube to get LMS + l3 (* l_ l_ l_) + m3 (* m_ m_ m_) + s3 (* s_ s_ s_) + ;; LMS → linear sRGB + r-lin (+ (* 4.0767416621 l3) (* -3.3077115913 m3) (* 0.2309699292 s3)) + g-lin (+ (* -1.2684380046 l3) (* 2.6097574011 m3) (* -0.3413193965 s3)) + b-lin (+ (* -0.0041960863 l3) (* -0.7034186147 m3) (* 1.7076147010 s3)) + ;; Linear sRGB → sRGB (gamma) + gamma (fn [x] + (if (<= x 0.0031308) + (* 12.92 x) + (- (* 1.055 (Math/pow (max 0.0 x) (/ 1.0 2.4))) 0.055))) + clamp (fn [x] (max 0 (min 255 (int (Math/round (* 255.0 (gamma (max 0.0 x))))))))] + [(clamp r-lin) (clamp g-lin) (clamp b-lin)])) + +(defn oklch->hex + "Convert OKLCH [L C H] to hex string. Clamps to sRGB gamut." + [[l c h]] + (color/rgb->hex (oklch->srgb [l c h]))) + +;; ── Scale generation ───────────────────────────────────────────── + (defn generate-size-scale "Generate linear size scale: --size-N = base * N." [{:keys [base unit steps]}] @@ -49,17 +86,20 @@ (str/join "\n"))) (defn generate-color-scale - "Generate CSS variables for a named color scale. - Each step is [label lightness] or [label lightness saturation]. - Uses hsl->hex from jon.color-tools for conversion." - [scale-name {:keys [hue saturation steps]}] + "Generate CSS variables for a named OKLCH color scale. + Each step is [label lightness] or [label lightness chroma]. + Outputs oklch() CSS values for perceptual uniformity." + [scale-name {:keys [hue chroma steps]}] (->> steps (map (fn [step] - (let [[label lightness sat] (if (= 3 (count step)) + (let [[label lightness chr] (if (= 3 (count step)) step - [(first step) (second step) saturation]) - hex (color/hsl->hex [hue sat lightness])] - (str " --" (name scale-name) "-" label ": " hex ";")))) + [(first step) (second step) chroma]) + ;; Format: oklch(L C H) + css-val (str "oklch(" (format "%.3f" (double lightness)) + " " (format "%.4f" (double chr)) + " " (format "%.1f" (double hue)) ")")] + (str " --" (name scale-name) "-" label ": " css-val ";")))) (str/join "\n"))) (defn generate-color-scales diff --git a/test/ui/theme_test.clj b/test/ui/theme_test.clj index 352aa43..876648b 100644 --- a/test/ui/theme_test.clj +++ b/test/ui/theme_test.clj @@ -16,20 +16,27 @@ (is (= "--bg-0" (gen/token->css-var :bg-0))))) (deftest generate-color-scale-test - (testing "generates CSS variables for a color scale" + (testing "generates OKLCH CSS variables for a color scale" (let [scale (gen/generate-color-scale - :gray {:hue 240 :saturation 18 - :steps [[50 97] [950 5]]})] + :gray {:hue 285 :chroma 0.025 + :steps [[50 0.975] [950 0.145]]})] (is (str/includes? scale "--gray-50:")) (is (str/includes? scale "--gray-950:")) - (is (str/includes? scale "#")))) + (is (str/includes? scale "oklch(")))) - (testing "per-step saturation override" + (testing "per-step chroma override" (let [scale (gen/generate-color-scale - :accent {:hue 252 :saturation 96 - :steps [[500 67 96] [400 75 93]]})] + :accent {:hue 286 :chroma 0.23 + :steps [[500 0.595 0.230] [400 0.690 0.170]]})] (is (str/includes? scale "--accent-500:")) - (is (str/includes? scale "--accent-400:"))))) + (is (str/includes? scale "--accent-400:")) + (is (str/includes? scale "oklch(0.595 0.2300 286.0)")))) + + (testing "oklch->hex roundtrip produces valid hex" + (let [hex (gen/oklch->hex [0.595 0.23 286])] + (is (string? hex)) + (is (str/starts-with? hex "#") "should start with #") + (is (= 7 (count hex)))))) (deftest generate-css-test (let [token-data (gen/read-tokens "src/theme/tokens.edn")