Files
clj-ui-framework/AGENTS.md
Florian Schroedl 609613f4fb feat: add algorithmic size and font scales
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.
2026-03-03 11:16:23 +01:00

10 KiB
Raw Blame History

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:

  • :cljs branch: :class must be a vector, :style must be a keyword-keyed map
  • :squint branch: :class is a string, :style is a string-keyed map, events are flat (:on-click)
  • :clj branch: :class and :style are 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 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)

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:

  1. :root { ... } — light defaults
  2. [data-theme="dark"] { ... } — explicit dark override
  3. @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { ... } } — auto dark

Toggle with: document.documentElement.dataset.theme = "dark" | "light"

Squint Pitfalls

  1. name is not available — define kw-name stubs via reader conditionals
  2. Keywords are strings:primary becomes "primary" at runtime
  3. Maps are JS objects — style maps must use string keys: {"display" "flex"}, not {:display "flex"}
  4. No lazy seq flattening in Eucalypt — use into with mapcat/map to build hiccup vectors eagerly
  5. Eucalypt render arg order(eu/render hiccup container), hiccup first
  6. Eucalypt import(:require ["eucalypt" :as eu]), quoted string for npm package

Replicant Pitfalls

  1. :class must be a vector of strings — not a space-joined string. Replicant asserts on this.
  2. :style must be a map with keyword keys{:color "red"}, not "color: red;". Replicant asserts no string styles.
  3. Events use nested :on map{:on {:click handler}}, not :on-click
  4. set-dispatch! required — call (d/set-dispatch! (fn [_ _])) before first render, even if no-op
  5. Lazy seqs are fine — Replicant flattens for/map results as children

Hiccup (Backend) Pitfalls

  1. :style is a plain string"color: red; display: flex;"
  2. :class is a plain string"btn btn--primary"
  3. No event handlers — use inline JS via :onclick strings or HTMX attributes
  4. Uses hiccup2 — wrap in (h/html ...) and call str on the result

Babashka (bb.edn) Notes

  • Task names are unquoted symbols, not keywords: build-theme not :build-theme
  • Use :requires in task config, not inline (require ...) in the task body
  • :depends references other task symbols