aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/static
diff options
context:
space:
mode:
Diffstat (limited to 'internal/admin/static')
-rw-r--r--internal/admin/static/admin.css37
-rw-r--r--internal/admin/static/coloreditor.js570
2 files changed, 607 insertions, 0 deletions
diff --git a/internal/admin/static/admin.css b/internal/admin/static/admin.css
index aeec09d..06a640c 100644
--- a/internal/admin/static/admin.css
+++ b/internal/admin/static/admin.css
@@ -491,3 +491,40 @@ details.obj-card summary.obj-card-header::-webkit-details-marker{display:none}
.obj-card[data-card="trigger_action"] .ie-inter-header .ie-trig-dn{display:none}
input:focus-visible,select:focus-visible,textarea:focus-visible,button:focus-visible{outline:2px solid rgba(100,160,255,.5);outline-offset:1px}
.sr-no-results{padding:6px 8px;font-size:11px;color:#666;text-align:center;cursor:default;pointer-events:none}
+
+/* ── Color themer ── */
+.color-themer{display:flex;flex:1;min-height:0}
+.color-list{width:340px;display:flex;flex-direction:column;padding-bottom:0}
+.color-list .search-bar{flex-shrink:0}
+#colorRows{flex:1;overflow-y:auto;padding-right:4px}
+.color-group{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:#777;margin:10px 0 4px 0;padding-bottom:2px;border-bottom:1px solid var(--border)}
+.color-row{display:flex;align-items:center;gap:6px;padding:4px 4px;border-radius:3px;margin-bottom:1px}
+.color-row:hover{background:rgba(15,52,96,.3)}
+.color-row.dirty{background:rgba(158,99,7,.12)}
+.color-row .color-swatch{width:22px;height:22px;flex-shrink:0;border-radius:3px;border:1px solid #555;cursor:pointer}
+.color-row .color-swatch.nocolor{background:repeating-linear-gradient(45deg,#333,#333 3px,#222 3px,#222 6px)!important;border-color:#444}
+.color-row .color-label{font-size:12px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
+.color-row .color-flags{display:flex;flex-direction:column;gap:1px;font-size:9px;color:#aaa}
+.color-row .color-flags label{display:flex;align-items:center;gap:2px;cursor:pointer}
+.color-row .color-flags input[type=checkbox]{margin:0}
+.color-row .color-adv{width:78px;padding:2px 4px;font-size:10px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace}
+.color-row .color-revert{flex-shrink:0;padding:0 4px;font-size:11px;line-height:1;background:none;border:none;color:#888;cursor:pointer}
+.color-row .color-revert:hover{color:#fff}
+.color-row .color-revert:disabled{opacity:.3;cursor:default}
+.color-list-actions{flex-shrink:0;display:flex;gap:4px;padding:8px 0 4px 0;border-top:1px solid var(--border);margin-top:4px}
+.color-save-bar{flex-shrink:0;display:flex;align-items:center;gap:8px;padding:8px 0 12px 0}
+.color-save-status{font-size:11px;color:#888}
+.color-save-status.ok{color:var(--success)}
+.color-save-status.err{color:var(--danger)}
+
+.color-preview-main{display:flex;flex-direction:column;padding-bottom:8px}
+.color-preview-toolbar{display:flex;align-items:center;gap:8px;flex-shrink:0;margin-bottom:8px}
+.color-preview-toolbar select{padding:3px 6px;background:var(--input-bg);color:var(--text);border:1px solid var(--input-border);border-radius:3px;font-family:monospace}
+.color-preview-hint{font-size:11px;color:#888}
+.color-preview-scroll{flex:1;overflow-y:auto;overflow-x:auto;border:1px solid var(--border);border-radius:4px}
+.color-preview-pre{margin:0;padding:8px;font-family:ui-monospace,"Cascadia Mono","Cascadia Code","JetBrains Mono","Fira Code",Menlo,Consolas,"DejaVu Sans Mono","Courier New",monospace;font-size:12px;line-height:1;white-space:pre-wrap;color:#e0e0e0;background:#000}
+.cp-title{color:#9ab;display:block;margin:8px 0 3px 0;font-size:11px;text-transform:uppercase;letter-spacing:.5px;font-weight:bold}
+.cp-title:first-child{margin-top:0}
+/* Picker popup tweaks for the themer (richer than bindColorField's) */
+#__cp_popover .cp-grid .cp-swatch{width:14px;height:14px}
+#__cp_popover .cp-swatch.selected{outline:2px solid #fff;outline-offset:1px}
diff --git a/internal/admin/static/coloreditor.js b/internal/admin/static/coloreditor.js
new file mode 100644
index 0000000..dc219f6
--- /dev/null
+++ b/internal/admin/static/coloreditor.js
@@ -0,0 +1,570 @@
+// 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}]
+ categoriesById: {}, // name -> cat
+ saved: {}, // name -> spec string (last persisted)
+ staged: {}, // name -> spec string (unsaved edits)
+ defaults: {}, // name -> spec string (builtins, for reset)
+ preview: [], // [{title, lines:[{segments:[{t,c}]}]}]
+ dirty: {} // name -> true
+ };
+
+ 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(' ');
+ }
+
+ // Current effective spec for a category (staged overrides saved).
+ function effectiveSpec(name) {
+ if (STATE.dirty[name]) return STATE.staged[name];
+ return STATE.saved[name] || STATE.defaults[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 = '';
+ var byGroup = {};
+ var order = [];
+ STATE.categories.forEach(function (c) {
+ if (!byGroup[c.group]) { byGroup[c.group] = []; order.push(c.group); }
+ byGroup[c.group].push(c);
+ });
+ order.forEach(function (g) {
+ host.appendChild(el('div', 'color-group', g));
+ byGroup[g].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);
+ STATE.staged[cat.name] = specStr;
+ STATE.dirty[cat.name] = (specStr !== (STATE.saved[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', !!STATE.dirty[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 = !STATE.dirty[cat.name];
+ }
+
+ function revertRow(row, cat) {
+ delete STATE.staged[cat.name];
+ delete STATE.dirty[cat.name];
+ var spec = parseSpec(STATE.saved[cat.name] || STATE.defaults[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 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:340px';
+ 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);
+
+ var grid = el('div', 'cp-grid');
+ for (var i = 0; i < 256; i++) {
+ var sw = el('div', 'cp-swatch');
+ sw.className = 'cp-swatch';
+ (function (idx, ee) {
+ ee.style.background = window.xtermToCss ? xtermToCss(idx) : '#000';
+ ee.dataset.idx = idx;
+ ee.title = ('0' + idx.toString(16)).slice(-2).toUpperCase() + ' (' + idx + ')';
+ })(i, sw);
+ grid.appendChild(sw);
+ }
+ popover.appendChild(grid);
+
+ document.body.appendChild(popover);
+
+ grid.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) {
+ var title = el('span', 'cp-title', sec.title);
+ pre.appendChild(title);
+ 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 payload = {};
+ Object.keys(STATE.dirty).forEach(function (k) {
+ if (STATE.dirty[k]) payload[k] = effectiveSpec(k);
+ });
+ fetch('/api/colors', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ colors: payload })
+ }).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); });
+ STATE.dirty = {};
+ 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 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];
+ });
+ buildRows();
+ renderColorPreview();
+ }).catch(function (e) { alert('Reset failed: ' + e); });
+ }
+
+ function colorExport() {
+ var out = {};
+ STATE.categories.forEach(function (c) { out[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 (a {category: spec} object):');
+ if (!txt) return;
+ var obj;
+ try { obj = JSON.parse(txt); } catch (e) { alert('Invalid JSON: ' + e); return; }
+ var applied = 0;
+ Object.keys(obj).forEach(function (k) {
+ if (!STATE.categoriesById[k]) return;
+ var spec = parseSpec(obj[k]);
+ STATE.staged[k] = serializeSpec(spec);
+ STATE.dirty[k] = (STATE.staged[k] !== (STATE.saved[k] || ''));
+ applied++;
+ });
+ 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();
+ document.querySelectorAll('#colorRows .color-row').forEach(function (row) {
+ var name = row.dataset.name;
+ var label = (STATE.categoriesById[name] || {}).label || '';
+ row.style.display = (!q || name.indexOf(q) >= 0 || label.toLowerCase().indexOf(q) >= 0) ? '' : 'none';
+ });
+ document.querySelectorAll('#colorRows .color-group').forEach(function (g) {
+ // hide group header if all its rows are hidden — naive: check sibling visibility
+ var any = false, n = g.nextElementSibling;
+ while (n && !n.classList.contains('color-group')) {
+ if (n.classList.contains('color-row') && n.style.display !== 'none') { any = true; break; }
+ n = n.nextElementSibling;
+ }
+ g.style.display = any || !q ? '' : '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.preview = data.preview || [];
+ STATE.staged = {}; STATE.dirty = {};
+ Object.keys(STATE.saved).forEach(function (k) { STATE.staged[k] = STATE.saved[k]; });
+ 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;
+})(); \ No newline at end of file