// Color themer: left-side category editor + right-side live example preview. // Self-contained picker that does NOT clobber bold/dim/bg tokens (unlike the // generic bindColorField), so a row's full color spec stays editable in parts. (function () { 'use strict'; var STATE = { categories: [], // [{name,label,group}] — player-overridable categoriesById: {}, // name -> cat (player) saved: {}, // name -> spec (last persisted) — player staged: {}, // name -> spec (unsaved) — player defaults: {}, // name -> spec (builtins) — player dirty: {}, // name -> true — player // Constant (admin-only) colors. Kept in parallel maps so the page can // render two top-level sections but share one save/reset pipeline. constantCategories: [], constantCategoriesById: {}, constantSaved: {}, constantStaged: {}, constantDefaults: {}, constantDirty: {}, preview: [], }; function $i(id) { return document.getElementById(id); } function el(tag, cls, txt) { var e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; } // ── Spec parse/serialize (JS port of internal/color.Parse) ────────────── function parseSpec(v) { v = (v || '').trim(); var s = { off: false, fg: -1, bg: -1, bold: false, dim: false, underline: false, gradient: null }; if (v === '' || v === 'off') { s.off = true; return s; } var toks = v.split(/\s+/); for (var i = 0; i < toks.length; i++) { var t = toks[i]; if (!t) continue; if (t === 'bold') { s.bold = true; continue; } if (t === 'dim') { s.dim = true; continue; } if (t === 'underline') { s.underline = true; continue; } if (t.indexOf('bg:') === 0) { var h = t.slice(3).replace(/[^0-9A-Fa-f]/g, '').substring(0, 2); if (h.length === 2) { var n = parseInt(h, 16); if (n >= 0 && n <= 255) s.bg = n; } continue; } if (t.indexOf('g:') === 0) { var parts = t.slice(2).split(',').map(function (p) { return p.trim(); }); var stops = []; for (var j = 0; j < parts.length; j++) { var gh = parts[j].replace(/[^0-9A-Fa-f]/g, '').substring(0, 2); if (gh.length === 2) stops.push(parseInt(gh, 16)); } if (stops.length >= 2) s.gradient = stops; continue; } var hx = t.replace(/[^0-9A-Fa-f]/g, '').substring(0, 2); if (hx.length === 2) { var fi = parseInt(hx, 16); if (fi >= 0 && fi <= 255) s.fg = fi; } } return s; } function serializeSpec(s) { if (!s || s.off) return s && s.off ? 'off' : ''; var parts = []; if (s.fg >= 0) parts.push(('0' + s.fg.toString(16)).slice(-2).toUpperCase()); if (s.bold) parts.push('bold'); if (s.dim) parts.push('dim'); if (s.underline) parts.push('underline'); if (s.bg >= 0) parts.push('bg:' + ('0' + s.bg.toString(16)).slice(-2).toUpperCase()); if (s.gradient && s.gradient.length >= 2) { parts.push('g:' + s.gradient.map(function (g) { return ('0' + g.toString(16)).slice(-2).toUpperCase(); }).join(',')); } return parts.join(' '); } // Returns 'constant' if the category name is a constant (admin-only) // color, otherwise 'player'. function sectionOf(name) { if (STATE.constantCategoriesById[name]) return 'constant'; return 'player'; } // Current effective spec for a category (staged overrides saved), works for // both player and constant sections. function effSaved(name) { return sectionOf(name) === 'constant' ? (STATE.constantSaved[name] || STATE.constantDefaults[name] || '') : (STATE.saved[name] || STATE.defaults[name] || ''); } function effStaged(name) { return sectionOf(name) === 'constant' ? STATE.constantStaged[name] : STATE.staged[name]; } function effDirty(name) { return sectionOf(name) === 'constant' ? STATE.constantDirty[name] : STATE.dirty[name]; } function setStaged(name, val) { if (sectionOf(name) === 'constant') STATE.constantStaged[name] = val; else STATE.staged[name] = val; } function setDirty(name, val) { if (sectionOf(name) === 'constant') STATE.constantDirty[name] = val; else STATE.dirty[name] = val; } function clearDirty(name) { if (sectionOf(name) === 'constant') delete STATE.constantDirty[name]; else delete STATE.dirty[name]; } function clearStaged(name) { if (sectionOf(name) === 'constant') delete STATE.constantStaged[name]; else delete STATE.staged[name]; } // Current effective spec for a category (staged overrides saved). function effectiveSpec(name) { if (effDirty(name)) return effStaged(name); return effSaved(name); } // ── 16-color ANSI projection (mirrors nearestANSI in color.go) ────────── var ANSI16 = [ [0, 0, 0], [170, 0, 0], [0, 170, 0], [170, 170, 0], [0, 0, 170], [170, 0, 170], [0, 170, 170], [170, 170, 170], [85, 85, 85], [255, 85, 85], [85, 255, 85], [255, 255, 85], [85, 85, 255], [255, 85, 255], [85, 255, 255], [255, 255, 255] ]; function rgbForCss(hex) { if (!hex) return [0, 0, 0]; hex = hex.replace('#', ''); return [parseInt(hex.substring(0, 2), 16), parseInt(hex.substring(2, 4), 16), parseInt(hex.substring(4, 6), 16)]; } function nearestAnsi16(idx) { var rgb = (window.xtermToRGB ? xtermToRGB(idx) : [0, 0, 0]); var best = 0, bestD = Infinity; for (var i = 0; i < 16; i++) { var dr = rgb[0] - ANSI16[i][0], dg = rgb[1] - ANSI16[i][1], db = rgb[2] - ANSI16[i][2]; var d = dr * dr + dg * dg + db * db; if (d < bestD) { bestD = d; best = i; } } var c = ANSI16[best]; return '#' + ('0' + c[0].toString(16)).slice(-2) + ('0' + c[1].toString(16)).slice(-2) + ('0' + c[2].toString(16)).slice(-2); } function nearest256OfRgb(r, g, b) { if (window.cssToXtermIdx) { var hex = ('0' + (r & 255).toString(16)).slice(-2) + ('0' + (g & 255).toString(16)).slice(-2) + ('0' + (b & 255).toString(16)).slice(-2); return cssToXtermIdx(hex); } return 0; } // ── Left-side row builder ────────────────────────────────────────────── function buildRows() { var host = $i('colorRows'); host.innerHTML = ''; // Section header, then flat ordered rows. No per-group dividers: the // order mirrors the in-game `colors` command exactly. host.appendChild(el('div', 'color-section-header', 'Player Colors')); STATE.categories.forEach(function (c) { host.appendChild(buildRow(c)); }); host.appendChild(el('div', 'color-section-header', 'Constants')); STATE.constantCategories.forEach(function (c) { host.appendChild(buildRow(c)); }); } function rowSwatchColor(spec) { if (spec.off || spec.fg < 0) return null; // no color → striped swatch return window.xtermToCss ? xtermToCss(spec.fg) : '#808080'; } function buildRow(cat) { var row = el('div', 'color-row'); row.dataset.name = cat.name; var savedSpec = parseSpec(effectiveSpec(cat.name)); row.dataset.fg = savedSpec.fg >= 0 ? savedSpec.fg : '-1'; var sw = el('div', 'color-swatch'); var sc = rowSwatchColor(savedSpec); if (sc) sw.style.background = sc; else sw.classList.add('nocolor'); sw.title = 'Click to pick foreground color'; var lbl = el('div', 'color-label', cat.label); var flags = el('div', 'color-flags'); function mkFlag(name, label) { var lab = el('label'); var cb = el('input'); cb.type = 'checkbox'; cb.dataset.flag = name; if (savedSpec[name]) cb.checked = true; lab.appendChild(cb); lab.appendChild(el('span', null, label)); return lab; } flags.appendChild(mkFlag('bold', 'B')); flags.appendChild(mkFlag('dim', 'D')); flags.appendChild(mkFlag('underline', 'U')); var adv = el('input', 'color-adv'); adv.type = 'text'; adv.placeholder = 'bg:/g:'; adv.value = extractAdvanced(savedSpec); adv.title = 'Advanced tokens: bg:HH or g:HH,HH,...'; adv.addEventListener('input', function () { commitRow(row, cat); }); var rbtn = el('button', 'color-revert', '\u21BA'); rbtn.title = 'Revert this row to the saved value'; row.appendChild(sw); row.appendChild(lbl); row.appendChild(flags); row.appendChild(adv); row.appendChild(rbtn); // wire events flags.addEventListener('change', function () { commitRow(row, cat); }); sw.addEventListener('click', function (e) { e.stopPropagation(); openPicker(row, cat, sw); }); lbl.addEventListener('click', function (e) { e.stopPropagation(); openPicker(row, cat, sw); }); rbtn.addEventListener('click', function (e) { e.stopPropagation(); revertRow(row, cat); }); refreshRow(row); return row; } // Read the row's current UI into a spec string, mark dirty, refresh. function commitRow(row, cat) { var s = { off: false, fg: -1, bg: -1, bold: false, dim: false, underline: false, gradient: null }; s.fg = parseInt(row.dataset.fg != null ? row.dataset.fg : '-1', 10); if (isNaN(s.fg)) s.fg = -1; var flags = row.querySelectorAll('.color-flags input'); for (var i = 0; i < flags.length; i++) { var f = flags[i].dataset.flag; if (f === 'bold') s.bold = flags[i].checked; if (f === 'dim') s.dim = flags[i].checked; if (f === 'underline') s.underline = flags[i].checked; } var adv = row.querySelector('.color-adv'); var advStr = adv.value.trim(); if (advStr) { var toks = advStr.split(/\s+/); for (var j = 0; j < toks.length; j++) { var t = toks[j]; if (t.indexOf('bg:') === 0) { var h = t.slice(3).replace(/[^0-9A-Fa-f]/g, '').substring(0, 2); if (h.length === 2) s.bg = parseInt(h, 16); } else if (t.indexOf('g:') === 0) { var parts = t.slice(2).split(',').map(function (p) { return p.trim(); }); var stops = []; for (var k = 0; k < parts.length; k++) { var gh = parts[k].replace(/[^0-9A-Fa-f]/g, '').substring(0, 2); if (gh.length === 2) stops.push(parseInt(gh, 16)); } if (stops.length >= 2) s.gradient = stops; } else if (t === 'off') { s.off = true; } } } var specStr = serializeSpec(s); setStaged(cat.name, specStr); setDirty(cat.name, (specStr !== effSaved(cat.name))); refreshRow(row); refreshRevertButton(row, cat); renderColorPreview(); } function extractAdvanced(spec) { if (!spec || spec.off) return ''; var parts = []; if (spec.bg >= 0) parts.push('bg:' + ('0' + spec.bg.toString(16)).slice(-2).toUpperCase()); if (spec.gradient && spec.gradient.length >= 2) { parts.push('g:' + spec.gradient.map(function (g) { return ('0' + g.toString(16)).slice(-2).toUpperCase(); }).join(',')); } return parts.join(' '); } function refreshRow(row) { row.classList.toggle('dirty', !!effDirty(row.dataset.name)); var spec = parseSpec(effectiveSpec(row.dataset.name)); var sw = row.querySelector('.color-swatch'); var sc = rowSwatchColor(spec); sw.classList.toggle('nocolor', !sc); sw.style.background = sc || ''; } function refreshRevertButton(row, cat) { var btn = row.querySelector('.color-revert'); btn.disabled = !effDirty(cat.name); } function revertRow(row, cat) { clearStaged(cat.name); clearDirty(cat.name); var spec = parseSpec(effSaved(cat.name)); row.dataset.fg = spec.fg >= 0 ? spec.fg : '-1'; row.querySelectorAll('.color-flags input').forEach(function (cb) { var f = cb.dataset.flag; cb.checked = !!spec[f]; }); row.querySelector('.color-adv').value = extractAdvanced(spec); refreshRow(row); refreshRevertButton(row, cat); renderColorPreview(); } // ── Picker popover (per-row, does not touch bold/dim/bg) ──────────────── var popover = null, boundRow = null, boundCat = null, boundSwatch = null; function makeSwatch(idx) { var sw = el('div', 'cp-swatch'); sw.dataset.idx = idx; sw.style.background = window.xtermToCss ? xtermToCss(idx) : '#000'; sw.title = ('0' + idx.toString(16)).slice(-2).toUpperCase() + ' (' + idx + ')'; return sw; } function ensurePicker() { if (popover) return; popover = el('div'); popover.id = '__ct_popover'; popover.style.cssText = 'display:none;position:fixed;z-index:300;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px;box-shadow:0 4px 20px rgba(0,0,0,.6);max-width:400px'; var head = el('div'); head.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:6px'; head.appendChild(el('span', 'color-swatch')); var hexIn = el('input'); hexIn.type = 'text'; hexIn.maxLength = 2; hexIn.style.cssText = 'width:50px;padding:3px 6px;font-size:11px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace;text-transform:uppercase'; var offB = el('button', 'btn btn-sm', 'Off'); var xB = el('button', 'btn btn-sm', '\u00d7'); xB.title = 'close'; head.appendChild(hexIn); head.appendChild(offB); head.appendChild(xB); popover.appendChild(head); // ANSI colors 0-15 (2 rows of 8) popover.appendChild(el('div', 'cp-section-label', 'ANSI')); var ansiGrid = el('div', 'cp-grid cp-ansi'); for (var i = 0; i < 16; i++) ansiGrid.appendChild(makeSwatch(i)); popover.appendChild(ansiGrid); // Color cube 16-231: six 6x6 slices (G=rows, B=cols, each slice one R level) popover.appendChild(el('div', 'cp-section-label', 'Color Cube')); var cubeRow = el('div', 'cp-cube-row'); for (var r = 0; r < 6; r++) { var cubeGrid = el('div', 'cp-grid cp-cube'); for (var g = 0; g < 6; g++) for (var b = 0; b < 6; b++) cubeGrid.appendChild(makeSwatch(16 + 36 * r + 6 * g + b)); cubeRow.appendChild(cubeGrid); } popover.appendChild(cubeRow); // Grayscale 232-255 (2 rows of 12) popover.appendChild(el('div', 'cp-section-label', 'Grayscale')); var grayGrid = el('div', 'cp-grid cp-gray'); for (var i = 232; i < 256; i++) grayGrid.appendChild(makeSwatch(i)); popover.appendChild(grayGrid); document.body.appendChild(popover); popover.addEventListener('click', function (e) { var s = e.target.closest('.cp-swatch'); if (!s) return; var idx = parseInt(s.dataset.idx, 10); setRowFg(boundRow, boundCat, idx); hexIn.value = ('0' + idx.toString(16)).slice(-2).toUpperCase(); highlightGrid(idx); popover.querySelector('.color-swatch').style.background = window.xtermToCss ? xtermToCss(idx) : '#000'; }); hexIn.addEventListener('input', function () { var v = this.value.toUpperCase().replace(/[^0-9A-F]/g, '').substring(0, 2); this.value = v; if (v.length === 2) { var idx = parseInt(v, 16); setRowFg(boundRow, boundCat, idx); highlightGrid(idx); popover.querySelector('.color-swatch').style.background = window.xtermToCss ? xtermToCss(idx) : '#000'; } }); offB.addEventListener('click', function () { setRowFg(boundRow, boundCat, -1); boundRow.querySelector('.color-swatch').classList.add('nocolor'); boundRow.querySelector('.color-swatch').style.background = ''; hidePicker(); }); xB.addEventListener('click', hidePicker); document.addEventListener('click', function (e) { if (!popover || popover.style.display === 'none') return; if (!popover.contains(e.target) && e.target !== boundSwatch) hidePicker(); }); } function highlightGrid(idx) { var all = popover.querySelectorAll('.cp-swatch'); for (var i = 0; i < all.length; i++) all[i].classList.remove('selected'); var sel = popover.querySelector('.cp-swatch[data-idx="' + idx + '"]'); if (sel) sel.classList.add('selected'); } function setRowFg(row, cat, idx) { row.dataset.fg = idx; commitRow(row, cat); var sw = row.querySelector('.color-swatch'); if (idx < 0) { sw.classList.add('nocolor'); sw.style.background = ''; } else { sw.classList.remove('nocolor'); sw.style.background = window.xtermToCss ? xtermToCss(idx) : '#808080'; } } function openPicker(row, cat, swatch) { ensurePicker(); boundRow = row; boundCat = cat; boundSwatch = swatch; var spec = parseSpec(effectiveSpec(cat.name)); var fg = spec.fg >= 0 ? spec.fg : 0; var hexIn = popover.querySelector('input[type=text]'); hexIn.value = ('0' + fg.toString(16)).slice(-2).toUpperCase(); popover.querySelector('.color-swatch').style.background = window.xtermToCss ? xtermToCss(fg) : '#000'; highlightGrid(fg); var rect = swatch.getBoundingClientRect(); popover.style.display = 'block'; var pw = popover.offsetWidth; var left = rect.left; if (left + pw > window.innerWidth - 10) left = Math.max(10, window.innerWidth - pw - 10); popover.style.left = left + 'px'; popover.style.top = (rect.bottom + 4) + 'px'; hexIn.focus(); hexIn.select(); } function hidePicker() { if (popover) popover.style.display = 'none'; } // ── Preview rendering ────────────────────────────────────────────────── function renderColorPreview() { var pre = $i('colorPreview'); if (!pre) return; var mode = ($i('colorMode') || {}).value || 'xterm256'; pre.innerHTML = ''; STATE.preview.forEach(function (sec, idx) { sec.lines.forEach(function (line) { var lineEl = el('div'); lineEl.style.minHeight = '1em'; line.segments.forEach(function (seg) { lineEl.appendChild(renderSegment(seg, mode)); }); pre.appendChild(lineEl); }); }); } function renderSegment(seg, mode) { var span = el('span'); span.textContent = seg.t; if (!seg.c || mode === 'none') return span; var spec = parseSpec(effectiveSpec(seg.c)); if (spec.off) return span; if (spec.bg >= 0) { var bgCss = mode === 'ansi16' ? nearestAnsi16(spec.bg) : (window.xtermToCss ? xtermToCss(spec.bg) : '#808080'); span.style.backgroundColor = bgCss; // pick a readable fg when the spec has none var rgb = rgbForCss(bgCss); span.style.color = (rgb[0] + rgb[1] + rgb[2] > 384) ? '#000' : '#fff'; } if (spec.gradient && spec.gradient.length >= 2 && mode === 'xterm256') { // per-character gradient ramp var stops = spec.gradient; var chars = Array.from(seg.t); var txt = document.createDocumentFragment(); chars.forEach(function (ch, i) { var t = chars.length > 1 ? i / (chars.length - 1) : 0; var idx = interpolateGradient(stops, t); var s = el('span'); s.textContent = ch; if (spec.bold) s.style.fontWeight = 'bold'; if (spec.dim) s.style.opacity = '0.55'; if (spec.underline) s.style.textDecoration = 'underline'; s.style.color = window.xtermToCss ? xtermToCss(idx) : '#808080'; if (spec.bg >= 0) { s.style.backgroundColor = span.style.backgroundColor; } txt.appendChild(s); }); return txt; } if (spec.fg >= 0) { var fgCss; if (mode === 'ansi16') fgCss = nearestAnsi16(spec.fg); else fgCss = window.xtermToCss ? xtermToCss(spec.fg) : '#808080'; span.style.color = fgCss; } if (spec.bold) span.style.fontWeight = 'bold'; if (spec.dim) span.style.opacity = '0.55'; if (spec.underline) span.style.textDecoration = 'underline'; return span; } function interpolateGradient(stops, t) { if (t <= 0) return stops[0]; if (t >= 1) return stops[stops.length - 1]; var seg2 = t * (stops.length - 1); var i = Math.floor(seg2); if (i >= stops.length - 1) i = stops.length - 2; var frac = seg2 - i; var a = window.xtermToRGB ? xtermToRGB(stops[i]) : [0, 0, 0]; var b = window.xtermToRGB ? xtermToRGB(stops[i + 1]) : [0, 0, 0]; var r = Math.round(a[0] + frac * (b[0] - a[0])); var g = Math.round(a[1] + frac * (b[1] - a[1])); var bb = Math.round(a[2] + frac * (b[2] - a[2])); return nearest256OfRgb(r, g, bb); } // ── Save / reset / export / import ───────────────────────────────────── function colorSave() { var status = $i('colorSaveStatus'); status.textContent = 'Saving...'; status.className = 'color-save-status'; var playerPayload = {}, constPayload = {}; Object.keys(STATE.dirty).forEach(function (k) { if (STATE.dirty[k]) playerPayload[k] = effectiveSpec(k); }); Object.keys(STATE.constantDirty).forEach(function (k) { if (STATE.constantDirty[k]) constPayload[k] = effectiveSpec(k); }); fetch('/api/colors', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ colors: playerPayload, constant_colors: constPayload }) }).then(function (r) { return r.json(); }).then(function (j) { if (j.ok) { // committed values become the new saved baseline Object.keys(STATE.dirty).forEach(function (k) { if (STATE.dirty[k]) STATE.saved[k] = effectiveSpec(k); }); Object.keys(STATE.constantDirty).forEach(function (k) { if (STATE.constantDirty[k]) STATE.constantSaved[k] = effectiveSpec(k); }); STATE.dirty = {}; STATE.constantDirty = {}; document.querySelectorAll('.color-row.dirty').forEach(function (row) { row.classList.remove('dirty'); var btn = row.querySelector('.color-revert'); if (btn) btn.disabled = true; }); status.textContent = 'Saved'; status.className = 'color-save-status ok'; } else { status.textContent = 'Error: ' + (j.error || 'unknown'); status.className = 'color-save-status err'; } }).catch(function (e) { status.textContent = 'Error: ' + e; status.className = 'color-save-status err'; }); } function colorResetAll() { if (!confirm('Reset ALL colors (player + constants) to the built-in defaults? This saves immediately.')) return; fetch('/api/colors/reset', { method: 'POST' }).then(function (r) { return r.json(); }).then(function (j) { if (!j.ok) { alert('Reset failed: ' + (j.error || 'unknown')); return; } Object.keys(j.colors || {}).forEach(function (k) { STATE.saved[k] = j.colors[k]; STATE.staged[k] = j.colors[k]; delete STATE.dirty[k]; }); Object.keys(j.constant_colors || {}).forEach(function (k) { STATE.constantSaved[k] = j.constant_colors[k]; STATE.constantStaged[k] = j.constant_colors[k]; delete STATE.constantDirty[k]; }); buildRows(); renderColorPreview(); }).catch(function (e) { alert('Reset failed: ' + e); }); } function colorExport() { var out = { colors: {}, constant_colors: {} }; STATE.categories.forEach(function (c) { out.colors[c.name] = effectiveSpec(c.name); }); STATE.constantCategories.forEach(function (c) { out.constant_colors[c.name] = effectiveSpec(c.name); }); var json = JSON.stringify(out, null, 2); var ta = el('textarea'); ta.value = json; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (e) {} document.body.removeChild(ta); var status = $i('colorSaveStatus'); status.textContent = 'Theme JSON copied to clipboard'; status.className = 'color-save-status ok'; setTimeout(function () { if (status.textContent === 'Theme JSON copied to clipboard') status.textContent = ''; }, 2500); } function colorImportPrompt() { var txt = prompt('Paste exported theme JSON ({colors:{...},constant_colors:{...}}):'); if (!txt) return; var obj; try { obj = JSON.parse(txt); } catch (e) { alert('Invalid JSON: ' + e); return; } // Accept either the layered {colors, constant_colors} shape or a flat // {category: spec} legacy object. var layerColors = obj.colors || obj; var layerConst = obj.constant_colors || {}; var applied = 0; function applyInto(map, idMap, stagedMap, dirtyMap) { Object.keys(map).forEach(function (k) { var cat = idMap[k]; if (!cat) return; var spec = parseSpec(map[k]); stagedMap[k] = serializeSpec(spec); var saved = sectionOf(k) === 'constant' ? STATE.constantSaved[k] : STATE.saved[k]; dirtyMap[k] = (stagedMap[k] !== (saved || '')); applied++; }); } applyInto(layerColors, STATE.categoriesById, STATE.staged, STATE.dirty); applyInto(layerConst, STATE.constantCategoriesById, STATE.constantStaged, STATE.constantDirty); if (applied === 0) { alert('No known categories found in the JSON.'); return; } buildRows(); renderColorPreview(); var status = $i('colorSaveStatus'); status.textContent = 'Imported ' + applied + ' colors (press Save to persist).'; status.className = 'color-save-status'; } function filterColorRows(q) { q = (q || '').toLowerCase(); // Group rows under their preceding section header so the header can be // hidden when its section has zero visible rows under a query. var headers = document.querySelectorAll('#colorRows .color-section-header'); headers.forEach(function (h) { var any = false, n = h.nextElementSibling; while (n && !n.classList.contains('color-section-header')) { if (n.classList.contains('color-row')) { var name = n.dataset.name; var idMap = sectionOf(name) === 'constant' ? STATE.constantCategoriesById : STATE.categoriesById; var label = (idMap[name] || {}).label || ''; var match = (!q || name.indexOf(q) >= 0 || label.toLowerCase().indexOf(q) >= 0); n.style.display = match ? '' : 'none'; if (match) any = true; } n = n.nextElementSibling; } h.style.display = (!q || any) ? '' : 'none'; }); } // ── Init ─────────────────────────────────────────────────────────────── window.initColorEditor = function () { fetch('/api/colors').then(function (r) { return r.json(); }).then(function (data) { STATE.categories = data.categories || []; STATE.categoriesById = {}; STATE.categories.forEach(function (c) { STATE.categoriesById[c.name] = c; }); STATE.saved = data.colors || {}; STATE.defaults = data.default_colors || {}; STATE.staged = {}; STATE.dirty = {}; Object.keys(STATE.saved).forEach(function (k) { STATE.staged[k] = STATE.saved[k]; }); STATE.constantCategories = data.constant_categories || []; STATE.constantCategoriesById = {}; STATE.constantCategories.forEach(function (c) { STATE.constantCategoriesById[c.name] = c; }); STATE.constantSaved = data.constant_colors || {}; STATE.constantDefaults = data.default_constant_colors || {}; STATE.constantStaged = {}; STATE.constantDirty = {}; Object.keys(STATE.constantSaved).forEach(function (k) { STATE.constantStaged[k] = STATE.constantSaved[k]; }); STATE.preview = data.preview || []; buildRows(); renderColorPreview(); }).catch(function (e) { $i('colorRows').textContent = 'Failed to load colors: ' + e; }); }; // expose for inline onclick handlers window.colorSave = colorSave; window.colorResetAll = colorResetAll; window.colorExport = colorExport; window.colorImportPrompt = colorImportPrompt; window.filterColorRows = filterColorRows; window.renderColorPreview = renderColorPreview; })();