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.
415 lines
17 KiB
JavaScript
415 lines
17 KiB
JavaScript
// 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();
|
|
}
|
|
}
|
|
})();
|