feat(theme): add HSL-based color scale generation with jon/color-tools
Replace hardcoded hex color tokens with algorithmic color scales generated from HSL parameters. Each scale is defined by hue, saturation, and lightness steps in tokens.edn, then converted to hex via jon/color-tools. Color scales (gray, accent, danger, success, warning) generate 11 stops each (50–950) into :root. Semantic tokens (bg-0, fg-0, accent, etc.) reference scale variables with var(--gray-50), var(--accent-500), etc. Dark theme switches which stop each semantic token points to. Gray scale uses hue 240 with tapered saturation for a purplish tint matching the activity-tracker aesthetic. Accent is vivid purple (hue 252). Border radii bumped to 6/10/16px for a rounder feel. To shift the entire palette (e.g. warm gray), change hue/saturation in tokens.edn and run `bb build-theme`.
This commit is contained in:
36
AGENTS.md
36
AGENTS.md
@@ -229,7 +229,7 @@ Semantic + scale tokens in `src/theme/tokens.edn`:
|
||||
- **Backgrounds**: `bg-0` (base), `bg-1` (surface), `bg-2` (elevated)
|
||||
- **Foregrounds**: `fg-0` (primary text), `fg-1` (secondary), `fg-2` (muted)
|
||||
- **Semantic**: `accent`, `danger`, `success` + `fg-on-*` for contrast text
|
||||
- **Borders**: `border-0/1/2` (full shorthand: `1px solid #color`)
|
||||
- **Borders**: `border-0/1/2` (full shorthand: `1px solid var(--gray-N)`)
|
||||
- **Shadows**: `shadow-0/1/2/3` (increasing elevation)
|
||||
- **Radii**: `radius-sm/md/lg`
|
||||
|
||||
@@ -254,7 +254,32 @@ 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.
|
||||
|
||||
To adjust the entire scale, change `base` or `ratio` — all values recompute on `bb build-theme`.
|
||||
**Color scales** — HSL-based, generated via `jon.color-tools`:
|
||||
|
||||
```edn
|
||||
:color {:gray {:hue 240 :saturation 18
|
||||
:steps [[50 97 14] [100 95 14] ... [950 5 18]]}}
|
||||
```
|
||||
|
||||
Each step is `[label lightness]` (uses default saturation) or `[label lightness saturation]` (per-step override). Produces `--gray-50: #hex` through `--gray-950: #hex`.
|
||||
|
||||
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
|
||||
|
||||
**To change the accent color**, change `hue` in the accent scale:
|
||||
- `252` → purple (current, matches activity-tracker)
|
||||
- `220` → blue
|
||||
- `142` → green
|
||||
- `0` → 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`).
|
||||
|
||||
To adjust the entire scale, change `hue`, `saturation`, or `steps` — all values recompute on `bb build-theme`.
|
||||
|
||||
**Usage in component CSS:**
|
||||
|
||||
@@ -267,6 +292,7 @@ To adjust the entire scale, change `base` or `ratio` — all values recompute on
|
||||
```
|
||||
|
||||
**Rule: never use raw `rem` values in component CSS** — always reference a scale variable.
|
||||
**Rule: never use raw hex colors in component CSS** — always reference a token or scale variable.
|
||||
|
||||
### Adding tokens
|
||||
|
||||
@@ -275,10 +301,12 @@ Add to both `:tokens` (light) and `:themes > :dark` in `tokens.edn`. They must h
|
||||
### Dark mode
|
||||
|
||||
Three CSS layers are generated:
|
||||
1. `:root { ... }` — light defaults
|
||||
2. `[data-theme="dark"] { ... }` — explicit dark override
|
||||
1. `:root { ... }` — light defaults + all scales (size, font, color)
|
||||
2. `[data-theme="dark"] { ... }` — explicit dark override (semantic tokens only)
|
||||
3. `@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { ... } }` — auto dark
|
||||
|
||||
Color scales are generated once in `:root` and never duplicated. Dark theme just reassigns which scale stop each semantic token points to.
|
||||
|
||||
Toggle with: `document.documentElement.dataset.theme = "dark" | "light"`
|
||||
|
||||
## Squint Pitfalls
|
||||
|
||||
3
bb.edn
3
bb.edn
@@ -1,4 +1,5 @@
|
||||
{:paths ["src" "test" "dev/hiccup/src"]
|
||||
{:deps {com.github.jramosg/color-tools {:mvn/version "1.1.0"}}
|
||||
:paths ["src" "test" "dev/hiccup/src"]
|
||||
|
||||
:tasks
|
||||
{build-theme
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{:scales
|
||||
{;; ─── 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).
|
||||
|
||||
:scales
|
||||
{:size {:base 0.25 :unit "rem" :steps 16}
|
||||
|
||||
:font {:base 1 :unit "rem" :ratio 1.25
|
||||
:steps [[-2 "xs"]
|
||||
[-1 "sm"]
|
||||
@@ -8,57 +14,135 @@
|
||||
[2 "lg"]
|
||||
[3 "xl"]
|
||||
[4 "2xl"]
|
||||
[5 "3xl"]]}}
|
||||
[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]]}
|
||||
|
||||
;; 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]]}
|
||||
|
||||
;; 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]]}
|
||||
|
||||
;; 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]]}
|
||||
|
||||
;; 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]]}}}
|
||||
|
||||
;; ─── Semantic tokens (light theme) ────────────────────────────────
|
||||
;; Reference color scale variables with var(--scale-step).
|
||||
|
||||
:tokens
|
||||
{:bg-0 "#ffffff"
|
||||
:bg-1 "#f5f5f5"
|
||||
:bg-2 "#e8e8e8"
|
||||
:fg-0 "#1a1a1a"
|
||||
:fg-1 "#4a4a4a"
|
||||
:fg-2 "#8a8a8a"
|
||||
:accent "#2563eb"
|
||||
{:bg-0 "var(--gray-50)"
|
||||
:bg-1 "var(--gray-100)"
|
||||
:bg-2 "var(--gray-200)"
|
||||
:fg-0 "var(--gray-950)"
|
||||
:fg-1 "var(--gray-600)"
|
||||
:fg-2 "var(--gray-400)"
|
||||
:accent "var(--accent-500)"
|
||||
:fg-on-accent "#ffffff"
|
||||
:danger "#dc2626"
|
||||
:danger "var(--danger-500)"
|
||||
:fg-on-danger "#ffffff"
|
||||
:success "#16a34a"
|
||||
:success "var(--success-500)"
|
||||
:fg-on-success "#ffffff"
|
||||
:warning "#d97706"
|
||||
:warning "var(--warning-500)"
|
||||
:fg-on-warning "#ffffff"
|
||||
:border-0 "1px solid #e0e0e0"
|
||||
:border-1 "1px solid #cccccc"
|
||||
:border-2 "1px solid #999999"
|
||||
:border-0 "1px solid var(--gray-200)"
|
||||
:border-1 "1px solid var(--gray-300)"
|
||||
:border-2 "1px solid var(--gray-500)"
|
||||
:shadow-0 "0 1px 2px rgba(0,0,0,0.05)"
|
||||
:shadow-1 "0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06)"
|
||||
:shadow-2 "0 4px 6px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.06)"
|
||||
:shadow-3 "0 10px 15px rgba(0,0,0,0.1), 0 4px 6px rgba(0,0,0,0.05)"
|
||||
:radius-sm "4px"
|
||||
:radius-md "6px"
|
||||
:radius-lg "12px"}
|
||||
:radius-sm "6px"
|
||||
:radius-md "10px"
|
||||
:radius-lg "16px"}
|
||||
|
||||
;; ─── Dark theme overrides ─────────────────────────────────────────
|
||||
;; Same keys, different scale stops. Scales themselves don't change.
|
||||
|
||||
:themes
|
||||
{:dark
|
||||
{:bg-0 "#121212"
|
||||
:bg-1 "#1e1e1e"
|
||||
:bg-2 "#2a2a2a"
|
||||
:fg-0 "#e8e8e8"
|
||||
:fg-1 "#b0b0b0"
|
||||
:fg-2 "#707070"
|
||||
:accent "#3b82f6"
|
||||
{:bg-0 "var(--gray-950)"
|
||||
:bg-1 "var(--gray-900)"
|
||||
:bg-2 "var(--gray-800)"
|
||||
:fg-0 "var(--gray-50)"
|
||||
:fg-1 "var(--gray-300)"
|
||||
:fg-2 "var(--gray-500)"
|
||||
:accent "var(--accent-400)"
|
||||
:fg-on-accent "#ffffff"
|
||||
:danger "#ef4444"
|
||||
:danger "var(--danger-400)"
|
||||
:fg-on-danger "#ffffff"
|
||||
:success "#22c55e"
|
||||
:success "var(--success-400)"
|
||||
:fg-on-success "#ffffff"
|
||||
:warning "#f59e0b"
|
||||
:warning "var(--warning-400)"
|
||||
:fg-on-warning "#ffffff"
|
||||
:border-0 "1px solid #2a2a2a"
|
||||
:border-1 "1px solid #3a3a3a"
|
||||
:border-2 "1px solid #555555"
|
||||
:border-0 "1px solid var(--gray-800)"
|
||||
:border-1 "1px solid var(--gray-700)"
|
||||
:border-2 "1px solid var(--gray-500)"
|
||||
:shadow-0 "0 1px 2px rgba(0,0,0,0.2)"
|
||||
:shadow-1 "0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2)"
|
||||
:shadow-2 "0 4px 6px rgba(0,0,0,0.3), 0 2px 4px rgba(0,0,0,0.2)"
|
||||
:shadow-3 "0 10px 15px rgba(0,0,0,0.3), 0 4px 6px rgba(0,0,0,0.2)"
|
||||
:radius-sm "4px"
|
||||
:radius-md "6px"
|
||||
:radius-lg "12px"}}}
|
||||
:radius-sm "6px"
|
||||
:radius-md "10px"
|
||||
:radius-lg "16px"}}}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
(ns ui.css.gen
|
||||
(:require [babashka.fs :as fs]
|
||||
[clojure.edn :as edn]
|
||||
[clojure.string :as str]))
|
||||
[clojure.string :as str]
|
||||
[jon.color-tools :as color]))
|
||||
|
||||
(defn read-tokens
|
||||
"Read and parse the tokens EDN file."
|
||||
@@ -47,13 +48,37 @@
|
||||
unit ";")))
|
||||
(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]}]
|
||||
(->> steps
|
||||
(map (fn [step]
|
||||
(let [[label lightness sat] (if (= 3 (count step))
|
||||
step
|
||||
[(first step) (second step) saturation])
|
||||
hex (color/hsl->hex [hue sat lightness])]
|
||||
(str " --" (name scale-name) "-" label ": " hex ";"))))
|
||||
(str/join "\n")))
|
||||
|
||||
(defn generate-color-scales
|
||||
"Generate all color scale CSS variables."
|
||||
[color-scales]
|
||||
(->> color-scales
|
||||
(sort-by key)
|
||||
(map (fn [[scale-name config]]
|
||||
(generate-color-scale scale-name config)))
|
||||
(str/join "\n")))
|
||||
|
||||
(defn generate-scales
|
||||
"Generate CSS variable declarations for all scales."
|
||||
[scales]
|
||||
(str/join "\n"
|
||||
(cond-> []
|
||||
(:size scales) (conj (generate-size-scale (:size scales)))
|
||||
(:font scales) (conj (generate-font-scale (:font scales))))))
|
||||
(:size scales) (conj (generate-size-scale (:size scales)))
|
||||
(:font scales) (conj (generate-font-scale (:font scales)))
|
||||
(:color scales) (conj (generate-color-scales (:color scales))))))
|
||||
|
||||
(defn base-css
|
||||
"Generate base body/reset styles."
|
||||
|
||||
@@ -15,6 +15,22 @@
|
||||
(is (= "--accent" (gen/token->css-var :accent)))
|
||||
(is (= "--bg-0" (gen/token->css-var :bg-0)))))
|
||||
|
||||
(deftest generate-color-scale-test
|
||||
(testing "generates CSS variables for a color scale"
|
||||
(let [scale (gen/generate-color-scale
|
||||
:gray {:hue 240 :saturation 18
|
||||
:steps [[50 97] [950 5]]})]
|
||||
(is (str/includes? scale "--gray-50:"))
|
||||
(is (str/includes? scale "--gray-950:"))
|
||||
(is (str/includes? scale "#"))))
|
||||
|
||||
(testing "per-step saturation override"
|
||||
(let [scale (gen/generate-color-scale
|
||||
:accent {:hue 252 :saturation 96
|
||||
:steps [[500 67 96] [400 75 93]]})]
|
||||
(is (str/includes? scale "--accent-500:"))
|
||||
(is (str/includes? scale "--accent-400:")))))
|
||||
|
||||
(deftest generate-css-test
|
||||
(let [token-data (gen/read-tokens "src/theme/tokens.edn")
|
||||
css (gen/generate-css token-data)]
|
||||
@@ -31,6 +47,17 @@
|
||||
(is (str/includes? css (str "--" (name token) ":"))
|
||||
(str "Missing token: " (name token)))))
|
||||
|
||||
(testing "contains color scale variables"
|
||||
(doseq [scale ["gray" "accent" "danger" "success" "warning"]]
|
||||
(doseq [step [50 100 200 300 400 500 600 700 800 900 950]]
|
||||
(is (str/includes? css (str "--" scale "-" step ":"))
|
||||
(str "Missing color: " scale "-" step)))))
|
||||
|
||||
(testing "color scales only in :root, not in dark theme blocks"
|
||||
(let [dark-block (second (str/split css #"\[data-theme=\"dark\"\]"))]
|
||||
(is (not (str/includes? dark-block "--gray-50:")))
|
||||
(is (not (str/includes? dark-block "--accent-500:")))))
|
||||
|
||||
(testing "contains dark theme data attribute selector"
|
||||
(is (str/includes? css "[data-theme=\"dark\"]")))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user