Files
clj-ui-framework/AGENTS.md
Florian Schroedl e003e1c4a8 docs: add agent rules for dev server management and browser verification
- Section 6: Never start dev servers from the agent (prevents orphan
  processes and broken tmux panes)
- Section 7: Check tmux panes for compile errors (renumbered)
- Section 8: Verify compiled output in browser before committing
  (catches squint's silent empty-file failures)
2026-03-05 14:30:01 +01:00

13 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

6. Never start dev servers from the agent — CRITICAL

Do not run bb dev, bb dev-hiccup, bb dev-replicant, bb dev-squint, or any long-running server process from the agent. The user manages dev servers in a separate tmux session (ui-dev). Starting servers from the agent blocks the session, spawns orphan processes, and can break existing tmux panes.

The agent may only:

  • Run short commands: bb test, bb build-theme, curl, wc -l, grep
  • Inspect tmux panes via tmux capture-pane
  • Touch files to trigger recompilation: touch src/ui/<module>.cljc

If a dev server needs restarting, tell the user — don't do it yourself.

7. Check running dev servers before committing — CRITICAL

A tmux session ui-dev runs all three dev servers (bb dev-all). Always check every pane for compile errors before committing:

# List panes, then check each for errors
tmux list-panes -t ui-dev -F "#{pane_index}: #{pane_current_command}"
for i in $(tmux list-panes -t ui-dev -F "#{pane_index}"); do
  echo "=== pane $i ==="
  tmux capture-pane -t "ui-dev:bash.$i" -p -S -30 | grep -v '^$' | tail -10
done

Look for:

  • shadow-cljs (Replicant): Build failure, warnings, or CompilerException
  • Vite/Squint: ERROR, SyntaxError, or failed imports
  • Hiccup (Babashka): stack traces or Exception

Do not commit if any pane shows errors. Fix them first.

8. Verify pages load in browser before committing — CRITICAL

Terminal compile checks alone are not enough. Squint can produce empty .mjs files silently (no errors in the terminal). Use the fetch tool to verify each dev server actually serves a working page:

# Check hiccup (server-rendered — look for actual HTML content)
curl -s http://localhost:4003 | grep -c 'sidebar-layout'

# Check squint compiled output is not empty (must be >1 line)
wc -l dev/squint/.compiled/dev/squint.mjs
wc -l dev/squint/.compiled/ui/*.mjs | sort -n | head -5
# Any file with only 1 line is broken — recompile it:
# touch src/ui/<module>.cljc

# Check replicant compiled JS exists
ls -la dev/replicant/public/js/cljs-runtime/ui.sidebar.js

If any compiled file is suspiciously small (1 line = just the import), touch the source file to trigger rewatch, or manually compile:

cd dev/squint && npx squint compile ../../src/ui/<module>.cljc
cd dev/squint && npx squint compile src/dev/squint.cljs

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

  7. Blank page from squint watcher race condition — The squint watcher can produce truncated/empty .mjs files when it detects a file change mid-save. Vite picks up the broken module and the page goes blank with no terminal errors (the crash is browser-side only). This commonly happens during rapid edits or when multiple files change at once.

    How to detect: Page is blank, no compile errors in the tmux pane. Verify with:

    wc -l dev/squint/.compiled/ui/<module>.mjs  # Should be >1 line
    

    How to recover:

    # Option A: touch the source file to trigger recompile
    touch src/ui/<module>.cljc
    # Then hard-refresh the browser (Ctrl+Shift+R)
    
    # Option B: restart the squint tmux pane
    # Kill existing processes and recreate:
    tmux split-window -v -t ui-dev \
      "bash -c 'cd dev/squint && npx squint watch & cd dev/squint && npx vite --port 4002'"
    tmux select-layout -t ui-dev tiled
    # Then hard-refresh the browser
    

    After recovering, always verify the compiled output is complete before committing.

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