refactor(theme): switch color generation from HSL to OKLCH

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.
This commit is contained in:
Florian Schroedl
2026-03-11 12:04:33 +01:00
parent 41811dba88
commit 59d46700bc
6 changed files with 222 additions and 175 deletions

View File

@@ -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).

View File

@@ -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