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

@@ -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, 200900, 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`).

View File

@@ -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 (50950) 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 (50950) 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.0030.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

View File

@@ -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);
});
}
}

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

View File

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