From 9f3ebe453fe6b6a0ad196243cf3b036f26409bf9 Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Wed, 11 Mar 2026 16:53:08 +0100 Subject: [PATCH] refactor(dev): dogfood framework CSS in theme adapter panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all inline styles in theme-adapter.js with framework classes. The panel now uses its own tokens (var(--bg-1), var(--fg-0), etc.) so it visually adapts when you change theme colors — true dogfooding. New framework components added to fill gaps: - popover.css — fixed-position floating panel (.popover, .popover-br) - chip.css + chip.cljc — selectable preset buttons (.chip, .chip-active) - swatch.css — color preview strips (.swatch-row, .swatch) - button.css — icon-only buttons (.btn-icon, .btn-icon-round) - card.css — sectioned card variant (.card-flush, .card-section) - utilities.css — text/flex helpers (.text-xs, .font-semibold, .flex-1, etc.) Theme adapter JS shrunk from 340 to 250 lines by removing the 60-line inline style object and applyStyle() helper. --- bb.edn | 2 + dev/theme-adapter.js | 288 +++++++++++++++--------------------------- src/ui/button.css | 18 +++ src/ui/card.css | 13 ++ src/ui/chip.cljc | 54 ++++++++ src/ui/chip.css | 37 ++++++ src/ui/popover.css | 12 ++ src/ui/swatch.css | 13 ++ src/ui/utilities.css | 13 ++ test/ui/chip_test.clj | 36 ++++++ 10 files changed, 300 insertions(+), 186 deletions(-) create mode 100644 src/ui/chip.cljc create mode 100644 src/ui/chip.css create mode 100644 src/ui/popover.css create mode 100644 src/ui/swatch.css create mode 100644 test/ui/chip_test.clj diff --git a/bb.edn b/bb.edn index 298d44b..5fbd5bd 100644 --- a/bb.edn +++ b/bb.edn @@ -38,6 +38,7 @@ [ui.form-test] [ui.icon-test] [ui.sidebar-test] + [ui.chip-test] [ui.theme-test]) :task (let [{:keys [fail error]} (t/run-tests 'ui.button-test @@ -57,6 +58,7 @@ 'ui.form-test 'ui.icon-test 'ui.sidebar-test + 'ui.chip-test 'ui.theme-test)] (when (pos? (+ fail error)) (System/exit 1)))} diff --git a/dev/theme-adapter.js b/dev/theme-adapter.js index 215eea3..9039fa7 100644 --- a/dev/theme-adapter.js +++ b/dev/theme-adapter.js @@ -1,16 +1,14 @@ // Theme Adapter — live color scale editor for dev preview +// Uses the framework's own CSS classes (dogfooding). // Self-contained IIFE. Persists state in localStorage. (function() { 'use strict'; // ── OKLCH helpers ─────────────────────────────────────────────── - // CSS oklch() is used directly — no hex conversion needed. - // oklch(L C H) where L: 0-1, C: 0-~0.4, H: 0-360 degrees. function oklchCSS(l, c, h) { return 'oklch(' + l.toFixed(3) + ' ' + c.toFixed(4) + ' ' + h.toFixed(1) + ')'; } - // OKLCH → sRGB hex for swatch preview (canvas-free fallback) function oklchToHex(L, C, H) { var hRad = H * Math.PI / 180; var a = C * Math.cos(hRad), b = C * Math.sin(hRad); @@ -28,7 +26,6 @@ } // ── Scale step definitions [label, lightness, baseChroma] ────── - // OKLCH: L is 0-1 (perceptual lightness), C is chroma (colorfulness) var GRAY_STEPS = [ [50,0.975,0.003],[100,0.955,0.005],[200,0.915,0.010],[300,0.850,0.012],[400,0.690,0.025], [500,0.530,0.035],[600,0.425,0.035],[700,0.350,0.035],[800,0.245,0.025],[900,0.190,0.016],[950,0.145,0.011] @@ -39,7 +36,6 @@ ]; // ── Presets ──────────────────────────────────────────────────── - // Presets use OKLCH hue (0-360) and chroma multiplier (0-2) var PRESETS = [ { name: 'Purple', grayHue: 285, graySat: 1.0, accentHue: 286, accentSat: 1.0 }, { name: 'Blue', grayHue: 255, graySat: 0.5, accentHue: 255, accentSat: 0.87 }, @@ -89,7 +85,6 @@ function reset() { state = assign({}, DEFAULT, { open: true }); - // Remove inline overrides so CSS file values take effect var root = document.documentElement.style; GRAY_STEPS.forEach(function(s) { root.removeProperty('--gray-' + s[0]); }); ACCENT_STEPS.forEach(function(s) { root.removeProperty('--accent-' + s[0]); }); @@ -114,98 +109,42 @@ ' :steps [' + fmtSteps(ACCENT_STEPS, state.accentSat).trimStart() + ']}}'; } - // ── UI ───────────────────────────────────────────────────────── - var panel, swatchGray, swatchAccent, inputs = {}; + // ── DOM helpers ──────────────────────────────────────────────── + var panel, toggleBtn, swatchGray, swatchAccent, presetBtns = [], inputs = {}; - function el(tag, attrs, children) { + function el(tag, cls, children) { var e = document.createElement(tag); - if (attrs) for (var k in attrs) { - if (k === 'style' && typeof attrs[k] === 'object') { - for (var s in attrs[k]) e.style[s] = attrs[k][s]; - } else if (k.slice(0,2) === 'on') { - e.addEventListener(k.slice(2), attrs[k]); - } else { - e.setAttribute(k, attrs[k]); - } - } - if (children) { - if (typeof children === 'string') e.textContent = children; - else if (Array.isArray(children)) children.forEach(function(c) { if (c) e.appendChild(c); }); - } + if (cls) e.className = cls; + if (typeof children === 'string') e.textContent = children; + else if (Array.isArray(children)) children.forEach(function(c) { if (c) e.appendChild(c); }); return e; } - var S = { - panel: { - position: 'fixed', bottom: '16px', right: '16px', zIndex: '99999', - fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", - fontSize: '12px', lineHeight: '1.4', - }, - card: { - background: '#1a1a26', color: '#e0e0e8', border: '1px solid #2a2a3a', - borderRadius: '12px', width: '280px', overflow: 'hidden', - boxShadow: '0 8px 32px rgba(0,0,0,0.4)', - }, - header: { - display: 'flex', alignItems: 'center', justifyContent: 'space-between', - padding: '10px 12px', borderBottom: '1px solid #2a2a3a', - }, - title: { fontWeight: '600', fontSize: '12px', letterSpacing: '0.02em' }, - body: { padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: '10px' }, - presetRow: { display: 'flex', flexWrap: 'wrap', gap: '4px' }, - preset: { - padding: '3px 8px', border: '1px solid #3a3a4a', borderRadius: '6px', - background: 'transparent', color: '#b0b0c0', cursor: 'pointer', - fontSize: '11px', fontFamily: 'inherit', transition: 'all 0.15s', - }, - presetActive: { - background: '#7a5afc', borderColor: '#7a5afc', color: '#fff', - }, - section: { display: 'flex', flexDirection: 'column', gap: '6px' }, - label: { fontWeight: '500', fontSize: '11px', color: '#8a8aa0', textTransform: 'uppercase', letterSpacing: '0.05em' }, - sliderRow: { display: 'flex', alignItems: 'center', gap: '8px' }, - sliderLabel: { width: '26px', fontSize: '10px', color: '#6a6a80', flexShrink: '0' }, - sliderValue: { width: '32px', fontSize: '10px', color: '#9a9aac', textAlign: 'right', flexShrink: '0', fontFamily: "'JetBrains Mono', monospace" }, - slider: { flex: '1', height: '4px', WebkitAppearance: 'none', appearance: 'none', borderRadius: '2px', outline: 'none', cursor: 'pointer' }, - swatches: { display: 'flex', gap: '2px' }, - swatch: { flex: '1', height: '14px', borderRadius: '3px' }, - footer: { padding: '8px 12px', borderTop: '1px solid #2a2a3a', display: 'flex', gap: '6px' }, - btn: { - flex: '1', padding: '5px 8px', border: '1px solid #3a3a4a', borderRadius: '6px', - background: 'transparent', color: '#b0b0c0', cursor: 'pointer', - fontSize: '11px', fontFamily: 'inherit', textAlign: 'center', - }, - toggle: { - width: '36px', height: '36px', borderRadius: '10px', - background: '#1a1a26', border: '1px solid #2a2a3a', cursor: 'pointer', - display: 'flex', alignItems: 'center', justifyContent: 'center', - fontSize: '16px', boxShadow: '0 4px 12px rgba(0,0,0,0.3)', - position: 'fixed', bottom: '16px', right: '16px', zIndex: '99999', - }, - }; - - function applyStyle(elem, style) { - for (var k in style) elem.style[k] = style[k]; - } - function hueGradient() { - return 'linear-gradient(to right, hsl(0,80%,50%),hsl(60,80%,50%),hsl(120,80%,50%),hsl(180,80%,50%),hsl(240,80%,50%),hsl(300,80%,50%),hsl(360,80%,50%))'; + return 'linear-gradient(to right, oklch(0.65 0.15 0),oklch(0.65 0.15 60),oklch(0.65 0.15 120),oklch(0.65 0.15 180),oklch(0.65 0.15 240),oklch(0.65 0.15 300),oklch(0.65 0.15 360))'; } - function satGradient(hue) { + function chromaGradient(hue) { return 'linear-gradient(to right, oklch(0.5 0 ' + hue + '), oklch(0.5 0.15 ' + hue + '))'; } + // ── Build slider row ────────────────────────────────────────── function makeSlider(id, label, min, max, step, gradient) { - var row = el('div'); - applyStyle(row, S.sliderRow); - var lbl = el('span', null, label); - applyStyle(lbl, S.sliderLabel); - var input = el('input', { type: 'range', min: String(min), max: String(max), step: String(step) }); - applyStyle(input, S.slider); - input.style.background = gradient || '#3a3a4a'; - var val = el('span', null, '0'); - applyStyle(val, S.sliderValue); + var row = el('div', 'flex items-center gap-2'); + + var lbl = el('span', 'text-xs text-faint shrink-0', label); + lbl.style.width = '26px'; + + var input = el('input', 'form-range flex-1'); + input.type = 'range'; + input.min = String(min); + input.max = String(max); + input.step = String(step); + if (gradient) input.style.background = gradient; + + var val = el('span', 'text-xs text-muted shrink-0 font-mono text-right', '0'); + val.style.width = '32px'; + row.appendChild(lbl); row.appendChild(input); row.appendChild(val); @@ -213,86 +152,82 @@ return row; } + // ── Build swatch row ────────────────────────────────────────── function makeSwatches() { - var row = el('div'); - applyStyle(row, S.swatches); + var row = el('div', 'swatch-row'); var items = []; for (var i = 0; i < 11; i++) { - var sw = el('div'); - applyStyle(sw, S.swatch); + var sw = el('div', 'swatch'); row.appendChild(sw); items.push(sw); } return { row: row, items: items }; } + // ── Build panel ─────────────────────────────────────────────── function buildPanel() { - // Toggle button (shown when panel is closed) - var toggle = el('button', { onclick: function() { state.open = true; save(); updateUI(); } }, '\u{1F3A8}'); - applyStyle(toggle, S.toggle); + var root = el('div', 'popover popover-br'); - // Panel - panel = el('div'); - applyStyle(panel, S.panel); + // Toggle button (visible when panel closed) + toggleBtn = el('button', 'btn btn-secondary btn-icon btn-icon-round', '\u{1F3A8}'); + toggleBtn.addEventListener('click', function() { + state.open = true; save(); updateUI(); + }); - var card = el('div'); - applyStyle(card, S.card); + // Panel card + panel = el('div', 'card card-flush'); + panel.style.width = '280px'; - // Header - var header = el('div'); - applyStyle(header, S.header); - var title = el('span', null, 'Theme'); - applyStyle(title, S.title); - var closeBtn = el('button', { onclick: function() { state.open = false; save(); updateUI(); } }, '\u00d7'); - applyStyle(closeBtn, { background: 'none', border: 'none', color: '#6a6a80', cursor: 'pointer', fontSize: '16px', padding: '0 2px', fontFamily: 'inherit' }); - var resetBtn = el('button', { onclick: reset }, 'Reset'); - applyStyle(resetBtn, { background: 'none', border: '1px solid #3a3a4a', color: '#6a6a80', cursor: 'pointer', fontSize: '10px', padding: '2px 6px', borderRadius: '4px', fontFamily: 'inherit', marginLeft: 'auto', marginRight: '8px' }); + // ─ Header section ─ + var header = el('div', 'card-section flex items-center justify-between'); + var title = el('span', 'font-semibold text-sm', 'Theme'); + var headerActions = el('div', 'flex items-center gap-2'); + var resetBtn = el('button', 'btn btn-ghost btn-sm', 'Reset'); + resetBtn.addEventListener('click', reset); + var closeBtn = el('button', 'btn btn-ghost btn-icon btn-sm', '\u00d7'); + closeBtn.addEventListener('click', function() { + state.open = false; save(); updateUI(); + }); + headerActions.appendChild(resetBtn); + headerActions.appendChild(closeBtn); header.appendChild(title); - header.appendChild(resetBtn); - header.appendChild(closeBtn); + header.appendChild(headerActions); - // Body - var body = el('div'); - applyStyle(body, S.body); + // ─ Body section ─ + var body = el('div', 'card-section vstack gap-3'); // Presets - var presetRow = el('div'); - applyStyle(presetRow, S.presetRow); + var presetRow = el('div', 'hstack gap-1'); PRESETS.forEach(function(p) { - var btn = el('button', { onclick: function() { + var dot = el('span', 'chip-dot'); + dot.style.background = oklchToHex(0.595, 0.23 * p.accentSat, p.accentHue); + + var btn = el('button', 'chip'); + btn.appendChild(dot); + btn.appendChild(document.createTextNode(p.name)); + btn.addEventListener('click', function() { state.grayHue = p.grayHue; state.graySat = p.graySat; state.accentHue = p.accentHue; state.accentSat = p.accentSat; apply(); - }}, p.name); - applyStyle(btn, S.preset); - // Small color dot - var dot = el('span'); - dot.style.cssText = 'display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:4px;vertical-align:middle;'; - dot.style.background = oklchToHex(0.595, 0.23 * p.accentSat, p.accentHue); - btn.insertBefore(dot, btn.firstChild); + }); + presetBtns.push({ el: btn, preset: p }); presetRow.appendChild(btn); }); - // Gray section - var graySection = el('div'); - applyStyle(graySection, S.section); - var grayLabel = el('div', null, 'Gray'); - applyStyle(grayLabel, S.label); - graySection.appendChild(grayLabel); + // Gray scale section + var graySection = el('div', 'vstack gap-2'); + graySection.appendChild(el('div', 'text-xs text-faint uppercase tracking-wide font-semibold', 'Gray')); graySection.appendChild(makeSlider('grayHue', 'Hue', 0, 360, 1, hueGradient())); graySection.appendChild(makeSlider('graySat', 'Chr', 0, 200, 1)); var graySwatches = makeSwatches(); swatchGray = graySwatches.items; graySection.appendChild(graySwatches.row); - // Accent section - var accentSection = el('div'); - applyStyle(accentSection, S.section); - var accentLabel = el('div', null, 'Accent'); - applyStyle(accentLabel, S.label); - accentSection.appendChild(accentLabel); + // Accent scale section + var accentSection = el('div', 'vstack gap-2'); + accentSection.appendChild(el('div', 'text-xs text-faint uppercase tracking-wide font-semibold', 'Accent')); accentSection.appendChild(makeSlider('accentHue', 'Hue', 0, 360, 1, hueGradient())); accentSection.appendChild(makeSlider('accentSat', 'Chr', 0, 200, 1)); var accentSwatches = makeSwatches(); @@ -303,26 +238,25 @@ body.appendChild(graySection); body.appendChild(accentSection); - // Footer - var footer = el('div'); - applyStyle(footer, S.footer); - var copyBtn = el('button', { onclick: function() { + // ─ Footer section ─ + var footer = el('div', 'card-section'); + var copyBtn = el('button', 'btn btn-secondary btn-sm w-full', 'Copy EDN'); + copyBtn.addEventListener('click', function() { var edn = generateEDN(); navigator.clipboard.writeText(edn).then(function() { copyBtn.textContent = 'Copied!'; setTimeout(function() { copyBtn.textContent = 'Copy EDN'; }, 1500); }); - }}, 'Copy EDN'); - applyStyle(copyBtn, S.btn); + }); footer.appendChild(copyBtn); - card.appendChild(header); - card.appendChild(body); - card.appendChild(footer); - panel.appendChild(card); - - document.body.appendChild(toggle); - document.body.appendChild(panel); + // Assemble + panel.appendChild(header); + panel.appendChild(body); + panel.appendChild(footer); + root.appendChild(toggleBtn); + root.appendChild(panel); + document.body.appendChild(root); // Slider events inputs.grayHue.input.addEventListener('input', function(e) { @@ -337,54 +271,37 @@ inputs.accentSat.input.addEventListener('input', function(e) { state.accentSat = parseInt(e.target.value) / 100; apply(); }); - - // Style range inputs (webkit + moz) - var css = document.createElement('style'); - css.textContent = [ - '#ta-root input[type=range]::-webkit-slider-thumb {', - ' -webkit-appearance: none; width: 14px; height: 14px;', - ' border-radius: 50%; background: #fff; border: 2px solid #7a5afc;', - ' cursor: pointer; margin-top: -5px; box-shadow: 0 1px 4px rgba(0,0,0,0.3);', - '}', - '#ta-root input[type=range]::-moz-range-thumb {', - ' width: 14px; height: 14px; border-radius: 50%;', - ' background: #fff; border: 2px solid #7a5afc; cursor: pointer;', - ' box-shadow: 0 1px 4px rgba(0,0,0,0.3);', - '}', - '#ta-root input[type=range]::-webkit-slider-runnable-track {', - ' height: 4px; border-radius: 2px;', - '}', - '#ta-root input[type=range]::-moz-range-track {', - ' height: 4px; border-radius: 2px;', - '}', - ].join('\n'); - document.head.appendChild(css); - panel.id = 'ta-root'; - toggle.id = 'ta-toggle'; - - // Reference for toggle visibility - panel._toggle = toggle; } + // ── Update UI ───────────────────────────────────────────────── function updateUI() { if (!panel) return; + // Show/hide - panel.style.display = state.open ? 'block' : 'none'; - panel._toggle.style.display = state.open ? 'none' : 'flex'; + panel.style.display = state.open ? 'flex' : 'none'; + toggleBtn.style.display = state.open ? 'none' : 'inline-flex'; // Sync slider values inputs.grayHue.input.value = state.grayHue; inputs.grayHue.value.textContent = state.grayHue + '\u00b0'; inputs.graySat.input.value = Math.round(state.graySat * 100); inputs.graySat.value.textContent = Math.round(state.graySat * 100) + '%'; - inputs.graySat.input.style.background = satGradient(state.grayHue); + inputs.graySat.input.style.background = chromaGradient(state.grayHue); inputs.accentHue.input.value = state.accentHue; inputs.accentHue.value.textContent = state.accentHue + '\u00b0'; inputs.accentSat.input.value = Math.round(state.accentSat * 100); inputs.accentSat.value.textContent = Math.round(state.accentSat * 100) + '%'; - inputs.accentSat.input.style.background = satGradient(state.accentHue); + inputs.accentSat.input.style.background = chromaGradient(state.accentHue); - // Swatches — use oklch() CSS for accurate preview + // Preset active states — uses .chip / .chip-active + presetBtns.forEach(function(item) { + var p = item.preset; + var isActive = (state.grayHue === p.grayHue && state.graySat === p.graySat && + state.accentHue === p.accentHue && state.accentSat === p.accentSat); + item.el.className = isActive ? 'chip chip-active' : 'chip'; + }); + + // Swatches if (swatchGray) { GRAY_STEPS.forEach(function(s, i) { var c = Math.min(0.4, s[2] * state.graySat); @@ -400,15 +317,8 @@ } // ── Init ─────────────────────────────────────────────────────── - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } - function init() { buildPanel(); - // Apply saved non-default state if (state.grayHue !== DEFAULT.grayHue || state.graySat !== DEFAULT.graySat || state.accentHue !== DEFAULT.accentHue || state.accentSat !== DEFAULT.accentSat) { apply(); @@ -416,4 +326,10 @@ updateUI(); } } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } })(); diff --git a/src/ui/button.css b/src/ui/button.css index 06c73a4..33df2f5 100644 --- a/src/ui/button.css +++ b/src/ui/button.css @@ -78,6 +78,24 @@ a.btn-link { line-height: var(--size-6); } +.btn-icon { + padding: var(--size-2); + line-height: 1; + aspect-ratio: 1; +} + +.btn-icon.btn-sm { + padding: var(--size-1); +} + +.btn-icon.btn-lg { + padding: var(--size-3); +} + +.btn-icon-round { + border-radius: 9999px; +} + .btn:disabled { opacity: 0.5; cursor: not-allowed; diff --git a/src/ui/card.css b/src/ui/card.css index 3bafeb0..bc85fe1 100644 --- a/src/ui/card.css +++ b/src/ui/card.css @@ -11,6 +11,19 @@ padding: var(--size-6); } +.card-flush { + padding: 0; + gap: 0; +} + +.card-section { + padding: var(--size-3) var(--size-4); +} + +.card-section + .card-section { + border-top: var(--border-0); +} + .card-header { display: flex; flex-direction: column; diff --git a/src/ui/chip.cljc b/src/ui/chip.cljc new file mode 100644 index 0000000..835aff3 --- /dev/null +++ b/src/ui/chip.cljc @@ -0,0 +1,54 @@ +(ns ui.chip + (:require [clojure.string :as str])) + +#?(:squint (defn- kw-name [s] s) + :cljs (defn- kw-name [s] (name s)) + :clj (defn- kw-name [s] (name s))) + +(defn chip-class-list + "Returns a vector of CSS class strings for a chip." + [{:keys [active]}] + (cond-> ["chip"] + active (conj "chip-active"))) + +(defn chip-classes + "Returns a space-joined class string." + [opts] + (str/join " " (chip-class-list opts))) + +(defn chip + "A small selectable button for tags, filters, presets. + Props: :active, :dot-color, :on-click, :class, :attrs" + [{:keys [active dot-color on-click class attrs] :as _props} & children] + #?(:squint + (let [classes (cond-> (chip-classes {:active active}) + class (str " " class)) + base-attrs (cond-> (merge {:class classes} attrs) + on-click (assoc :on-click on-click)) + dot (when dot-color + [:span {:class "chip-dot" :style {"background" dot-color}}])] + (cond-> [:button base-attrs] + dot (conj dot) + true (into children))) + + :cljs + (let [cls (chip-class-list {:active active}) + classes (cond-> cls class (conj class)) + base-attrs (cond-> (merge {:class classes} attrs) + on-click (assoc :on {:click on-click})) + dot (when dot-color + [:span {:class ["chip-dot"] :style {:background dot-color}}])] + (cond-> [:button base-attrs] + dot (conj dot) + true (into children))) + + :clj + (let [classes (cond-> (chip-classes {:active active}) + class (str " " class)) + base-attrs (merge {:class classes} attrs) + dot (when dot-color + [:span {:class "chip-dot" + :style (str "background:" dot-color)}])] + (cond-> [:button base-attrs] + dot (conj dot) + true (into children))))) diff --git a/src/ui/chip.css b/src/ui/chip.css new file mode 100644 index 0000000..769e116 --- /dev/null +++ b/src/ui/chip.css @@ -0,0 +1,37 @@ +/* ── Chip ───────────────────────────────────────────────────────── */ +/* Small selectable buttons for tags, filters, presets. */ + +.chip { + display: inline-flex; + align-items: center; + gap: var(--size-1); + padding: var(--size-1) var(--size-2); + font-size: var(--font-xs); + font-weight: 500; + line-height: var(--size-4); + border: var(--border-0); + border-radius: var(--radius-sm); + background: transparent; + color: var(--fg-1); + cursor: pointer; + font-family: inherit; + transition: background 150ms ease, border-color 150ms ease, color 150ms ease; +} + +.chip:hover:not(.chip-active) { + background: var(--bg-2); +} + +.chip-active { + background: var(--accent); + border-color: var(--accent); + color: var(--fg-on-accent); +} + +.chip-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 9999px; + flex-shrink: 0; +} diff --git a/src/ui/popover.css b/src/ui/popover.css new file mode 100644 index 0000000..303d1ec --- /dev/null +++ b/src/ui/popover.css @@ -0,0 +1,12 @@ +/* ── Popover ────────────────────────────────────────────────────── */ +/* Fixed-position floating panel. Combine with a corner class. */ + +.popover { + position: fixed; + z-index: 9999; +} + +.popover-br { bottom: var(--size-4); right: var(--size-4); } +.popover-bl { bottom: var(--size-4); left: var(--size-4); } +.popover-tr { top: var(--size-4); right: var(--size-4); } +.popover-tl { top: var(--size-4); left: var(--size-4); } diff --git a/src/ui/swatch.css b/src/ui/swatch.css new file mode 100644 index 0000000..c455107 --- /dev/null +++ b/src/ui/swatch.css @@ -0,0 +1,13 @@ +/* ── Swatch ─────────────────────────────────────────────────────── */ +/* Color preview strip — a row of small colored boxes. */ + +.swatch-row { + display: flex; + gap: 2px; +} + +.swatch { + flex: 1; + height: var(--size-4); + border-radius: var(--radius-sm); +} diff --git a/src/ui/utilities.css b/src/ui/utilities.css index 58f2543..e445ba3 100644 --- a/src/ui/utilities.css +++ b/src/ui/utilities.css @@ -37,6 +37,19 @@ .mb-6 { margin-bottom: var(--size-6); } .p-4 { padding: var(--size-4); } +.flex-1 { flex: 1; min-width: 0; } +.shrink-0 { flex-shrink: 0; } +.flex-row { flex-direction: row; } +.flex-wrap { flex-wrap: wrap; } + +.text-xs { font-size: var(--font-xs); } +.text-sm { font-size: var(--font-sm); } +.text-right { text-align: right; } +.font-semibold { font-weight: 600; } +.font-mono { font-family: ui-monospace, 'JetBrains Mono', monospace; } +.uppercase { text-transform: uppercase; } +.tracking-wide { letter-spacing: 0.05em; } + .w-full { width: 100%; } .sr-only { position: absolute; diff --git a/test/ui/chip_test.clj b/test/ui/chip_test.clj new file mode 100644 index 0000000..55348e0 --- /dev/null +++ b/test/ui/chip_test.clj @@ -0,0 +1,36 @@ +(ns ui.chip-test + (:require [clojure.test :refer [deftest is testing]] + [ui.chip :as chip])) + +(deftest chip-class-list-test + (testing "default chip" + (is (= ["chip"] (chip/chip-class-list {})))) + (testing "active chip" + (is (= ["chip" "chip-active"] (chip/chip-class-list {:active true}))))) + +(deftest chip-classes-test + (testing "space-joined" + (is (= "chip" (chip/chip-classes {}))) + (is (= "chip chip-active" (chip/chip-classes {:active true}))))) + +(deftest chip-component-test + (testing "basic chip renders button" + (let [[tag attrs & children] (chip/chip {} "Label")] + (is (= :button tag)) + (is (= "chip" (:class attrs))) + (is (= ["Label"] children)))) + + (testing "active chip" + (let [[tag attrs] (chip/chip {:active true} "Active")] + (is (= :button tag)) + (is (= "chip chip-active" (:class attrs))))) + + (testing "chip with dot-color" + (let [[_tag _attrs dot label] (chip/chip {:dot-color "#ff0000"} "Red")] + (is (= :span (first dot))) + (is (= "chip-dot" (:class (second dot)))) + (is (= "Red" label)))) + + (testing "custom class" + (let [[_tag attrs] (chip/chip {:class "extra"} "X")] + (is (= "chip extra" (:class attrs))))))