Init
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Build artifacts
|
||||
dist/
|
||||
|
||||
# Dev target build outputs
|
||||
dev/replicant/public/js/
|
||||
dev/replicant/public/theme.css
|
||||
dev/replicant/.shadow-cljs/
|
||||
dev/squint/.compiled/
|
||||
dev/squint/public/
|
||||
dev/squint/node_modules/
|
||||
|
||||
# General
|
||||
node_modules/
|
||||
.nrepl-port
|
||||
.cpcache/
|
||||
/.clj-kondo/
|
||||
253
AGENTS.md
Normal file
253
AGENTS.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# 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)
|
||||
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 to `src/ui/css/gen.clj`
|
||||
|
||||
Add a `component-css-card` function and include it in `generate-css`:
|
||||
|
||||
```clojure
|
||||
(defn component-css-card []
|
||||
(str/join "\n\n"
|
||||
[".card {
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-1);
|
||||
}"
|
||||
".card--elevated {
|
||||
box-shadow: var(--shadow-1);
|
||||
}"]))
|
||||
|
||||
;; In generate-css, add to the components list:
|
||||
(defn generate-css [{:keys [tokens themes]}]
|
||||
(let [...]
|
||||
(str/join "\n\n" [root-block dark-attr dark-media
|
||||
(component-css-button)
|
||||
(component-css-card) ;; <-- add here
|
||||
""])))
|
||||
```
|
||||
|
||||
CSS conventions:
|
||||
- BEM-lite: `.component`, `.component--variant`, `.component--size`
|
||||
- Use `var(--token-name)` for all colors, borders, shadows, radii
|
||||
- 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`
|
||||
|
||||
### 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.
|
||||
|
||||
### 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
|
||||
69
bb.edn
Normal file
69
bb.edn
Normal file
@@ -0,0 +1,69 @@
|
||||
{:paths ["src" "test" "dev/hiccup/src"]
|
||||
|
||||
:tasks
|
||||
{build-theme
|
||||
{:doc "Generate dist/theme.css from tokens.edn and copy to dev targets"
|
||||
:requires ([ui.css.gen :as gen]
|
||||
[clojure.java.io :as io])
|
||||
:task (do (gen/build-theme! {:input "src/theme/tokens.edn"
|
||||
:output "dist/theme.css"})
|
||||
(let [css (slurp "dist/theme.css")]
|
||||
(spit "dev/replicant/public/theme.css" css)
|
||||
(io/make-parents "dev/squint/public/theme.css")
|
||||
(spit "dev/squint/public/theme.css" css)
|
||||
(println "Copied theme.css to dev targets")))}
|
||||
|
||||
test
|
||||
{:doc "Run all unit tests"
|
||||
:requires ([clojure.test :as t]
|
||||
[ui.button-test]
|
||||
[ui.theme-test])
|
||||
:task (let [{:keys [fail error]} (t/run-tests 'ui.button-test 'ui.theme-test)]
|
||||
(when (pos? (+ fail error))
|
||||
(System/exit 1)))}
|
||||
|
||||
dev-hiccup
|
||||
{:doc "Start hiccup dev server on port 3003"
|
||||
:depends [build-theme]
|
||||
:requires ([dev.hiccup])
|
||||
:task (do (dev.hiccup/start! {:port 3003})
|
||||
(deref (promise)))}
|
||||
|
||||
dev-replicant
|
||||
{:doc "Start replicant dev server on port 3001"
|
||||
:depends [build-theme]
|
||||
:task (shell {:dir "dev/replicant"} "npx shadow-cljs watch app")}
|
||||
|
||||
dev-squint
|
||||
{:doc "Start squint dev server on port 3002"
|
||||
:depends [build-theme]
|
||||
:task (shell {:dir "dev/squint"} "npm run dev")}
|
||||
|
||||
dev
|
||||
{:doc "Start all dev servers"
|
||||
:depends [build-theme]
|
||||
:requires ([dev.hiccup])
|
||||
:task (do (dev.hiccup/start! {:port 3003})
|
||||
(println "Dev servers running:")
|
||||
(println " Replicant: cd dev/replicant && npx shadow-cljs watch app (port 3001)")
|
||||
(println " Squint: cd dev/squint && npm run dev (port 3002)")
|
||||
(println " Hiccup: http://localhost:3003")
|
||||
(println " Test page: dev/index.html")
|
||||
(deref (promise)))}
|
||||
|
||||
dev-all
|
||||
{:doc "Start all dev servers in tmux panes"
|
||||
:depends [build-theme]
|
||||
:task (let [session "ui-dev"]
|
||||
(shell {:continue true} "tmux kill-session -t" session)
|
||||
(shell "tmux new-session -d -s" session "bb dev-hiccup")
|
||||
(shell "tmux split-window -h -t" session "bash -c 'cd dev/replicant && npx shadow-cljs watch app'")
|
||||
(shell "tmux split-window -v -t" session "bash -c 'cd dev/squint && npm run dev'")
|
||||
(shell "tmux select-layout -t" session "tiled")
|
||||
(println "Tmux session 'ui-dev' created with 3 panes:")
|
||||
(println " Pane 0: Hiccup → http://localhost:3003")
|
||||
(println " Pane 1: Replicant → http://localhost:3001")
|
||||
(println " Pane 2: Squint → http://localhost:3002")
|
||||
(println)
|
||||
(when-not (zero? (:exit (shell {:continue true} "tmux attach -t" session)))
|
||||
(println "Attach with: tmux attach -t" session)))}}}
|
||||
68
dev/hiccup/src/dev/hiccup.clj
Normal file
68
dev/hiccup/src/dev/hiccup.clj
Normal file
@@ -0,0 +1,68 @@
|
||||
(ns dev.hiccup
|
||||
(:require [org.httpkit.server :as http]
|
||||
[hiccup2.core :as h]
|
||||
[ui.button :as button]))
|
||||
|
||||
(def variants [:primary :secondary :ghost :danger])
|
||||
(def sizes [:sm :md :lg])
|
||||
|
||||
(defn button-grid []
|
||||
[:div {:style "display: grid; grid-template-columns: repeat(4, auto); gap: 1rem; align-items: center;"}
|
||||
;; Header row
|
||||
[:div]
|
||||
(for [size sizes]
|
||||
[:div {:style "font-weight: 600; text-align: center; color: var(--fg-1); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em;"}
|
||||
(name size)])
|
||||
|
||||
;; Variant rows
|
||||
(for [variant variants]
|
||||
(list
|
||||
[:div {:style "font-weight: 600; color: var(--fg-1); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em;"}
|
||||
(name variant)]
|
||||
(for [size sizes]
|
||||
[:div {:style "text-align: center;"}
|
||||
(button/button {:variant variant :size size}
|
||||
(str (name variant) " " (name size)))])))])
|
||||
|
||||
(defn disabled-row []
|
||||
[:div {:style "display: flex; gap: 0.75rem; flex-wrap: wrap;"}
|
||||
(for [variant variants]
|
||||
(button/button {:variant variant :disabled true}
|
||||
(str (name variant) " disabled")))])
|
||||
|
||||
(defn page []
|
||||
(str
|
||||
(h/html
|
||||
[:html
|
||||
[:head
|
||||
[:meta {:charset "utf-8"}]
|
||||
[:meta {:name "viewport" :content "width=device-width, initial-scale=1"}]
|
||||
[:link {:rel "stylesheet" :href "/theme.css"}]
|
||||
[:style "body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 2rem; background: var(--bg-0); color: var(--fg-0); margin: 0; transition: background-color 0.2s, color 0.2s; }"]]
|
||||
[:body
|
||||
[:div {:style "max-width: 800px; margin: 0 auto;"}
|
||||
[:div {:style "display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;"}
|
||||
[:h2 {:style "margin: 0; color: var(--fg-0);"} "Hiccup (Backend)"]
|
||||
[:button {:onclick "document.documentElement.dataset.theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'"
|
||||
:style "padding: 0.5rem 1rem; cursor: pointer; border-radius: var(--radius-md); border: var(--border-0); background: var(--bg-1); color: var(--fg-0);"}
|
||||
"Toggle Dark Mode"]]
|
||||
[:h3 {:style "color: var(--fg-1); margin-bottom: 1rem;"} "Button Grid"]
|
||||
(button-grid)
|
||||
[:h3 {:style "color: var(--fg-1); margin: 2rem 0 1rem;"} "Disabled States"]
|
||||
(disabled-row)]]])))
|
||||
|
||||
(defn handler [{:keys [uri]}]
|
||||
(case uri
|
||||
"/" {:status 200
|
||||
:headers {"Content-Type" "text/html; charset=utf-8"}
|
||||
:body (page)}
|
||||
"/theme.css" {:status 200
|
||||
:headers {"Content-Type" "text/css"}
|
||||
:body (slurp "dist/theme.css")}
|
||||
{:status 404
|
||||
:headers {"Content-Type" "text/plain"}
|
||||
:body "Not found"}))
|
||||
|
||||
(defn start! [{:keys [port] :or {port 3003}}]
|
||||
(println (str "Hiccup server running at http://localhost:" port))
|
||||
(http/run-server handler {:port port}))
|
||||
84
dev/index.html
Normal file
84
dev/index.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>UI Framework — Test Page</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f0f0f0;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: #fff;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.tab {
|
||||
padding: 0.75rem 1.5rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.tab:hover { color: #1a1a1a; }
|
||||
.tab.active {
|
||||
color: #2563eb;
|
||||
border-bottom-color: #2563eb;
|
||||
}
|
||||
.tab.disabled {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.frame-container {
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #999;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-target="hiccup" data-url="http://localhost:3003">Hiccup</button>
|
||||
<button class="tab" data-target="replicant" data-url="http://localhost:3001">Replicant</button>
|
||||
<button class="tab" data-target="squint" data-url="http://localhost:3002">Squint</button>
|
||||
</div>
|
||||
<div class="frame-container" id="frame-container">
|
||||
<iframe id="target-frame" src="http://localhost:3003"></iframe>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const tabs = document.querySelectorAll('.tab');
|
||||
const frame = document.getElementById('target-frame');
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
if (tab.classList.contains('disabled')) return;
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
frame.src = tab.dataset.url;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
3
dev/replicant/deps.edn
Normal file
3
dev/replicant/deps.edn
Normal file
@@ -0,0 +1,3 @@
|
||||
{:paths ["src" "../../src"]
|
||||
:deps {no.cjohansen/replicant {:mvn/version "2025.12.1"}
|
||||
thheller/shadow-cljs {:mvn/version "2.28.23"}}}
|
||||
1565
dev/replicant/package-lock.json
generated
Normal file
1565
dev/replicant/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
dev/replicant/package.json
Normal file
7
dev/replicant/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "ui-framework-dev-replicant",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"shadow-cljs": "^2.28.0"
|
||||
}
|
||||
}
|
||||
22
dev/replicant/public/index.html
Normal file
22
dev/replicant/public/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/theme.css" />
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
padding: 2rem;
|
||||
background: var(--bg-0);
|
||||
color: var(--fg-0);
|
||||
margin: 0;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
9
dev/replicant/shadow-cljs.edn
Normal file
9
dev/replicant/shadow-cljs.edn
Normal file
@@ -0,0 +1,9 @@
|
||||
{:deps true
|
||||
:dev-http {3001 "public"}
|
||||
:builds
|
||||
{:app
|
||||
{:target :browser
|
||||
:output-dir "public/js"
|
||||
:asset-path "/js"
|
||||
:modules {:main {:init-fn dev.replicant/init!}}
|
||||
:devtools {:after-load dev.replicant/reload!}}}}
|
||||
59
dev/replicant/src/dev/replicant.cljs
Normal file
59
dev/replicant/src/dev/replicant.cljs
Normal file
@@ -0,0 +1,59 @@
|
||||
(ns dev.replicant
|
||||
(:require [replicant.dom :as d]
|
||||
[ui.button :as button]))
|
||||
|
||||
(def variants [:primary :secondary :ghost :danger])
|
||||
(def sizes [:sm :md :lg])
|
||||
|
||||
(defn button-grid []
|
||||
[:div {:style {:display "grid"
|
||||
:grid-template-columns "repeat(4, auto)"
|
||||
:gap "1rem"
|
||||
:align-items "center"}}
|
||||
[:div]
|
||||
(for [size sizes]
|
||||
[:div {:style {:font-weight "600" :text-align "center" :color "var(--fg-1)"
|
||||
:font-size "0.75rem" :text-transform "uppercase" :letter-spacing "0.05em"}}
|
||||
(name size)])
|
||||
(for [variant variants]
|
||||
(list
|
||||
[:div {:style {:font-weight "600" :color "var(--fg-1)"
|
||||
:font-size "0.75rem" :text-transform "uppercase" :letter-spacing "0.05em"}}
|
||||
(name variant)]
|
||||
(for [size sizes]
|
||||
[:div {:style {:text-align "center"}}
|
||||
(button/button {:variant variant :size size
|
||||
:on-click (fn [_] (js/console.log (str "Clicked: " (name variant) " " (name size))))}
|
||||
(str (name variant) " " (name size)))])))])
|
||||
|
||||
(defn disabled-row []
|
||||
[:div {:style {:display "flex" :gap "0.75rem" :flex-wrap "wrap"}}
|
||||
(for [variant variants]
|
||||
(button/button {:variant variant :disabled true}
|
||||
(str (name variant) " disabled")))])
|
||||
|
||||
(defn toggle-theme! [_e]
|
||||
(let [el (.-documentElement js/document)
|
||||
current (.. el -dataset -theme)]
|
||||
(set! (.. el -dataset -theme)
|
||||
(if (= current "dark") "light" "dark"))))
|
||||
|
||||
(defn app []
|
||||
[:div {:style {:max-width "800px" :margin "0 auto"}}
|
||||
[:div {:style {:display "flex" :justify-content "space-between" :align-items "center" :margin-bottom "2rem"}}
|
||||
[:h2 {:style {:margin "0" :color "var(--fg-0)"}} "Replicant (CLJS)"]
|
||||
[:button {:on {:click toggle-theme!}
|
||||
:style {:padding "0.5rem 1rem" :cursor "pointer" :border-radius "var(--radius-md)"
|
||||
:border "var(--border-0)" :background "var(--bg-1)" :color "var(--fg-0)"}}
|
||||
"Toggle Dark Mode"]]
|
||||
[:h3 {:style {:color "var(--fg-1)" :margin-bottom "1rem"}} "Button Grid"]
|
||||
(button-grid)
|
||||
[:h3 {:style {:color "var(--fg-1)" :margin "2rem 0 1rem"}} "Disabled States"]
|
||||
(disabled-row)])
|
||||
|
||||
(defn ^:export init! []
|
||||
(d/set-dispatch! (fn [_ _]))
|
||||
(d/render (.getElementById js/document "app") (app)))
|
||||
|
||||
(defn ^:export reload! []
|
||||
(d/render (.getElementById js/document "app") (app)))
|
||||
22
dev/squint/index.html
Normal file
22
dev/squint/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/theme.css" />
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
padding: 2rem;
|
||||
background: var(--bg-0);
|
||||
color: var(--fg-0);
|
||||
margin: 0;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src=".compiled/dev/squint.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
1160
dev/squint/package-lock.json
generated
Normal file
1160
dev/squint/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
dev/squint/package.json
Normal file
16
dev/squint/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "ui-framework-dev-squint",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npx squint watch & npx vite --port 3002",
|
||||
"build": "npx squint compile && npx vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"squint-cljs": "latest",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"eucalypt": "latest"
|
||||
}
|
||||
}
|
||||
2
dev/squint/squint.edn
Normal file
2
dev/squint/squint.edn
Normal file
@@ -0,0 +1,2 @@
|
||||
{:paths ["src" "../../src"]
|
||||
:output-dir ".compiled"}
|
||||
73
dev/squint/src/dev/squint.cljs
Normal file
73
dev/squint/src/dev/squint.cljs
Normal file
@@ -0,0 +1,73 @@
|
||||
(ns dev.squint
|
||||
(:require ["eucalypt" :as eu]
|
||||
[ui.button :as button]))
|
||||
|
||||
(def variants ["primary" "secondary" "ghost" "danger"])
|
||||
(def sizes ["sm" "md" "lg"])
|
||||
|
||||
(defn toggle-theme! [_e]
|
||||
(let [el (.-documentElement js/document)
|
||||
current (.. el -dataset -theme)]
|
||||
(set! (.. el -dataset -theme)
|
||||
(if (= current "dark") "light" "dark"))))
|
||||
|
||||
(def label-style {"font-weight" "600"
|
||||
"color" "var(--fg-1)"
|
||||
"font-size" "0.75rem"
|
||||
"text-transform" "uppercase"
|
||||
"letter-spacing" "0.05em"})
|
||||
|
||||
(defn button-grid []
|
||||
(into
|
||||
[:div {:style {"display" "grid"
|
||||
"grid-template-columns" "repeat(4, auto)"
|
||||
"gap" "1rem"
|
||||
"align-items" "center"}}
|
||||
[:div]
|
||||
[:div {:style (merge label-style {"text-align" "center"})} "sm"]
|
||||
[:div {:style (merge label-style {"text-align" "center"})} "md"]
|
||||
[:div {:style (merge label-style {"text-align" "center"})} "lg"]]
|
||||
(mapcat (fn [variant]
|
||||
[[:div {:style label-style} variant]
|
||||
[:div {:style {"text-align" "center"}}
|
||||
(button/button {:variant variant :size "sm"
|
||||
:on-click (fn [_] (js/console.log (str "Clicked: " variant " sm")))}
|
||||
(str variant " sm"))]
|
||||
[:div {:style {"text-align" "center"}}
|
||||
(button/button {:variant variant :size "md"
|
||||
:on-click (fn [_] (js/console.log (str "Clicked: " variant " md")))}
|
||||
(str variant " md"))]
|
||||
[:div {:style {"text-align" "center"}}
|
||||
(button/button {:variant variant :size "lg"
|
||||
:on-click (fn [_] (js/console.log (str "Clicked: " variant " lg")))}
|
||||
(str variant " lg"))]])
|
||||
variants)))
|
||||
|
||||
(defn disabled-row []
|
||||
(into
|
||||
[:div {:style {"display" "flex" "gap" "0.75rem" "flex-wrap" "wrap"}}]
|
||||
(map (fn [variant]
|
||||
(button/button {:variant variant :disabled true}
|
||||
(str variant " disabled")))
|
||||
variants)))
|
||||
|
||||
(defn app []
|
||||
[:div {:style {"max-width" "800px" "margin" "0 auto"}}
|
||||
[:div {:style {"display" "flex" "justify-content" "space-between" "align-items" "center" "margin-bottom" "2rem"}}
|
||||
[:h2 {:style {"margin" "0" "color" "var(--fg-0)"}} "Squint (Eucalypt)"]
|
||||
[:button {:on-click toggle-theme!
|
||||
:style {"padding" "0.5rem 1rem" "cursor" "pointer" "border-radius" "var(--radius-md)"
|
||||
"border" "var(--border-0)" "background" "var(--bg-1)" "color" "var(--fg-0)"}}
|
||||
"Toggle Dark Mode"]]
|
||||
[:h3 {:style {"color" "var(--fg-1)" "margin-bottom" "1rem"}} "Button Grid"]
|
||||
(button-grid)
|
||||
[:h3 {:style {"color" "var(--fg-1)" "margin" "2rem 0 1rem"}} "Disabled States"]
|
||||
(disabled-row)])
|
||||
|
||||
(defn init! []
|
||||
(eu/render (app) (js/document.getElementById "app")))
|
||||
|
||||
(defn reload! []
|
||||
(eu/render (app) (js/document.getElementById "app")))
|
||||
|
||||
(init!)
|
||||
9
dev/squint/vite.config.js
Normal file
9
dev/squint/vite.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
root: ".",
|
||||
publicDir: "public",
|
||||
server: {
|
||||
port: 3002,
|
||||
},
|
||||
});
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "ui-framework",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
103
plan.md
Normal file
103
plan.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Instructions
|
||||
|
||||
Review this plan thoroughly before making any code changes. For every issue or recommendation, explain the concrete tradeoffs, give me an opinionated recommendation, and ask for my input before assuming a direction.
|
||||
|
||||
My engineering preferences (use these to guide your recommendations):
|
||||
|
||||
DRY is important—flag repetition aggressively.
|
||||
Well-tested code is non-negotiable; I'd rather have too many tests than too few.
|
||||
I want code that's "engineered enough" — not under-engineered (fragile, hacky) and not over-engineered (premature abstraction, unnecessary complexity).
|
||||
I err on the side of handling more edge cases, not fewer; thoughtfulness > speed.
|
||||
Bias toward explicit over clever.
|
||||
|
||||
1. Architecture review Evaluate:
|
||||
|
||||
Overall system design and component boundaries.
|
||||
Dependency graph and coupling concerns.
|
||||
Data flow patterns and potential bottlenecks.
|
||||
Scaling characteristics and single points of failure.
|
||||
Security architecture (auth, data access, API boundaries).
|
||||
|
||||
2. Code quality review Evaluate:
|
||||
|
||||
Code organization and module structure.
|
||||
DRY violations—be aggressive here.
|
||||
Error handling patterns and missing edge cases (call these out explicitly).
|
||||
Technical debt hotspots.
|
||||
Areas that are over-engineered or under-engineered relative to my preferences.
|
||||
|
||||
3. Test review Evaluate:
|
||||
|
||||
Test coverage gaps (unit, integration, e2e).
|
||||
Test quality and assertion strength.
|
||||
Missing edge case coverage—be thorough.
|
||||
Untested failure modes and error paths.
|
||||
|
||||
4. Performance review Evaluate:
|
||||
|
||||
N+1 queries and database access patterns.
|
||||
Memory-usage concerns.
|
||||
Caching opportunities.
|
||||
Slow or high-complexity code paths.
|
||||
|
||||
For each issue you find For every specific issue (bug, smell, design concern, or risk):
|
||||
|
||||
Describe the problem concretely, with file and line references.
|
||||
Present 2–3 options, including "do nothing" where that's reasonable.
|
||||
For each option, specify: implementation effort, risk, impact on other code, and maintenance burden.
|
||||
Give me your recommended option and why, mapped to my preferences above.
|
||||
Then explicitly ask whether I agree or want to choose a different direction before proceeding.
|
||||
|
||||
Workflow and interaction
|
||||
|
||||
Do not assume my priorities on timeline or scale.
|
||||
After each section, pause and ask for my feedback before moving on.
|
||||
|
||||
BEFORE YOU START: Ask if I want one of two options: 1/ BIG CHANGE: Work through this interactively, one section at a time (Architecture → Code Quality → Tests → Performance) with at most 4 top issues in each section. 2/ SMALL CHANGE: Work through interactively ONE question per review section
|
||||
|
||||
FOR EACH STAGE OF REVIEW: output the explanation and pros and cons of each stage's questions AND your opinionated recommendation and why, and then use AskUserQuestion. Also NUMBER issues and then give LETTERS for options and when using AskUserQuestion make sure each option clearly labels the issue NUMBER and option LETTER so the user doesn't get confused. Make the recommended option always the 1st option.
|
||||
|
||||
# Plan
|
||||
|
||||
Create a plan to star a UI framework for my purposes.
|
||||
|
||||
Its should be similar to https://oat.ink/
|
||||
|
||||
It will be used in my clojure / clojurescript / squint apps.
|
||||
|
||||
So we need to make sure to have components that are abstract, I'm also fine with duplication of the varions components as only LLMs will modify it.
|
||||
|
||||
We can write out the components using clojure reader tags like
|
||||
|
||||
```cljc
|
||||
#?(:cljs (js/console.log x)
|
||||
:clj (println x))
|
||||
```
|
||||
|
||||
So we need component levels for
|
||||
|
||||
- Squint (/home/floscr/Code/My/ressources/skills/squint/SKILL.md)
|
||||
- Clojurescript with replicant
|
||||
- Pure hiccup components from a backend that get targeted via something like HTMX or vanilla JS (no interaction needed, just styles)
|
||||
|
||||
The components should have a themable system that gets defined via edn but transforms to css variables.
|
||||
- So we should easily have dark / light mode.
|
||||
- Multiple levlels of strokes
|
||||
- Multiple levels of shadows
|
||||
- Multiple levels of backgrounds
|
||||
|
||||
Here are some examples where I want to use these components
|
||||
|
||||
- ~/Code/Projects/org-mode-agenda-cli/app/collab-v2/src/
|
||||
(Replicant cljs)
|
||||
- /home/floscr/Code/Projects/piui/
|
||||
(Replicant cljs with
|
||||
- With /home/floscr/Code/Projects/piui/apps/
|
||||
Apps are written in (squint + eucalypt)
|
||||
- /home/floscr/Code/Projects/org-mode-agenda-cli/app/calendar
|
||||
(squint eucalypt)
|
||||
|
||||
As a first step we could do simple button component that works for all the targets.
|
||||
Ideally we have a test page where we can tab between the targets.
|
||||
|
||||
Something like https://github.com/cjohansen/portfolio as a storybook alternative would be awesome but we can handroll and keep it simple.
|
||||
48
src/theme/tokens.edn
Normal file
48
src/theme/tokens.edn
Normal file
@@ -0,0 +1,48 @@
|
||||
{:tokens
|
||||
{:bg-0 "#ffffff"
|
||||
:bg-1 "#f5f5f5"
|
||||
:bg-2 "#e8e8e8"
|
||||
:fg-0 "#1a1a1a"
|
||||
:fg-1 "#4a4a4a"
|
||||
:fg-2 "#8a8a8a"
|
||||
:accent "#2563eb"
|
||||
:fg-on-accent "#ffffff"
|
||||
:danger "#dc2626"
|
||||
:fg-on-danger "#ffffff"
|
||||
:success "#16a34a"
|
||||
:fg-on-success "#ffffff"
|
||||
:border-0 "1px solid #e0e0e0"
|
||||
:border-1 "1px solid #cccccc"
|
||||
:border-2 "1px solid #999999"
|
||||
:shadow-0 "0 1px 2px rgba(0,0,0,0.05)"
|
||||
:shadow-1 "0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06)"
|
||||
:shadow-2 "0 4px 6px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.06)"
|
||||
:shadow-3 "0 10px 15px rgba(0,0,0,0.1), 0 4px 6px rgba(0,0,0,0.05)"
|
||||
:radius-sm "4px"
|
||||
:radius-md "6px"
|
||||
:radius-lg "12px"}
|
||||
|
||||
:themes
|
||||
{:dark
|
||||
{:bg-0 "#121212"
|
||||
:bg-1 "#1e1e1e"
|
||||
:bg-2 "#2a2a2a"
|
||||
:fg-0 "#e8e8e8"
|
||||
:fg-1 "#b0b0b0"
|
||||
:fg-2 "#707070"
|
||||
:accent "#3b82f6"
|
||||
:fg-on-accent "#ffffff"
|
||||
:danger "#ef4444"
|
||||
:fg-on-danger "#ffffff"
|
||||
:success "#22c55e"
|
||||
:fg-on-success "#ffffff"
|
||||
:border-0 "1px solid #2a2a2a"
|
||||
:border-1 "1px solid #3a3a3a"
|
||||
:border-2 "1px solid #555555"
|
||||
:shadow-0 "0 1px 2px rgba(0,0,0,0.2)"
|
||||
:shadow-1 "0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2)"
|
||||
:shadow-2 "0 4px 6px rgba(0,0,0,0.3), 0 2px 4px rgba(0,0,0,0.2)"
|
||||
:shadow-3 "0 10px 15px rgba(0,0,0,0.3), 0 4px 6px rgba(0,0,0,0.2)"
|
||||
:radius-sm "4px"
|
||||
:radius-md "6px"
|
||||
:radius-lg "12px"}}}
|
||||
64
src/ui/button.cljc
Normal file
64
src/ui/button.cljc
Normal file
@@ -0,0 +1,64 @@
|
||||
(ns ui.button
|
||||
(:require [clojure.string :as str]))
|
||||
|
||||
;; In 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)))
|
||||
|
||||
(def default-variant "secondary")
|
||||
(def default-size "md")
|
||||
|
||||
(defn button-class-list
|
||||
"Generate a vector of CSS class strings for a button given variant and size.
|
||||
Returns e.g. [\"btn\" \"btn--primary\" \"btn--lg\"]."
|
||||
[{:keys [variant size]}]
|
||||
(let [v (or (some-> variant kw-name) default-variant)
|
||||
s (or (some-> size kw-name) default-size)]
|
||||
(cond-> ["btn" (str "btn--" v)]
|
||||
(not= s "md") (conj (str "btn--" s)))))
|
||||
|
||||
(defn button-classes
|
||||
"Generate CSS class string for a button. Returns a space-joined string."
|
||||
[opts]
|
||||
(str/join " " (button-class-list opts)))
|
||||
|
||||
(defn button
|
||||
"Render a button element. Works across all targets via reader conditionals.
|
||||
|
||||
Props:
|
||||
:variant - :primary, :secondary, :ghost, :danger
|
||||
:size - :sm, :md, :lg
|
||||
:on-click - click handler (ignored in :clj target)
|
||||
:disabled - boolean
|
||||
:class - additional CSS classes (string or vector)
|
||||
:attrs - additional HTML attributes map"
|
||||
[{:keys [variant size on-click disabled class attrs] :as _props} & children]
|
||||
#?(:squint
|
||||
(let [classes (cond-> (button-classes {:variant variant :size size})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes}
|
||||
(when disabled {:disabled true})
|
||||
attrs)]
|
||||
(into [:button (cond-> base-attrs
|
||||
on-click (assoc :on-click on-click))]
|
||||
children))
|
||||
|
||||
:cljs
|
||||
(let [cls (button-class-list {:variant variant :size size})
|
||||
classes (cond-> cls
|
||||
class (conj class))
|
||||
base-attrs (merge {:class classes}
|
||||
(when disabled {:disabled true})
|
||||
attrs)]
|
||||
(into [:button (cond-> base-attrs
|
||||
on-click (assoc-in [:on :click] on-click))]
|
||||
children))
|
||||
|
||||
:clj
|
||||
(let [classes (cond-> (button-classes {:variant variant :size size})
|
||||
class (str " " class))
|
||||
base-attrs (merge {:class classes}
|
||||
(when disabled {:disabled true})
|
||||
attrs)]
|
||||
(into [:button base-attrs] children))))
|
||||
107
src/ui/css/gen.clj
Normal file
107
src/ui/css/gen.clj
Normal file
@@ -0,0 +1,107 @@
|
||||
(ns ui.css.gen
|
||||
(:require [clojure.edn :as edn]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn read-tokens
|
||||
"Read and parse the tokens EDN file."
|
||||
[path]
|
||||
(edn/read-string (slurp path)))
|
||||
|
||||
(defn token->css-var
|
||||
"Convert a token keyword to a CSS variable name."
|
||||
[k]
|
||||
(str "--" (name k)))
|
||||
|
||||
(defn tokens->css-block
|
||||
"Generate CSS variable declarations from a token map."
|
||||
[tokens]
|
||||
(->> tokens
|
||||
(sort-by key)
|
||||
(map (fn [[k v]] (str " " (token->css-var k) ": " v ";")))
|
||||
(str/join "\n")))
|
||||
|
||||
(defn component-css-button
|
||||
"Generate BEM-lite CSS for the button component."
|
||||
[]
|
||||
(str/join "\n\n"
|
||||
[".btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5em;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
|
||||
font-family: inherit;
|
||||
}"
|
||||
".btn--primary {
|
||||
background: var(--accent);
|
||||
color: var(--fg-on-accent);
|
||||
}
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}"
|
||||
".btn--secondary {
|
||||
background: var(--bg-1);
|
||||
color: var(--fg-0);
|
||||
border: var(--border-0);
|
||||
}
|
||||
.btn--secondary:hover:not(:disabled) {
|
||||
background: var(--bg-2);
|
||||
}"
|
||||
".btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--fg-0);
|
||||
}
|
||||
.btn--ghost:hover:not(:disabled) {
|
||||
background: var(--bg-1);
|
||||
}"
|
||||
".btn--danger {
|
||||
background: var(--danger);
|
||||
color: var(--fg-on-danger);
|
||||
}
|
||||
.btn--danger:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}"
|
||||
".btn--sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}"
|
||||
".btn--lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
}"
|
||||
".btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}"]))
|
||||
|
||||
(defn generate-css
|
||||
"Generate the full CSS output from parsed token data."
|
||||
[{:keys [tokens themes]}]
|
||||
(let [dark-tokens (get themes :dark)
|
||||
root-block (str ":root {\n" (tokens->css-block tokens) "\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"
|
||||
(str/replace (tokens->css-block dark-tokens) #"(?m)^ " " ")
|
||||
"\n }\n}")
|
||||
components (component-css-button)]
|
||||
(str/join "\n\n" [root-block dark-attr dark-media components ""])))
|
||||
|
||||
(defn build-theme!
|
||||
"Read tokens from file and write generated CSS to output."
|
||||
[{:keys [input output]}]
|
||||
(let [token-data (read-tokens input)
|
||||
css (generate-css token-data)]
|
||||
(io/make-parents output)
|
||||
(spit output css)
|
||||
(println (str "Generated " output " (" (count (str/split-lines css)) " lines)"))))
|
||||
7
src/ui/theme.cljc
Normal file
7
src/ui/theme.cljc
Normal file
@@ -0,0 +1,7 @@
|
||||
(ns ui.theme)
|
||||
|
||||
(defn css-var
|
||||
"Reference a CSS variable by token keyword.
|
||||
(css-var :accent) => \"var(--accent)\""
|
||||
[token]
|
||||
(str "var(--" (name token) ")"))
|
||||
76
test/ui/button_test.clj
Normal file
76
test/ui/button_test.clj
Normal file
@@ -0,0 +1,76 @@
|
||||
(ns ui.button-test
|
||||
(:require [clojure.test :refer [deftest is testing]]
|
||||
[ui.button :as button]))
|
||||
|
||||
(deftest button-class-list-test
|
||||
(testing "default variant and size"
|
||||
(is (= ["btn" "btn--secondary"] (button/button-class-list {}))))
|
||||
|
||||
(testing "explicit variant"
|
||||
(is (= ["btn" "btn--primary"] (button/button-class-list {:variant :primary})))
|
||||
(is (= ["btn" "btn--ghost"] (button/button-class-list {:variant :ghost})))
|
||||
(is (= ["btn" "btn--danger"] (button/button-class-list {:variant :danger}))))
|
||||
|
||||
(testing "explicit size"
|
||||
(is (= ["btn" "btn--secondary" "btn--sm"] (button/button-class-list {:size :sm})))
|
||||
(is (= ["btn" "btn--secondary"] (button/button-class-list {:size :md})))
|
||||
(is (= ["btn" "btn--secondary" "btn--lg"] (button/button-class-list {:size :lg}))))
|
||||
|
||||
(testing "variant + size combined"
|
||||
(is (= ["btn" "btn--primary" "btn--lg"] (button/button-class-list {:variant :primary :size :lg})))))
|
||||
|
||||
(deftest button-classes-test
|
||||
(testing "default variant and size"
|
||||
(is (= "btn btn--secondary" (button/button-classes {}))))
|
||||
|
||||
(testing "explicit variant"
|
||||
(is (= "btn btn--primary" (button/button-classes {:variant :primary})))
|
||||
(is (= "btn btn--ghost" (button/button-classes {:variant :ghost})))
|
||||
(is (= "btn btn--danger" (button/button-classes {:variant :danger})))
|
||||
(is (= "btn btn--secondary" (button/button-classes {:variant :secondary}))))
|
||||
|
||||
(testing "explicit size"
|
||||
(is (= "btn btn--secondary btn--sm" (button/button-classes {:size :sm})))
|
||||
(is (= "btn btn--secondary" (button/button-classes {:size :md})))
|
||||
(is (= "btn btn--secondary btn--lg" (button/button-classes {:size :lg}))))
|
||||
|
||||
(testing "variant + size combined"
|
||||
(is (= "btn btn--primary btn--lg" (button/button-classes {:variant :primary :size :lg})))
|
||||
(is (= "btn btn--danger btn--sm" (button/button-classes {:variant :danger :size :sm}))))
|
||||
|
||||
(testing "nil variant falls back to default"
|
||||
(is (= "btn btn--secondary" (button/button-classes {:variant nil}))))
|
||||
|
||||
(testing "nil size falls back to default (md, no size class)"
|
||||
(is (= "btn btn--secondary" (button/button-classes {:size nil}))))
|
||||
|
||||
(testing "string variants work"
|
||||
(is (= "btn btn--primary" (button/button-classes {:variant "primary"})))))
|
||||
|
||||
(deftest button-component-test
|
||||
(testing "basic button renders correct hiccup (clj target)"
|
||||
(let [result (button/button {:variant :primary} "Click me")]
|
||||
(is (= :button (first result)))
|
||||
(is (= "btn btn--primary" (get-in result [1 :class])))
|
||||
(is (= "Click me" (nth result 2)))))
|
||||
|
||||
(testing "disabled button has disabled attr"
|
||||
(let [result (button/button {:variant :primary :disabled true} "Disabled")]
|
||||
(is (true? (get-in result [1 :disabled])))))
|
||||
|
||||
(testing "non-disabled button has no disabled attr"
|
||||
(let [result (button/button {:variant :primary} "Enabled")]
|
||||
(is (nil? (get-in result [1 :disabled])))))
|
||||
|
||||
(testing "extra class gets appended"
|
||||
(let [result (button/button {:variant :primary :class "extra"} "Test")]
|
||||
(is (= "btn btn--primary extra" (get-in result [1 :class])))))
|
||||
|
||||
(testing "extra attrs get merged"
|
||||
(let [result (button/button {:variant :primary :attrs {:id "my-btn"}} "Test")]
|
||||
(is (= "my-btn" (get-in result [1 :id])))))
|
||||
|
||||
(testing "multiple children"
|
||||
(let [result (button/button {:variant :primary} "A" "B")]
|
||||
(is (= "A" (nth result 2)))
|
||||
(is (= "B" (nth result 3))))))
|
||||
64
test/ui/theme_test.clj
Normal file
64
test/ui/theme_test.clj
Normal file
@@ -0,0 +1,64 @@
|
||||
(ns ui.theme-test
|
||||
(:require [clojure.test :refer [deftest is testing]]
|
||||
[clojure.string :as str]
|
||||
[ui.css.gen :as gen]
|
||||
[ui.theme :as theme]))
|
||||
|
||||
(deftest css-var-test
|
||||
(testing "generates correct CSS var reference"
|
||||
(is (= "var(--accent)" (theme/css-var :accent)))
|
||||
(is (= "var(--bg-0)" (theme/css-var :bg-0)))
|
||||
(is (= "var(--shadow-2)" (theme/css-var :shadow-2)))))
|
||||
|
||||
(deftest token->css-var-test
|
||||
(testing "converts keyword to CSS variable name"
|
||||
(is (= "--accent" (gen/token->css-var :accent)))
|
||||
(is (= "--bg-0" (gen/token->css-var :bg-0)))))
|
||||
|
||||
(deftest generate-css-test
|
||||
(let [token-data (gen/read-tokens "src/theme/tokens.edn")
|
||||
css (gen/generate-css token-data)]
|
||||
|
||||
(testing "contains :root block"
|
||||
(is (str/includes? css ":root {")))
|
||||
|
||||
(testing "contains all base tokens as CSS variables"
|
||||
(doseq [token [:bg-0 :bg-1 :bg-2 :fg-0 :fg-1 :fg-2
|
||||
:accent :danger :success
|
||||
:border-0 :border-1 :border-2
|
||||
:shadow-0 :shadow-1 :shadow-2 :shadow-3
|
||||
:radius-sm :radius-md :radius-lg]]
|
||||
(is (str/includes? css (str "--" (name token) ":"))
|
||||
(str "Missing token: " (name token)))))
|
||||
|
||||
(testing "contains dark theme data attribute selector"
|
||||
(is (str/includes? css "[data-theme=\"dark\"]")))
|
||||
|
||||
(testing "contains dark theme media query"
|
||||
(is (str/includes? css "@media (prefers-color-scheme: dark)")))
|
||||
|
||||
(testing "dark media query excludes explicit light theme"
|
||||
(is (str/includes? css ":root:not([data-theme=\"light\"])")))
|
||||
|
||||
(testing "contains button component CSS"
|
||||
(is (str/includes? css ".btn {"))
|
||||
(is (str/includes? css ".btn--primary {"))
|
||||
(is (str/includes? css ".btn--secondary {"))
|
||||
(is (str/includes? css ".btn--ghost {"))
|
||||
(is (str/includes? css ".btn--danger {"))
|
||||
(is (str/includes? css ".btn--sm {"))
|
||||
(is (str/includes? css ".btn--lg {"))
|
||||
(is (str/includes? css ".btn:disabled {")))))
|
||||
|
||||
(deftest tokens-roundtrip-test
|
||||
(testing "tokens.edn parses without error"
|
||||
(let [data (gen/read-tokens "src/theme/tokens.edn")]
|
||||
(is (map? (:tokens data)))
|
||||
(is (map? (get-in data [:themes :dark])))))
|
||||
|
||||
(testing "dark theme has same keys as base tokens"
|
||||
(let [data (gen/read-tokens "src/theme/tokens.edn")
|
||||
base-keys (set (keys (:tokens data)))
|
||||
dark-keys (set (keys (get-in data [:themes :dark])))]
|
||||
(is (= base-keys dark-keys)
|
||||
"Dark theme should override all base tokens"))))
|
||||
Reference in New Issue
Block a user