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.
10 KiB
UI Framework — Agent Guide
A cross-target component library for Clojure, ClojureScript (Replicant), and Squint (Eucalypt). Components are .cljc files using reader conditionals. CSS is generated from EDN tokens via Babashka.
Project Structure
src/
theme/tokens.edn # Design tokens (colors, borders, shadows, radii)
ui/
theme.cljc # Token helpers (css-var)
button.cljc # Button component (reference implementation)
button.css # Button component styles (read by gen.clj)
css/gen.clj # EDN → CSS generator (babashka)
dist/
theme.css # Generated CSS (tokens + component styles)
test/ui/
button_test.clj # Unit tests for button-classes, button component
theme_test.clj # Unit tests for CSS generation
dev/
index.html # Tab shell with iframes for all 3 targets
hiccup/src/dev/hiccup.clj # Babashka httpkit server (port 3003)
replicant/ # shadow-cljs + Replicant (port 3001)
squint/ # Vite + Squint + Eucalypt (port 3002)
Commands
bb build-theme # Generate dist/theme.css, copy to dev targets
bb test # Run all unit tests
bb dev-hiccup # Start hiccup server (port 3003)
bb dev-replicant # Start replicant dev (port 3001)
bb dev-squint # Start squint dev (port 3002)
bb dev # Build theme + start hiccup + print instructions
Replicant and squint need npm install in their dev directories first.
Reader Conditional Order — CRITICAL
Squint reads .cljc files and matches :cljs if it appears before :squint. Always put :squint first:
;; CORRECT — squint picks :squint
#?(:squint (do-squint-thing)
:cljs (do-cljs-thing)
:clj (do-clj-thing))
;; WRONG — squint picks :cljs, never reaches :squint
#?(:clj (do-clj-thing)
:cljs (do-cljs-thing)
:squint (do-squint-thing))
For conditional defs, use the splicing form inside do:
(do
#?@(:squint []
:cljs [(defn some-cljs-only-fn [x] ...)]))
Target Differences at a Glance
| Concern | :clj (Hiccup) |
:cljs (Replicant) |
:squint (Eucalypt) |
|---|---|---|---|
| Keywords | Clojure keywords | CLJS keywords | Strings |
name |
clojure.core/name |
cljs.core/name |
Not available — stub it |
:class |
String "btn btn--primary" |
Vector ["btn" "btn--primary"] |
String "btn btn--primary" |
:style |
String "color: red;" |
Map {:color "red"} |
Map {"color" "red"} (string keys) |
| Events | None (use onclick string or HTMX) | :on {:click handler} |
:on-click handler |
| Children | Lazy seqs OK (hiccup2 flattens) | Lazy seqs OK (replicant flattens) | Must use into to flatten |
How to Add a New Component
1. Create src/ui/COMPONENT.cljc
Follow the button pattern:
(ns ui.card
(:require [clojure.string :as str]))
;; Stub for squint (keywords are strings, name is identity)
#?(:squint (defn- kw-name [s] s)
:cljs (defn- kw-name [s] (name s))
:clj (defn- kw-name [s] (name s)))
;; Pure function for class generation — shared across all targets
(defn card-class-list
"Returns a vector of CSS class strings."
[{:keys [variant]}]
(let [v (or (some-> variant kw-name) "default")]
["card" (str "card--" v)]))
(defn card-classes
"Returns a space-joined class string."
[opts]
(str/join " " (card-class-list opts)))
;; Component with per-target rendering
(defn card
[{:keys [variant class attrs] :as _props} & children]
#?(:squint
(let [classes (cond-> (card-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes} attrs)]
(into [:div base-attrs] children))
:cljs
(let [cls (card-class-list {:variant variant})
classes (cond-> cls
class (conj class))
base-attrs (merge {:class classes} attrs)]
(into [:div base-attrs] children))
:clj
(let [classes (cond-> (card-classes {:variant variant})
class (str " " class))
base-attrs (merge {:class classes} attrs)]
(into [:div base-attrs] children))))
Key rules:
:cljsbranch::classmust be a vector,:stylemust be a keyword-keyed map:squintbranch::classis a string,:styleis a string-keyed map, events are flat (:on-click):cljbranch::classand:styleare both strings, no event handlers
2. Add CSS file src/ui/COMPONENT.css
Create a plain CSS file next to the .cljc file. The generator automatically reads all .css files from src/ui/:
/* src/ui/card.css */
.card {
padding: 1rem;
border-radius: var(--radius-md);
background: var(--bg-1);
}
.card-elevated {
box-shadow: var(--shadow-1);
}
No changes needed in gen.clj — it collects all src/ui/*.css files automatically.
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 rawremvalues - Use
var(--font-*)for font-size — no rawremvalues - Include hover/focus/disabled states
- Keep specificity flat — no nesting beyond
:hover:not(:disabled)
3. Add unit tests in test/ui/COMPONENT_test.clj
Test the pure class-generation functions (they run in :clj via Babashka):
(ns ui.card-test
(:require [clojure.test :refer [deftest is testing]]
[ui.card :as card]))
(deftest card-class-list-test
(testing "default variant"
(is (= ["card" "card-default"] (card/card-class-list {}))))
(testing "explicit variant"
(is (= ["card" "card-elevated"] (card/card-class-list {:variant :elevated})))))
Register new test namespaces in bb.edn:
test
{:requires ([clojure.test :as t]
[ui.button-test]
[ui.card-test] ;; <-- add
[ui.theme-test])
:task (let [{:keys [fail error]} (t/run-tests 'ui.button-test 'ui.card-test 'ui.theme-test)] ...)}
4. Add to dev test pages
Add the component to all three dev targets so it renders in the visual test page. Each target has its own rendering style — see existing button examples in dev/*/src/dev/*.cljs.
5. Run verification
bb build-theme # Regenerate CSS with new component styles
bb test # All tests pass
bb dev-hiccup # Visual check
Theme System
Token naming
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) - 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
: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
: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:
.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. Scale config (:scales) is theme-independent and lives at the top level.
Dark mode
Three CSS layers are generated:
:root { ... }— light defaults[data-theme="dark"] { ... }— explicit dark override@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { ... } }— auto dark
Toggle with: document.documentElement.dataset.theme = "dark" | "light"
Squint Pitfalls
nameis not available — definekw-namestubs via reader conditionals- Keywords are strings —
:primarybecomes"primary"at runtime - Maps are JS objects — style maps must use string keys:
{"display" "flex"}, not{:display "flex"} - No lazy seq flattening in Eucalypt — use
intowithmapcat/mapto build hiccup vectors eagerly - Eucalypt render arg order —
(eu/render hiccup container), hiccup first - Eucalypt import —
(:require ["eucalypt" :as eu]), quoted string for npm package
Replicant Pitfalls
:classmust be a vector of strings — not a space-joined string. Replicant asserts on this.:stylemust be a map with keyword keys —{:color "red"}, not"color: red;". Replicant asserts no string styles.- Events use nested
:onmap —{:on {:click handler}}, not:on-click set-dispatch!required — call(d/set-dispatch! (fn [_ _]))before first render, even if no-op- Lazy seqs are fine — Replicant flattens
for/mapresults as children
Hiccup (Backend) Pitfalls
:styleis a plain string —"color: red; display: flex;":classis a plain string —"btn btn--primary"- No event handlers — use inline JS via
:onclickstrings or HTMX attributes - Uses hiccup2 — wrap in
(h/html ...)and callstron the result
Babashka (bb.edn) Notes
- Task names are unquoted symbols, not keywords:
build-themenot:build-theme - Use
:requiresin task config, not inline(require ...)in the task body :dependsreferences other task symbols