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

286 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```sh
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:**
```clojure
;; 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`:
```clojure
(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:
```clojure
(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/`:
```css
/* 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):
```clojure
(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`:
```clojure
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
```sh
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`
```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. 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