From 7987d1d03119683cf0aa0e4063e89b1341b7200b Mon Sep 17 00:00:00 2001
From: M1racleShih <34762410+M1racleShih@users.noreply.github.com>
Date: Sun, 25 Oct 2026 09:25:18 +0000
Subject: [PATCH] feat(web): support optional custom color palettes
---
CLAUDE.md | 17 +-
README.md | 47 +++
public/web-theme-history-custom-dark.png | Bin 0 -> 127556 bytes
public/web-theme-history-custom.png | Bin 0 -> 126360 bytes
public/web-theme-history-light.png | Bin 0 -> 111838 bytes
public/web-theme-settings-custom.png | Bin 0 -> 263719 bytes
tests/run.sh | 2 +-
tests/test_web_assets.py | 17 +-
tests/test_web_custom_theme.py | 346 +++++++++++++++++++++++
tests/web_source.py | 3 +-
web/app.css | 15 +-
web/index.html | 30 +-
web/js/state.js | 61 ----
web/js/theme.js | 185 ++++++++++++
14 files changed, 639 insertions(+), 84 deletions(-)
create mode 100644 public/web-theme-history-custom-dark.png
create mode 100644 public/web-theme-history-custom.png
create mode 100644 public/web-theme-history-light.png
create mode 100644 public/web-theme-settings-custom.png
create mode 100644 tests/test_web_custom_theme.py
create mode 100644 web/js/theme.js
diff --git a/CLAUDE.md b/CLAUDE.md
index c371f722..3153df74 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -119,7 +119,7 @@ Runtime session overrides (per source) are persisted to `active_sessions.json` i
## Web App
-`web/` is `index.html` (markup) + `app.css` + eleven scripts under `web/js/`, and **still has no build
+`web/` is `index.html` (markup) + `app.css` + twelve scripts under `web/js/`, and **still has no build
step** — plain `', index)
- return [WEB_DIR / ref.lstrip("./") for ref in refs]
+ return [WEB_DIR / urlsplit(ref).path.lstrip("./") for ref in refs]
def web_source():
diff --git a/web/app.css b/web/app.css
index 24fa165e..e09bcf30 100644
--- a/web/app.css
+++ b/web/app.css
@@ -112,8 +112,8 @@ body { font-family: var(--font-mono); background: var(--bg); color: var(--text);
.setting-group button { margin-top: 8px; padding: 8px 16px; border-radius: 6px; border: none; background: var(--green); color: var(--on-accent); font-weight: 600; font-size: 13px; cursor: pointer; box-shadow: 0 1px 6px -1px color-mix(in srgb, var(--green) 45%, transparent); transition: transform 0.12s var(--ease), box-shadow 0.18s var(--ease), filter 0.18s var(--ease); }
.setting-group button:hover { filter: brightness(1.06); box-shadow: 0 2px 10px -1px color-mix(in srgb, var(--green) 55%, transparent); }
.setting-group button:active { transform: scale(0.97); box-shadow: none; }
-/* Appearance: one row, three exclusive choices, so a `radiogroup` rather than three `aria-pressed`
- toggles -- which would announce three independent switches, one of which happens to be on. The
+/* Appearance: one row of exclusive choices, so a `radiogroup` rather than `aria-pressed`
+ toggles -- which would announce independent switches instead of one choice. The
selected one fills with the INVERTED NEUTRAL, the same pair every toggle in the session view uses
and the only one guaranteed to contrast under both themes; blue here would read as the selection
colour the chips already own. Buttons override .setting-group button's margin, which is written
@@ -121,6 +121,17 @@ body { font-family: var(--font-mono); background: var(--bg); color: var(--text);
.theme-seg { display: flex; gap: 6px; margin-top: 2px; }
.theme-seg button { flex: 1; margin-top: 0; padding: 0; min-height: 32px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text); font-family: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
.theme-seg button[aria-checked="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
+.theme-seg button:disabled { opacity: 0.55; cursor: default; box-shadow: none; }
+.theme-editor { margin-top: 12px; }
+.theme-editor summary { color: var(--blue); cursor: pointer; font-size: 0.75rem; }
+.theme-editor .hint { margin: 8px 0; line-height: 1.5; overflow-wrap: anywhere; }
+.theme-editor label { margin-top: 8px; }
+.theme-editor textarea { display: block; width: 100%; max-width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); font-family: var(--font-mono); font-size: 16px; line-height: 1.4; resize: vertical; }
+.theme-editor textarea[aria-invalid="true"] { border-color: var(--red); }
+.theme-actions { display: flex; gap: 8px; flex-wrap: wrap; }
+.theme-actions button { min-height: 36px; }
+.theme-actions .theme-reset { background: var(--bg); color: var(--text); border: 1px solid var(--border); box-shadow: none; }
+#themeStatus[data-error="true"] { color: var(--red); }
/* Agent list */
diff --git a/web/index.html b/web/index.html
index eb8cd26f..f19e8e01 100644
--- a/web/index.html
+++ b/web/index.html
@@ -7,18 +7,8 @@
-
-
+
+
@@ -29,7 +19,7 @@
window.cue = play;
document.addEventListener('DOMContentLoaded', () => bind());
-
+
@@ -55,8 +45,20 @@
Settings
+
Auto follows the system.
+
+ Customize colors
+
Edit or paste a palette JSON, then apply to enable Custom. Use #RRGGBB colors with at least 4.5:1 text contrast. This changes the web interface and history, not live terminal colors.
+
+
+
+
+
+
+
+
@@ -216,7 +218,7 @@
Settings
-
+
diff --git a/web/js/state.js b/web/js/state.js
index 67037b81..c45ea1f2 100644
--- a/web/js/state.js
+++ b/web/js/state.js
@@ -48,67 +48,6 @@ const savedToken = localStorage.getItem('herdr_relay_token') || '';
document.getElementById('relayUrl').value = savedUrl;
document.getElementById('relayToken').value = savedToken;
-// Appearance -- Auto / Light / Dark.
-//
-// The VISUAL switch is pure CSS and none of this code's business: every token is a `light-dark()`
-// pair and the root's `color-scheme` picks the half, so Auto is correct with JavaScript disabled
-// entirely. What is left here is the part CSS cannot do:
-// 1. the pin attribute, in BOTH directions -- a stale `data-theme` has to come off, or Dark → Auto
-// leaves the page pinned dark until a reload;
-// 2. the theme-color metas, which carry `media` attributes and so follow the OS rather than a pin.
-// The script has already stamped the attribute for a pinned reader before first paint; this
-// is idempotent for that case and additionally fixes up the metas and the control.
-const THEME_KEY = 'herdr_theme';
-const THEME_META = {light: '#f5f5f5', dark: '#0a0a0a'}; // --bg's two halves, rasterized.
-
-function themePref() {
- try {
- const raw = localStorage.getItem(THEME_KEY);
- return raw === 'light' || raw === 'dark' ? raw : 'auto';
- } catch (e) { return 'auto'; }
-}
-
-function themeResolved(pref) {
- if (pref !== 'auto') return pref;
- return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
-}
-
-function applyTheme() {
- const pref = themePref(), resolved = themeResolved(pref);
- if (pref === 'auto') delete document.documentElement.dataset.theme;
- else document.documentElement.dataset.theme = pref;
- // Pinned: hand BOTH metas the pinned colour, so whichever one the browser's own media query
- // selects is the right answer. On Auto: give each back its own half and let the query decide.
- for (const meta of document.querySelectorAll('meta[name="theme-color"]')) {
- const own = (meta.getAttribute('media') || '').includes('dark') ? 'dark' : 'light';
- meta.content = THEME_META[pref === 'auto' ? own : resolved];
- }
- for (const btn of document.querySelectorAll('[data-theme-choice]'))
- btn.setAttribute('aria-checked', btn.dataset.themeChoice === pref ? 'true' : 'false');
- const note = document.getElementById('themeResolved');
- if (note) note.textContent = pref === 'auto' ? 'Now ' + resolved + '.' : '';
-}
-
-function setTheme(pref) {
- try {
- // Absent means Auto, so un-pinning REMOVES the key rather than storing a sentinel: the
- // pre-paint script's getItem then returns null and it does nothing, which is exactly right.
- if (pref === 'auto') localStorage.removeItem(THEME_KEY);
- else localStorage.setItem(THEME_KEY, pref);
- } catch (e) {} // Private mode: the choice still holds for this session.
- applyTheme();
- if (window.cue) cue('toggle');
-}
-
-// Follow the OS while on Auto, so an evening switch lands without a reload. The CSS half handles
-// itself; this keeps the metas and the control's readout honest.
-matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
- if (themePref() === 'auto') applyTheme();
-});
-applyTheme();
-
-
-
// Back-button navigation — every view or overlay that covers the agent list
// pushes a history entry when it opens, so the Android system back button (and
// the browser's) dismisses the top layer instead of leaving the app.
diff --git a/web/js/theme.js b/web/js/theme.js
new file mode 100644
index 00000000..ed1fcc02
--- /dev/null
+++ b/web/js/theme.js
@@ -0,0 +1,185 @@
+// Runs in , before the stylesheet: saved palettes must apply on the first paint.
+// Only these semantic colours are configurable. Never accept CSS, URLs, or terminal tokens.
+const THEME_KEY = 'herdr_theme';
+const CUSTOM_THEME_KEY = 'herdr_custom_theme';
+const THEME_META = {light: '#f5f5f5', dark: '#0a0a0a'};
+const THEME_TOKENS = ['bg', 'surface', 'border', 'text', 'muted', 'green', 'red', 'blue', 'orange', 'on-accent'];
+const THEME_MAX_LENGTH = 4096;
+
+function themeContrast(a, b) {
+ const luminance = hex => {
+ const channels = [1, 3, 5].map(i => {
+ const v = parseInt(hex.slice(i, i + 2), 16) / 255;
+ return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
+ });
+ return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
+ };
+ const x = luminance(a), y = luminance(b);
+ return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
+}
+
+function parseCustomTheme(raw) {
+ if (raw.length > THEME_MAX_LENGTH) throw new Error('Palette JSON must be at most 4096 characters.');
+ let value;
+ try { value = JSON.parse(raw); }
+ catch (e) { throw new Error('Enter valid JSON, using double quotes around names and colors.'); }
+ if (!value || typeof value !== 'object' || Array.isArray(value)
+ || Object.keys(value).some(k => k !== 'colorScheme' && k !== 'colors')
+ || !['light', 'dark'].includes(value.colorScheme))
+ throw new Error('Set colorScheme to "light" or "dark", and provide a colors object.');
+ const source = value.colors;
+ if (!source || typeof source !== 'object' || Array.isArray(source)
+ || Object.keys(source).length !== THEME_TOKENS.length
+ || THEME_TOKENS.some(k => !Object.hasOwn(source, k) || typeof source[k] !== 'string'
+ || !/^#[0-9a-f]{6}$/i.test(source[k])))
+ throw new Error('Provide exactly these colors as #RRGGBB: ' + THEME_TOKENS.join(', ') + '.');
+ const colors = Object.fromEntries(THEME_TOKENS.map(k => [k, source[k].toLowerCase()]));
+ // Keep the settings and recovery controls legible, not just the transcript's main text.
+ const accents = ['green', 'red', 'blue', 'orange'];
+ const pairs = ['bg', 'surface'].flatMap(bg => ['text', 'muted', ...accents].map(fg => [fg, bg]));
+ pairs.push(...accents.map(bg => ['on-accent', bg]));
+ for (const [fg, bg] of pairs) {
+ if (themeContrast(colors[fg], colors[bg]) < 4.5)
+ throw new Error(fg + ' on ' + bg + ' needs at least 4.5:1 contrast (WCAG AA).');
+ }
+ return {colorScheme: value.colorScheme, colors};
+}
+
+// Keep an in-memory choice too: a blocked/full localStorage must not make the control a no-op.
+let themeChoice = 'auto', customTheme = null;
+try {
+ const pref = localStorage.getItem(THEME_KEY);
+ if (['light', 'dark', 'custom'].includes(pref)) themeChoice = pref;
+ const raw = localStorage.getItem(CUSTOM_THEME_KEY);
+ if (raw) {
+ try { customTheme = parseCustomTheme(raw); }
+ catch (e) {} // Corrupt or older palettes cannot break the rest of the app.
+ }
+} catch (e) {} // Private mode: the CSS default still follows the system.
+if (themeChoice === 'custom' && !customTheme) themeChoice = 'auto';
+
+function themePref() { return themeChoice; }
+
+function themeResolved(pref) {
+ if (pref === 'custom') return customTheme.colorScheme;
+ if (pref !== 'auto') return pref;
+ return matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+}
+
+function applyTheme() {
+ const root = document.documentElement;
+ const pref = themePref(), resolved = themeResolved(pref);
+ if (pref === 'auto') delete root.dataset.theme;
+ else root.dataset.theme = pref;
+ // Remove only properties owned here, so returning to Auto/Light/Dark restores the CSS pairs.
+ for (const token of THEME_TOKENS) root.style.removeProperty('--' + token);
+ root.style.removeProperty('--header-bg');
+ root.style.removeProperty('color-scheme');
+ if (pref === 'custom') {
+ for (const token of THEME_TOKENS) root.style.setProperty('--' + token, customTheme.colors[token]);
+ root.style.colorScheme = resolved;
+ root.style.setProperty('--header-bg', 'color-mix(in srgb, var(--bg) 72%, transparent)');
+ }
+ for (const meta of document.querySelectorAll('meta[name="theme-color"]')) {
+ const own = (meta.getAttribute('media') || '').includes('dark') ? 'dark' : 'light';
+ meta.content = pref === 'custom' ? customTheme.colors.bg : THEME_META[pref === 'auto' ? own : resolved];
+ }
+ for (const btn of document.querySelectorAll('[data-theme-choice]')) {
+ const checked = btn.dataset.themeChoice === pref;
+ btn.setAttribute('aria-checked', String(checked));
+ btn.tabIndex = checked ? 0 : -1;
+ if (btn.dataset.themeChoice === 'custom') btn.disabled = !customTheme;
+ }
+ const note = document.getElementById('themeResolved');
+ if (note) note.textContent = pref === 'auto' ? 'Now ' + resolved + '.'
+ : pref === 'custom' ? 'Custom ' + resolved + ' palette.' : '';
+}
+
+function themeStatus(message, invalid = false) {
+ const status = document.getElementById('themeStatus');
+ if (status) {
+ status.textContent = message;
+ status.dataset.error = String(invalid);
+ }
+ const editor = document.getElementById('themePalette');
+ if (editor) editor.setAttribute('aria-invalid', String(invalid));
+}
+
+function storeTheme() {
+ try {
+ if (customTheme) localStorage.setItem(CUSTOM_THEME_KEY, JSON.stringify(customTheme));
+ else localStorage.removeItem(CUSTOM_THEME_KEY);
+ if (themeChoice === 'auto') localStorage.removeItem(THEME_KEY);
+ else localStorage.setItem(THEME_KEY, themeChoice);
+ return true;
+ } catch (e) { return false; }
+}
+
+function setTheme(pref) {
+ if (!['auto', 'light', 'dark', 'custom'].includes(pref) || (pref === 'custom' && !customTheme)) return false;
+ themeChoice = pref;
+ const saved = storeTheme();
+ applyTheme();
+ themeStatus(saved ? '' : 'Applied for this tab only; browser storage is unavailable.');
+ if (window.cue) cue('toggle');
+ return saved;
+}
+
+function applyCustomTheme() {
+ const editor = document.getElementById('themePalette');
+ let palette;
+ try { palette = parseCustomTheme(editor.value); }
+ catch (e) { themeStatus(e.message, true); return; }
+ customTheme = palette;
+ editor.value = JSON.stringify(palette, null, 2);
+ if (setTheme('custom')) themeStatus('Custom colors applied and saved in this browser.');
+}
+
+function currentThemePalette() {
+ // Resolve the existing CSS pairs instead of maintaining a second copy of the default palettes.
+ const probe = document.createElement('span');
+ probe.hidden = true;
+ document.body.appendChild(probe);
+ const colors = {};
+ for (const token of THEME_TOKENS) {
+ probe.style.color = 'var(--' + token + ')';
+ const rgb = getComputedStyle(probe).color.match(/[\d.]+/g).slice(0, 3);
+ colors[token] = '#' + rgb.map(v => Math.round(Number(v)).toString(16).padStart(2, '0')).join('');
+ }
+ probe.remove();
+ return {colorScheme: themeResolved(themePref()), colors};
+}
+
+function resetCustomTheme() {
+ customTheme = null;
+ if (themeChoice === 'custom') themeChoice = 'auto';
+ const saved = storeTheme();
+ applyTheme();
+ document.getElementById('themePalette').value = JSON.stringify(currentThemePalette(), null, 2);
+ themeStatus(saved ? 'Custom colors cleared.' : 'Reset for this tab only; browser storage is unavailable.');
+}
+
+function initThemeEditor() {
+ applyTheme();
+ const editor = document.getElementById('themePalette');
+ // Preserve a prefilled example or a browser-restored draft until the user explicitly applies it.
+ if (customTheme) editor.value = JSON.stringify(customTheme, null, 2);
+ else if (!editor.value.trim()) editor.value = JSON.stringify(currentThemePalette(), null, 2);
+ document.querySelector('.theme-seg').addEventListener('keydown', event => {
+ if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;
+ const buttons = [...document.querySelectorAll('[data-theme-choice]')].filter(b => !b.disabled);
+ const index = buttons.indexOf(event.target);
+ if (index < 0) return;
+ event.preventDefault();
+ const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1
+ : (index + (['ArrowLeft', 'ArrowUp'].includes(event.key) ? -1 : 1) + buttons.length) % buttons.length;
+ buttons[next].focus();
+ setTheme(buttons[next].dataset.themeChoice);
+ });
+}
+
+matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
+ if (themePref() === 'auto') applyTheme();
+});
+applyTheme();
+document.addEventListener('DOMContentLoaded', initThemeEditor);