From 41811dba8860e889a2377939ba026049ac589798 Mon Sep 17 00:00:00 2001 From: Florian Schroedl Date: Wed, 11 Mar 2026 11:51:59 +0100 Subject: [PATCH] feat(dev): add live theme adapter panel for color customization Floating panel in bottom-right of all dev targets lets you: - Switch presets (Purple, Blue, Neutral, Warm, Rose, Emerald) - Adjust gray hue/saturation and accent hue/saturation with sliders - Preview color swatches in real-time - Copy EDN config to paste into tokens.edn State persists in localStorage. Panel collapses to a small toggle button. Hiccup handler changed to use #'handler var for hot-reload. --- bb.edn | 6 +- dev/hiccup/src/dev/hiccup.clj | 8 +- dev/replicant/public/index.html | 1 + dev/squint/index.html | 1 + dev/theme-adapter.js | 414 ++++++++++++++++++++++++++++++++ 5 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 dev/theme-adapter.js diff --git a/bb.edn b/bb.edn index b8bdd7f..298d44b 100644 --- a/bb.edn +++ b/bb.edn @@ -12,7 +12,11 @@ (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")))} + (println "Copied theme.css to dev targets")) + (let [adapter (slurp "dev/theme-adapter.js")] + (spit "dev/replicant/public/theme-adapter.js" adapter) + (spit "dev/squint/public/theme-adapter.js" adapter) + (println "Copied theme-adapter.js to dev targets")))} test {:doc "Run all unit tests" diff --git a/dev/hiccup/src/dev/hiccup.clj b/dev/hiccup/src/dev/hiccup.clj index 97473f3..abebf04 100644 --- a/dev/hiccup/src/dev/hiccup.clj +++ b/dev/hiccup/src/dev/hiccup.clj @@ -466,6 +466,7 @@ [:style (h/raw "html, body { margin: 0; padding: 0; }")] [:script (h/raw theme-persistence-script)]] [:body + [:script {:src "/theme-adapter.js" :defer true}] (sidebar/sidebar-layout {} (app-sidebar active-page port) (sidebar/sidebar-overlay {}) @@ -492,6 +493,11 @@ :headers {"Content-Type" "text/css"} :body (slurp "dist/theme.css")} + (= path "/theme-adapter.js") + {:status 200 + :headers {"Content-Type" "application/javascript"} + :body (slurp "dev/theme-adapter.js")} + (resolve-page path) {:status 200 :headers {"Content-Type" "text/html; charset=utf-8"} @@ -505,4 +511,4 @@ (defn start! [{:keys [port] :or {port 3003}}] (reset! !port port) (println (str "Hiccup server running at http://localhost:" port)) - (http/run-server handler {:port port})) + (http/run-server #'handler {:port port})) diff --git a/dev/replicant/public/index.html b/dev/replicant/public/index.html index ca22061..2e0b2a2 100644 --- a/dev/replicant/public/index.html +++ b/dev/replicant/public/index.html @@ -46,5 +46,6 @@
+ diff --git a/dev/squint/index.html b/dev/squint/index.html index 8da1a77..4024506 100644 --- a/dev/squint/index.html +++ b/dev/squint/index.html @@ -46,5 +46,6 @@
+ diff --git a/dev/theme-adapter.js b/dev/theme-adapter.js new file mode 100644 index 0000000..6699b4d --- /dev/null +++ b/dev/theme-adapter.js @@ -0,0 +1,414 @@ +// Theme Adapter — live color scale editor for dev preview +// Self-contained IIFE. Persists state in localStorage. +(function() { + 'use strict'; + + // ── HSL → Hex ────────────────────────────────────────────────── + function hslToHex(h, s, l) { + h = ((h % 360) + 360) % 360; + s = Math.max(0, Math.min(100, s)) / 100; + l = Math.max(0, Math.min(100, l)) / 100; + var c = (1 - Math.abs(2 * l - 1)) * s; + var x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + var m = l - c / 2; + var r, g, b; + if (h < 60) { r = c; g = x; b = 0; } + else if (h < 120) { r = x; g = c; b = 0; } + else if (h < 180) { r = 0; g = c; b = x; } + else if (h < 240) { r = 0; g = x; b = c; } + else if (h < 300) { r = x; g = 0; b = c; } + else { r = c; g = 0; b = x; } + function toHex(v) { + var n = Math.round((v + m) * 255); + var hex = n.toString(16); + return hex.length === 1 ? '0' + hex : hex; + } + return '#' + toHex(r) + toHex(g) + toHex(b); + } + + // ── Scale step definitions [label, lightness, baseSaturation] ── + var GRAY_STEPS = [ + [50,97,14],[100,95,14],[200,90,12],[300,82,10],[400,64,10], + [500,46,10],[600,34,12],[700,26,16],[800,15,18],[900,9,18],[950,5,18] + ]; + var ACCENT_STEPS = [ + [50,97,100],[100,94,95],[200,89,95],[300,82,95],[400,75,93], + [500,67,96],[600,57,84],[700,49,72],[800,41,69],[900,34,67],[950,22,73] + ]; + + // ── Presets ──────────────────────────────────────────────────── + var PRESETS = [ + { name: 'Purple', grayHue: 240, graySat: 1.0, accentHue: 252, accentSat: 1.0 }, + { name: 'Blue', grayHue: 215, graySat: 0.4, accentHue: 221, accentSat: 0.87 }, + { name: 'Neutral', grayHue: 0, graySat: 0.0, accentHue: 221, accentSat: 0.87 }, + { name: 'Warm', grayHue: 30, graySat: 0.7, accentHue: 24, accentSat: 1.0 }, + { name: 'Rose', grayHue: 340, graySat: 0.5, accentHue: 340, accentSat: 0.85 }, + { name: 'Emerald', grayHue: 160, graySat: 0.5, accentHue: 160, accentSat: 0.75 }, + ]; + + // ── State ────────────────────────────────────────────────────── + var STORAGE_KEY = 'ui-fw-theme-adapter'; + var DEFAULT = { grayHue: 240, graySat: 1.0, accentHue: 252, accentSat: 1.0, open: false }; + var state = assign({}, DEFAULT); + try { + var saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); + if (saved) state = assign({}, DEFAULT, saved); + } catch(e) {} + + function assign(target) { + for (var i = 1; i < arguments.length; i++) { + var src = arguments[i]; + if (src) for (var k in src) if (src.hasOwnProperty(k)) target[k] = src[k]; + } + return target; + } + + function save() { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch(e) {} + } + + // ── Apply colors to :root ───────────────────────────────────── + function applyScale(name, hue, steps, satMul) { + var root = document.documentElement.style; + for (var i = 0; i < steps.length; i++) { + var label = steps[i][0], l = steps[i][1], baseSat = steps[i][2]; + var sat = Math.round(Math.min(100, baseSat * satMul)); + root.setProperty('--' + name + '-' + label, hslToHex(hue, sat, l)); + } + } + + function apply() { + applyScale('gray', state.grayHue, GRAY_STEPS, state.graySat); + applyScale('accent', state.accentHue, ACCENT_STEPS, state.accentSat); + save(); + updateUI(); + } + + 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]); }); + save(); + updateUI(); + } + + // ── Generate EDN for tokens.edn ─────────────────────────────── + function generateEDN() { + function fmtSteps(steps, satMul) { + return steps.map(function(s) { + var sat = Math.round(Math.min(100, s[2] * satMul)); + return ' [' + String(s[0]).padStart(3) + ' ' + String(s[1]).padStart(2) + ' ' + sat + ']'; + }).join('\n'); + } + return ':color\n' + + ' {:gray {:hue ' + state.grayHue + ' :saturation ' + Math.round(18 * state.graySat) + '\n' + + ' :steps [' + fmtSteps(GRAY_STEPS, state.graySat).trimStart() + ']}\n\n' + + ' :accent {:hue ' + state.accentHue + ' :saturation ' + Math.round(96 * state.accentSat) + '\n' + + ' :steps [' + fmtSteps(ACCENT_STEPS, state.accentSat).trimStart() + ']}}'; + } + + // ── UI ───────────────────────────────────────────────────────── + var panel, swatchGray, swatchAccent, inputs = {}; + + function el(tag, attrs, 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); }); + } + 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%))'; + } + + function satGradient(hue) { + return 'linear-gradient(to right, hsl(' + hue + ',0%,50%), hsl(' + hue + ',100%,50%))'; + } + + 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); + row.appendChild(lbl); + row.appendChild(input); + row.appendChild(val); + inputs[id] = { input: input, value: val }; + return row; + } + + function makeSwatches() { + var row = el('div'); + applyStyle(row, S.swatches); + var items = []; + for (var i = 0; i < 11; i++) { + var sw = el('div'); + applyStyle(sw, S.swatch); + row.appendChild(sw); + items.push(sw); + } + return { row: row, items: items }; + } + + 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); + + // Panel + panel = el('div'); + applyStyle(panel, S.panel); + + var card = el('div'); + applyStyle(card, S.card); + + // 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.appendChild(title); + header.appendChild(resetBtn); + header.appendChild(closeBtn); + + // Body + var body = el('div'); + applyStyle(body, S.body); + + // Presets + var presetRow = el('div'); + applyStyle(presetRow, S.presetRow); + PRESETS.forEach(function(p) { + var btn = el('button', { onclick: 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 = hslToHex(p.accentHue, Math.round(96 * p.accentSat), 67); + btn.insertBefore(dot, btn.firstChild); + 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); + graySection.appendChild(makeSlider('grayHue', 'Hue', 0, 360, 1, hueGradient())); + graySection.appendChild(makeSlider('graySat', 'Sat', 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); + accentSection.appendChild(makeSlider('accentHue', 'Hue', 0, 360, 1, hueGradient())); + accentSection.appendChild(makeSlider('accentSat', 'Sat', 0, 200, 1)); + var accentSwatches = makeSwatches(); + swatchAccent = accentSwatches.items; + accentSection.appendChild(accentSwatches.row); + + body.appendChild(presetRow); + body.appendChild(graySection); + body.appendChild(accentSection); + + // Footer + var footer = el('div'); + applyStyle(footer, S.footer); + var copyBtn = el('button', { onclick: 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); + + // Slider events + inputs.grayHue.input.addEventListener('input', function(e) { + state.grayHue = parseInt(e.target.value); apply(); + }); + inputs.graySat.input.addEventListener('input', function(e) { + state.graySat = parseInt(e.target.value) / 100; apply(); + }); + inputs.accentHue.input.addEventListener('input', function(e) { + state.accentHue = parseInt(e.target.value); apply(); + }); + 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; + } + + function updateUI() { + if (!panel) return; + // Show/hide + panel.style.display = state.open ? 'block' : 'none'; + panel._toggle.style.display = state.open ? 'none' : '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.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); + + // Swatches + if (swatchGray) { + GRAY_STEPS.forEach(function(s, i) { + var sat = Math.round(Math.min(100, s[2] * state.graySat)); + swatchGray[i].style.background = hslToHex(state.grayHue, sat, s[1]); + }); + } + if (swatchAccent) { + ACCENT_STEPS.forEach(function(s, i) { + var sat = Math.round(Math.min(100, s[2] * state.accentSat)); + swatchAccent[i].style.background = hslToHex(state.accentHue, sat, s[1]); + }); + } + } + + // ── 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(); + } else { + updateUI(); + } + } +})();