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:
24
AGENTS.md
24
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`).
|
||||
|
||||
|
||||
54
README.md
54
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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user