diff --git a/AGENTS.md b/AGENTS.md index e8f677b..7966b98 100644 --- a/AGENTS.md +++ b/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 diff --git a/bb.edn b/bb.edn index 9fbfb46..b8bdd7f 100644 --- a/bb.edn +++ b/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 diff --git a/src/theme/tokens.edn b/src/theme/tokens.edn index 51d399d..8f6b04f 100644 --- a/src/theme/tokens.edn +++ b/src/theme/tokens.edn @@ -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"}}} diff --git a/src/ui/css/gen.clj b/src/ui/css/gen.clj index b4efbf3..5a10ed0 100644 --- a/src/ui/css/gen.clj +++ b/src/ui/css/gen.clj @@ -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." diff --git a/test/ui/theme_test.clj b/test/ui/theme_test.clj index 2046a66..352aa43 100644 --- a/test/ui/theme_test.clj +++ b/test/ui/theme_test.clj @@ -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\"]")))