From 609613f4fb7d25b282f24d91679be064c083f3ef Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Tue, 3 Mar 2026 11:16:23 +0100 Subject: [PATCH] feat: add algorithmic size and font scales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add :scales config to tokens.edn with two generated scales: - Size (linear): --size-1 through --size-16, base 0.25rem × N - Font (geometric): --font-xs through --font-3xl, ratio 1.25 gen.clj computes values and emits them into :root only (not dark theme blocks). button.css now uses scale vars instead of raw rem. Change base or ratio in tokens.edn to recompute the entire scale. --- AGENTS.md | 39 ++++++++++++++++++++++++++++++++++++++- src/theme/tokens.edn | 14 +++++++++++++- src/ui/button.css | 18 +++++++++--------- src/ui/css/gen.clj | 41 +++++++++++++++++++++++++++++++++++++++-- test/ui/theme_test.clj | 15 +++++++++++++++ 5 files changed, 114 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e843061..88d96d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,6 +149,8 @@ No changes needed in `gen.clj` — it collects all `src/ui/*.css` files automati CSS conventions: - Utility-style flat classes: `.component`, `.component-variant`, `.component-size` - Use `var(--token-name)` for all colors, borders, shadows, radii +- Use `var(--size-N)` for spacing/padding/gap/line-height — no raw `rem` values +- Use `var(--font-*)` for font-size — no raw `rem` values - Include hover/focus/disabled states - Keep specificity flat — no nesting beyond `:hover:not(:disabled)` @@ -204,9 +206,44 @@ Semantic + scale tokens in `src/theme/tokens.edn`: - **Shadows**: `shadow-0/1/2/3` (increasing elevation) - **Radii**: `radius-sm/md/lg` +### Algorithmic scales + +Defined in `:scales` in `tokens.edn`. Generated into `:root` only (not duplicated in dark theme blocks). + +**Size scale** — linear: `--size-N = base × N` + +```edn +:size {:base 0.25 :unit "rem" :steps 16} +``` + +Produces `--size-1: 0.25rem` through `--size-16: 4rem`. Use for all spacing, padding, gap, and line-height values. + +**Font scale** — geometric: `--font-{label} = base × ratio^power` + +```edn +:font {:base 1 :unit "rem" :ratio 1.25 + :steps [[-2 "xs"] [-1 "sm"] [0 "base"] [1 "md"] [2 "lg"] [3 "xl"] [4 "2xl"] [5 "3xl"]]} +``` + +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`. + +**Usage in component CSS:** + +```css +.btn { + padding: var(--size-2) var(--size-4); + font-size: var(--font-sm); + line-height: var(--size-5); +} +``` + +**Rule: never use raw `rem` values in component CSS** — always reference a scale variable. + ### Adding tokens -Add to both `:tokens` (light) and `:themes > :dark` in `tokens.edn`. They must have the same keys — the `tokens-roundtrip-test` enforces this. +Add to both `:tokens` (light) and `:themes > :dark` in `tokens.edn`. They must have the same keys — the `tokens-roundtrip-test` enforces this. Scale config (`:scales`) is theme-independent and lives at the top level. ### Dark mode diff --git a/src/theme/tokens.edn b/src/theme/tokens.edn index 4aeec3a..80a933c 100644 --- a/src/theme/tokens.edn +++ b/src/theme/tokens.edn @@ -1,4 +1,16 @@ -{:tokens +{:scales + {:size {:base 0.25 :unit "rem" :steps 16} + :font {:base 1 :unit "rem" :ratio 1.25 + :steps [[-2 "xs"] + [-1 "sm"] + [0 "base"] + [1 "md"] + [2 "lg"] + [3 "xl"] + [4 "2xl"] + [5 "3xl"]]}} + + :tokens {:bg-0 "#ffffff" :bg-1 "#f5f5f5" :bg-2 "#e8e8e8" diff --git a/src/ui/button.css b/src/ui/button.css index 81ec2cf..66e0c4a 100644 --- a/src/ui/button.css +++ b/src/ui/button.css @@ -3,10 +3,10 @@ align-items: center; justify-content: center; gap: 0.5em; - padding: 0.5rem 1rem; - font-size: 0.875rem; + padding: var(--size-2) var(--size-4); + font-size: var(--font-sm); font-weight: 500; - line-height: 1.25rem; + line-height: var(--size-5); border: none; border-radius: var(--radius-md); cursor: pointer; @@ -48,15 +48,15 @@ } .btn-sm { - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - line-height: 1rem; + padding: var(--size-1) var(--size-2); + font-size: var(--font-xs); + line-height: var(--size-4); } .btn-lg { - padding: 0.75rem 1.5rem; - font-size: 1rem; - line-height: 1.5rem; + padding: var(--size-3) var(--size-6); + font-size: var(--font-base); + line-height: var(--size-6); } .btn:disabled { diff --git a/src/ui/css/gen.clj b/src/ui/css/gen.clj index 1315170..4098668 100644 --- a/src/ui/css/gen.clj +++ b/src/ui/css/gen.clj @@ -21,6 +21,40 @@ (map (fn [[k v]] (str " " (token->css-var k) ": " v ";"))) (str/join "\n"))) +(defn format-number + "Format a number: strip trailing zeros, max 3 decimal places." + [n] + (let [s (format "%.3f" (double n))] + (-> s + (str/replace #"0+$" "") + (str/replace #"\.$" "")))) + +(defn generate-size-scale + "Generate linear size scale: --size-N = base * N." + [{:keys [base unit steps]}] + (->> (range 1 (inc steps)) + (map (fn [n] + (str " --size-" n ": " (format-number (* base n)) unit ";"))) + (str/join "\n"))) + +(defn generate-font-scale + "Generate geometric font scale: --font-{name} = base * ratio^power." + [{:keys [base unit ratio steps]}] + (->> steps + (map (fn [[power label]] + (str " --font-" label ": " + (format-number (* base (Math/pow ratio power))) + unit ";"))) + (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)))))) + (defn base-css "Generate base body/reset styles." [] @@ -42,9 +76,12 @@ (defn generate-css "Generate the full CSS output from parsed token data." - [{:keys [tokens themes]}] + [{:keys [tokens themes scales]}] (let [dark-tokens (get themes :dark) - root-block (str ":root {\n" (tokens->css-block tokens) "\n}") + scale-vars (when scales (generate-scales scales)) + root-block (str ":root {\n" (tokens->css-block tokens) + (when scale-vars (str "\n" scale-vars)) + "\n}") dark-attr (str "[data-theme=\"dark\"] {\n" (tokens->css-block dark-tokens) "\n}") dark-media (str "@media (prefers-color-scheme: dark) {\n" " :root:not([data-theme=\"light\"]) {\n" diff --git a/test/ui/theme_test.clj b/test/ui/theme_test.clj index 65ef9a0..2046a66 100644 --- a/test/ui/theme_test.clj +++ b/test/ui/theme_test.clj @@ -40,6 +40,21 @@ (testing "dark media query excludes explicit light theme" (is (str/includes? css ":root:not([data-theme=\"light\"])"))) + (testing "contains size scale variables" + (doseq [n (range 1 17)] + (is (str/includes? css (str "--size-" n ":")) + (str "Missing size-" n)))) + + (testing "contains font scale variables" + (doseq [label ["xs" "sm" "base" "md" "lg" "xl" "2xl" "3xl"]] + (is (str/includes? css (str "--font-" label ":")) + (str "Missing font-" label)))) + + (testing "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 "--size-1:"))) + (is (not (str/includes? dark-block "--font-base:"))))) + (testing "contains button component CSS" (is (str/includes? css ".btn {")) (is (str/includes? css ".btn-primary {"))