feat(theme): replace color scheme system with light/dark + accent skins (PR #627 by @aronprins)

Independent review by @nesquena confirmed all blockers resolved. Theme×skin two-axis system replaces old monolithic color schemes. Closes #627. Co-Authored-By: aronprins <aronprins@users.noreply.github.com>
This commit is contained in:
Aron Prins
2026-04-18 08:37:09 +02:00
committed by GitHub
parent f3f23abd4e
commit 7cb5547056
18 changed files with 870 additions and 482 deletions

View File

@@ -1,5 +1,21 @@
# Hermes Web UI -- Changelog
## [v0.50.77] — 2026-04-17
### Changed
- **Color scheme system replaced with theme + skin axes** — the old monolithic theme list (`dark`, `slate`, `solarized`, `monokai`, `nord`, `oled`, `light`) is split into two orthogonal axes: **theme** (`light` / `dark` / `system`) and **skin** (accent palette: Default gold, Ares red, Mono gray, Slate blue-gray, Poseidon ocean blue, Sisyphus purple, Charizard orange). Users can now mix any theme with any skin via the new **Appearance** settings tab. Internally, `.dark` class on `<html>` replaces `data-theme`; skin uses `data-skin` attribute and overrides only 5 accent CSS vars per skin, eliminating ~200 lines of duplicated palette overrides. (PR #627 by @aronprins)
### Migration notes
- **Legacy theme names are silently migrated on first load** to the closest theme + skin pair: `slate → dark+slate`, `solarized → dark+poseidon`, `monokai → dark+sisyphus`, `nord → dark+slate`, `oled → dark+default`. Both backend (`api/config.py::_normalize_appearance`) and frontend (`static/boot.js::_normalizeAppearance`) apply the same mapping.
- **Custom themes set via `data-theme` CSS overrides will reset** to `dark + default` on first load. The pre-PR `theme` setting was open-ended ("no enum gate -- allows custom themes"); the new system enumerates valid values. Users who maintained custom CSS will need to re-apply via a skin choice or by overriding skin variables (`--accent`, `--accent-hover`, `--accent-bg`, `--accent-bg-strong`, `--accent-text`).
### Fixed
- **Send button stays active after clearing composer text** — input listener now correctly toggles disabled state. (PR #627)
- **Composer workspace/model label flash on page load** — chips now wait for `_bootReady` before populating, eliminating the placeholder-then-real-value flicker. (PR #627)
- **Topbar border invisible in light mode** — added `:root:not(.dark)` border override. (PR #627)
- **User message bubble text contrast** — accent-colored bubbles now use skin-aware text colors meeting WCAG AA (Poseidon dark improved from 2.8 → 6.5 ratio). (PR #627)
- **Settings skin persistence race condition** — save now waits for server confirmation before applying. (PR #627)
## [v0.50.76] — 2026-04-17
### Fixed

View File

@@ -1216,7 +1216,8 @@ _SETTINGS_DEFAULTS = {
"show_cli_sessions": False, # merge CLI sessions from state.db into the sidebar
"sync_to_insights": False, # mirror WebUI token usage to state.db for /insights
"check_for_updates": True, # check if webui/agent repos are behind upstream
"theme": "dark", # active UI theme name (no enum gate -- allows custom themes)
"theme": "dark", # light | dark | system
"skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard
"language": "en", # UI locale code; must match a key in static/i18n.js LOCALES
"bot_name": os.getenv(
"HERMES_WEBUI_BOT_NAME", "Hermes"
@@ -1227,11 +1228,68 @@ _SETTINGS_DEFAULTS = {
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
}
_SETTINGS_LEGACY_DROP_KEYS = {"assistant_language"}
_SETTINGS_THEME_VALUES = {"light", "dark", "system"}
_SETTINGS_SKIN_VALUES = {
"default",
"ares",
"mono",
"slate",
"poseidon",
"sisyphus",
"charizard",
}
_SETTINGS_LEGACY_THEME_MAP = {
# Legacy full themes now map onto the closest supported theme + accent skin pair.
"slate": ("dark", "slate"),
"solarized": ("dark", "poseidon"),
"monokai": ("dark", "sisyphus"),
"nord": ("dark", "slate"),
"oled": ("dark", "default"),
}
def _normalize_appearance(theme, skin) -> tuple[str, str]:
"""Normalize a (theme, skin) pair, migrating legacy theme names.
Legacy migration table (from `_SETTINGS_LEGACY_THEME_MAP`):
slate → ("dark", "slate")
solarized → ("dark", "poseidon")
monokai → ("dark", "sisyphus")
nord → ("dark", "slate")
oled → ("dark", "default")
Unknown / custom theme names fall back to ("dark", "default"). This is a
behavior change vs. the pre-PR-#627 state, where the `theme` field was
open-ended ("no enum gate -- allows custom themes"). Users who set a
custom CSS theme via `data-theme` will need to re-apply via skin or
custom CSS — see CHANGELOG entry for details.
The same mapping is mirrored in `static/boot.js` (`_LEGACY_THEME_MAP`)
so client and server normalize identically; keep them in sync.
"""
raw_theme = theme.strip().lower() if isinstance(theme, str) else ""
raw_skin = skin.strip().lower() if isinstance(skin, str) else ""
legacy = _SETTINGS_LEGACY_THEME_MAP.get(raw_theme)
if legacy:
next_theme, legacy_skin = legacy
elif raw_theme in _SETTINGS_THEME_VALUES:
next_theme, legacy_skin = raw_theme, "default"
else:
# Unknown themes used to exist; default to dark so upgrades stay visually stable.
next_theme, legacy_skin = "dark", "default"
next_skin = (
raw_skin
if raw_skin in _SETTINGS_SKIN_VALUES
else legacy_skin
)
return next_theme, next_skin
def load_settings() -> dict:
"""Load settings from disk, merging with defaults for any missing keys."""
settings = dict(_SETTINGS_DEFAULTS)
stored = None
try:
settings_exists = SETTINGS_FILE.exists()
except OSError:
@@ -1252,6 +1310,10 @@ def load_settings() -> dict:
)
except Exception:
logger.debug("Failed to load settings from %s", SETTINGS_FILE)
settings["theme"], settings["skin"] = _normalize_appearance(
stored.get("theme") if isinstance(stored, dict) else settings.get("theme"),
stored.get("skin") if isinstance(stored, dict) else settings.get("skin"),
)
return settings
@@ -1276,6 +1338,10 @@ _SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8}
def save_settings(settings: dict) -> dict:
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
current = load_settings()
pending_theme = current.get("theme")
pending_skin = current.get("skin")
theme_was_explicit = False
skin_was_explicit = False
# Handle _set_password: hash and store as password_hash
raw_pw = settings.pop("_set_password", None)
if raw_pw and isinstance(raw_pw, str) and raw_pw.strip():
@@ -1288,6 +1354,16 @@ def save_settings(settings: dict) -> dict:
current["password_hash"] = None
for k, v in settings.items():
if k in _SETTINGS_ALLOWED_KEYS:
if k == "theme":
if isinstance(v, str) and v.strip():
pending_theme = v
theme_was_explicit = True
continue
if k == "skin":
if isinstance(v, str) and v.strip():
pending_skin = v
skin_was_explicit = True
continue
# Validate enum-constrained keys
if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]:
continue
@@ -1300,6 +1376,13 @@ def save_settings(settings: dict) -> dict:
if k in _SETTINGS_BOOL_KEYS:
v = bool(v)
current[k] = v
theme_value = pending_theme
skin_value = pending_skin
if theme_was_explicit and not skin_was_explicit:
raw_theme = pending_theme.strip().lower() if isinstance(pending_theme, str) else ""
if raw_theme not in _SETTINGS_THEME_VALUES:
skin_value = None
current["theme"], current["skin"] = _normalize_appearance(theme_value, skin_value)
current["default_workspace"] = str(
resolve_default_workspace(current.get("default_workspace"))

View File

@@ -579,29 +579,134 @@ window.addEventListener('resize',()=>{
};
})();
// ── System theme helper ──────────────────────────────────────────────────────
// ── Appearance helpers (theme = light/dark/system, skin = accent color) ──────
const _SKINS=[
{name:'Default', colors:['#FFD700','#FFBF00','#CD7F32']},
{name:'Ares', colors:['#FF4444','#CC3333','#992222']},
{name:'Mono', colors:['#CCCCCC','#999999','#666666']},
{name:'Slate', colors:['#334155','#475569','#64748b']},
{name:'Poseidon', colors:['#0EA5E9','#0284C7','#0369A1']},
{name:'Sisyphus', colors:['#A78BFA','#8B5CF6','#7C3AED']},
{name:'Charizard',colors:['#FB923C','#F97316','#EA580C']},
];
const _VALID_THEMES=new Set(['system','dark','light']);
const _VALID_SKINS=new Set((_SKINS||[]).map(s=>s.name.toLowerCase()));
const _LEGACY_THEME_MAP={
slate:{theme:'dark',skin:'slate'},
solarized:{theme:'dark',skin:'poseidon'},
monokai:{theme:'dark',skin:'sisyphus'},
nord:{theme:'dark',skin:'slate'},
oled:{theme:'dark',skin:'default'},
};
let _systemThemeMq=null;
let _onSystemThemeChange=null;
function _normalizeAppearance(theme,skin){
const rawTheme=typeof theme==='string'?theme.trim().toLowerCase():'';
const rawSkin=typeof skin==='string'?skin.trim().toLowerCase():'';
const legacy=_LEGACY_THEME_MAP[rawTheme];
const nextTheme=legacy?legacy.theme:(_VALID_THEMES.has(rawTheme)?rawTheme:'dark');
const nextSkin=_VALID_SKINS.has(rawSkin)?rawSkin:(legacy?legacy.skin:'default');
return {theme:nextTheme,skin:nextSkin};
}
function _setResolvedTheme(isDark){
document.documentElement.classList.toggle('dark',!!isDark);
const link=document.getElementById('prism-theme');
if(!link) return;
const want=isDark
?'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css'
:'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css';
if(link.href!==want){ link.href=want; }
}
function _applyTheme(name){
const resolved=(name==='system')
?(window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light')
:name;
document.documentElement.dataset.theme=resolved||'dark';
// Swap Prism syntax-highlighting theme to match UI theme
(function(){
const link=document.getElementById('prism-theme');
if(!link) return;
const isDark=(resolved!=='light');
const want=isDark
?'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css'
:'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css';
if(link.href!==want){ link.href=want; }
})();
// Re-register OS change listener whenever system theme is active
if(name==='system'){
const mq=window.matchMedia('(prefers-color-scheme:dark)');
const _onOsChange=()=>{ document.documentElement.dataset.theme=mq.matches?'dark':'light'; };
mq.removeEventListener('change',_onOsChange);
mq.addEventListener('change',_onOsChange);
const normalized=_normalizeAppearance(name,'default');
if(_systemThemeMq&&_onSystemThemeChange){
_systemThemeMq.removeEventListener('change',_onSystemThemeChange);
_systemThemeMq=null;
_onSystemThemeChange=null;
}
if(normalized.theme==='system'){
_systemThemeMq=window.matchMedia('(prefers-color-scheme:dark)');
_onSystemThemeChange=()=>_setResolvedTheme(_systemThemeMq.matches);
_setResolvedTheme(_systemThemeMq.matches);
_systemThemeMq.addEventListener('change',_onSystemThemeChange);
return;
}
_setResolvedTheme(normalized.theme==='dark');
}
function _applySkin(name){
const key=(name||'default').toLowerCase();
if(key==='default') delete document.documentElement.dataset.skin;
else document.documentElement.dataset.skin=key;
}
function _pickTheme(name){
const currentSkin=localStorage.getItem('hermes-skin');
const appearance=_normalizeAppearance(name,currentSkin);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
_syncThemePicker(appearance.theme);
_syncSkinPicker(appearance.skin);
if(typeof _markSettingsDirty==='function') _markSettingsDirty();
const hidden=$('settingsTheme');
if(hidden) hidden.value=appearance.theme;
const skinHidden=$('settingsSkin');
if(skinHidden) skinHidden.value=appearance.skin;
}
function _pickSkin(name){
const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),name);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
_syncThemePicker(appearance.theme);
_syncSkinPicker(appearance.skin);
if(typeof _markSettingsDirty==='function') _markSettingsDirty();
const hidden=$('settingsSkin');
if(hidden) hidden.value=appearance.skin;
const themeHidden=$('settingsTheme');
if(themeHidden) themeHidden.value=appearance.theme;
}
function _syncThemePicker(active){
document.querySelectorAll('#themePickerGrid .theme-pick-btn').forEach(btn=>{
const sel=btn.dataset.themeVal===active;
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
});
}
function _syncSkinPicker(active){
document.querySelectorAll('#skinPickerGrid .skin-pick-btn').forEach(btn=>{
const sel=btn.dataset.skinVal===active;
btn.style.borderColor=sel?'var(--accent)':'var(--border2)';
btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none';
});
}
function _buildSkinPicker(activeSkin){
const grid=$('skinPickerGrid');
if(!grid) return;
grid.innerHTML='';
for(const skin of _SKINS){
const key=skin.name.toLowerCase();
const btn=document.createElement('button');
btn.type='button';
btn.className='skin-pick-btn';
btn.dataset.skinVal=key;
btn.style.cssText='border:1px solid var(--border2);border-radius:8px;padding:8px 4px;text-align:center;cursor:pointer;background:none;transition:all .15s';
btn.onclick=()=>_pickSkin(skin.name);
const dots=skin.colors.map(c=>`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${c}"></span>`).join('');
btn.innerHTML=`<div style="display:flex;gap:3px;justify-content:center;margin-bottom:4px">${dots}</div><span style="font-size:11px;color:var(--text)">${skin.name}</span>`;
grid.appendChild(btn);
}
_syncSkinPicker((activeSkin||'default').toLowerCase());
}
function applyBotName(){
@@ -629,9 +734,11 @@ function applyBotName(){
window._soundEnabled=!!s.sound_enabled;
window._notificationsEnabled=!!s.notifications_enabled;
window._botName=s.bot_name||'Hermes';
const _theme=s.theme||'dark';
localStorage.setItem('hermes-theme',_theme);
_applyTheme(_theme);
const appearance=_normalizeAppearance(s.theme,s.skin);
localStorage.setItem('hermes-theme',appearance.theme);
_applyTheme(appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applySkin(appearance.skin);
document.body.classList.toggle('bubble-layout',!!s.bubble_layout);
if(typeof setLocale==='function'){
const _lang=typeof resolvePreferredLocale==='function'
@@ -695,10 +802,12 @@ function applyBotName(){
if(S.session&&S.session.workspace&&localStorage.getItem('hermes-webui-workspace-panel')==='open'){
_workspacePanelMode='browse';
}
syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;}
S._bootReady=true;
syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();await checkInflightOnBoot(saved);return;}
catch(e){localStorage.removeItem('hermes-webui-session');}
}
// no saved session - show empty state, wait for user to hit +
S._bootReady=true;
syncTopbar();
syncWorkspacePanelState();
$('emptyState').style.display='';

View File

@@ -121,19 +121,48 @@ async function cmdUsage(){
}
async function cmdTheme(args){
const themes=['system','dark','light','slate','solarized','monokai','nord','oled'];
if(!args||!themes.includes(args.toLowerCase())){
showToast(t('theme_usage')+themes.join('|'));
const themes=['system','dark','light'];
const skins=(_SKINS||[]).map(s=>s.name.toLowerCase());
const legacyThemes=Object.keys(_LEGACY_THEME_MAP||{});
const val=(args||'').toLowerCase().trim();
// Check if it's a theme
if(themes.includes(val)||legacyThemes.includes(val)){
const appearance=_normalizeAppearance(
val,
legacyThemes.includes(val)?null:localStorage.getItem('hermes-skin')
);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:appearance.theme,skin:appearance.skin})});}catch(e){}
const sel=$('settingsTheme');
if(sel)sel.value=appearance.theme;
const skinSel=$('settingsSkin');
if(skinSel)skinSel.value=appearance.skin;
if(typeof _syncThemePicker==='function') _syncThemePicker(appearance.theme);
if(typeof _syncSkinPicker==='function') _syncSkinPicker(appearance.skin);
showToast(t('theme_set')+appearance.theme+(legacyThemes.includes(val)?` + ${appearance.skin}`:''));
return;
}
const themeName=args.toLowerCase();
localStorage.setItem('hermes-theme',themeName);
_applyTheme(themeName);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:themeName})});}catch(e){}
// Update settings dropdown if panel is open
const sel=$('settingsTheme');
if(sel)sel.value=themeName;
showToast(t('theme_set')+themeName);
// Check if it's a skin
if(skins.includes(val)){
const appearance=_normalizeAppearance(localStorage.getItem('hermes-theme'),val);
localStorage.setItem('hermes-theme',appearance.theme);
localStorage.setItem('hermes-skin',appearance.skin);
_applyTheme(appearance.theme);
_applySkin(appearance.skin);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:appearance.theme,skin:appearance.skin})});}catch(e){}
const sel=$('settingsSkin');
if(sel)sel.value=appearance.skin;
const themeSel=$('settingsTheme');
if(themeSel)themeSel.value=appearance.theme;
if(typeof _syncThemePicker==='function') _syncThemePicker(appearance.theme);
if(typeof _syncSkinPicker==='function') _syncSkinPicker(appearance.skin);
showToast(t('theme_set')+appearance.skin);
return;
}
showToast(t('theme_usage')+themes.join('|')+' | '+skins.join('|')+' | legacy:'+legacyThemes.join('|'));
}
async function cmdSkills(args){

View File

@@ -66,7 +66,7 @@ const LOCALES = {
cmd_workspace: 'Switch workspace by name',
cmd_new: 'Start a new chat session',
cmd_usage: 'Toggle token usage display on/off',
cmd_theme: 'Switch theme (system/dark/light/slate/solarized/monokai/nord/oled)',
cmd_theme: 'Switch appearance (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard)',
cmd_personality: 'Switch agent personality',
cmd_skills: 'List available Hermes skills',
available_commands: 'Available commands:',
@@ -137,6 +137,7 @@ const LOCALES = {
settings_label_model: 'Default Model',
settings_label_send_key: 'Send Key',
settings_label_theme: 'Theme',
settings_label_skin: 'Skin',
settings_label_language: 'Language',
settings_label_token_usage: 'Show token usage',
settings_label_bubble_layout: 'Chat bubble layout',
@@ -480,7 +481,7 @@ const LOCALES = {
cmd_workspace: 'Cambiar de espacio de trabajo por nombre',
cmd_new: 'Iniciar una nueva sesión de chat',
cmd_usage: 'Activar o desactivar el uso de tokens',
cmd_theme: 'Cambiar tema (system/dark/light/slate/solarized/monokai/nord/oled)',
cmd_theme: 'Cambiar apariencia (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard)',
cmd_personality: 'Cambiar la personalidad del agente',
cmd_skills: 'Listar las skills de Hermes disponibles',
available_commands: 'Comandos disponibles:',
@@ -543,6 +544,7 @@ const LOCALES = {
settings_label_model: 'Modelo predeterminado',
settings_label_send_key: 'Tecla de envío',
settings_label_theme: 'Tema',
settings_label_skin: 'Piel',
settings_label_language: 'Idioma',
settings_label_token_usage: 'Mostrar uso de tokens',
settings_label_bubble_layout: 'Disposición en burbujas',
@@ -884,7 +886,7 @@ const LOCALES = {
cmd_workspace: 'Workspace nach Namen wechseln',
cmd_new: 'Neue Chat-Sitzung starten',
cmd_usage: 'Token-Verbrauchsanzeige umschalten',
cmd_theme: 'Theme wechseln (system/dark/light/slate/solarized/monokai/nord/oled)',
cmd_theme: 'Darstellung wechseln (Theme: system/dark/light, Skin: default/ares/mono/slate/poseidon/sisyphus/charizard)',
cmd_personality: 'Agenten-Persönlichkeit wechseln',
cmd_skills: 'Verfügbare Hermes-Skills auflisten',
available_commands: 'Verfügbare Befehle:',
@@ -955,6 +957,7 @@ const LOCALES = {
settings_label_model: 'Standard-Modell',
settings_label_send_key: 'Sende-Taste',
settings_label_theme: 'Theme',
settings_label_skin: 'Skin',
settings_label_language: 'Sprache',
settings_label_token_usage: 'Token-Verbrauch anzeigen',
settings_label_cli_sessions: 'Agent-Sitzungen anzeigen',
@@ -1096,7 +1099,7 @@ const LOCALES = {
cmd_workspace: '\u6309\u540d\u79f0\u5207\u6362\u5de5\u4f5c\u533a',
cmd_new: '\u65b0\u5efa\u804a\u5929\u4f1a\u8bdd',
cmd_usage: '\u5207\u6362 token \u7528\u91cf\u663e\u793a',
cmd_theme: '\u5207\u6362\u4e3b\u9898\uff08system/dark/light/slate/solarized/monokai/nord/oled\uff09',
cmd_theme: '\u5207\u6362\u5916\u89c2\uff08\u4e3b\u9898\uff1asystem/dark/light\uff0c\u76ae\u80a4\uff1adefault/ares/mono/slate/poseidon/sisyphus/charizard\uff09',
cmd_personality: '\u5207\u6362 Agent \u4eba\u8bbe',
cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd',
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
@@ -1167,6 +1170,7 @@ const LOCALES = {
settings_label_model: '\u9ed8\u8ba4\u6a21\u578b',
settings_label_send_key: '\u53d1\u9001\u5feb\u6377\u952e',
settings_label_theme: '\u4e3b\u9898',
settings_label_skin: '\u76ae\u80a4',
settings_label_language: '\u8bed\u8a00',
settings_label_token_usage: '\u663e\u793a token \u7528\u91cf',
settings_label_bubble_layout: '聊天气泡布局',
@@ -1499,7 +1503,7 @@ const LOCALES = {
cmd_workspace: '\u6309\u540d\u7a31\u5207\u63db\u5de5\u4f5c\u5340',
cmd_new: '\u65b0\u5efa\u804a\u5929\u6703\u8a71',
cmd_usage: '\u5207\u63db token \u7528\u91cf\u986f\u793a',
cmd_theme: '\u5207\u63db\u4e3b\u984c\uff08system/dark/light/slate/solarized/monokai/nord/oled\uff09',
cmd_theme: '\u5207\u63db\u5916\u89c0\uff08\u4e3b\u984c\uff1asystem/dark/light\uff0c\u76ae\u819a\uff1adefault/ares/mono/slate/poseidon/sisyphus/charizard\uff09',
cmd_personality: '\u5207\u63db Agent \u4eba\u8a2d',
cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd',
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
@@ -1562,6 +1566,7 @@ const LOCALES = {
settings_label_model: '\u9ed8\u8a8d\u6a21\u578b',
settings_label_send_key: '\u767c\u9001\u5feb\u6377\u9375',
settings_label_theme: '\u4e3b\u984c',
settings_label_skin: '\u76ae\u819a',
settings_label_language: '\u8a9d\u8a00',
settings_label_token_usage: '\u986f\u793a token \u7528\u91cf',
settings_label_cli_sessions: '\u986f\u793a CLI \u6703\u8a71',

View File

@@ -9,7 +9,7 @@
<link rel="shortcut icon" href="static/favicon.ico">
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) -->
<script>(function(){var p=location.pathname.endsWith('/')?location.pathname:(location.pathname.replace(/\/[^\/]*$/,'/')||'/');document.write('<base href="'+location.origin+p+'">');})()</script>
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t==='system'){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';}if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</script>
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
<link rel="stylesheet" href="static/style.css">
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
@@ -305,14 +305,14 @@
<div class="composer-ws-wrap">
<button class="composer-workspace-chip ws-chip" id="composerWorkspaceChip" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace" disabled>
<span class="composer-workspace-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
<span class="composer-workspace-label" id="composerWorkspaceLabel">Workspace</span>
<span class="composer-workspace-label" id="composerWorkspaceLabel"></span>
<span class="composer-workspace-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
</div>
<div class="composer-model-wrap">
<button class="composer-model-chip" id="composerModelChip" type="button" onclick="toggleModelDropdown()" title="Conversation model">
<span class="composer-model-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg></span>
<span class="composer-model-label" id="composerModelLabel">Model</span>
<span class="composer-model-label" id="composerModelLabel"></span>
<span class="composer-model-chevron" aria-hidden="true"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
</button>
<select id="modelSelect" class="composer-model-select" title="Conversation model" aria-hidden="true" tabindex="-1">
@@ -439,6 +439,10 @@
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span class="settings-tab-title">Conversation</span>
</button>
<button class="settings-tab" id="settingsTabAppearance" type="button" role="tab" aria-selected="false" aria-controls="settingsPaneAppearance" onclick="switchSettingsSection('appearance')">
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
<span class="settings-tab-title">Appearance</span>
</button>
<button class="settings-tab" id="settingsTabPreferences" type="button" role="tab" aria-selected="false" aria-controls="settingsPanePreferences" onclick="switchSettingsSection('preferences')">
<svg class="settings-tab-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>
<span class="settings-tab-title">Preferences</span>
@@ -464,6 +468,45 @@
</div>
<input type="file" id="importFileInput" accept=".json" style="display:none">
</div>
<div class="settings-pane" id="settingsPaneAppearance" role="tabpanel" aria-labelledby="settingsTabAppearance">
<div class="settings-section-head">
<div>
<div class="settings-section-title">Appearance</div>
<div class="settings-section-meta">Theme, accent colors, and visual style.</div>
</div>
</div>
<div class="settings-field">
<label data-i18n="settings_label_theme">Theme</label>
<div id="themePickerGrid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:4px">
<button type="button" data-theme-val="light" onclick="_pickTheme('light')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
<div style="width:100%;height:40px;border-radius:6px;background:#fff;border:1px solid rgba(0,0,0,.12);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
<svg width="16" height="16" fill="none" stroke="#999" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
</div>
<span style="font-size:12px;font-weight:500;color:var(--text)">Light</span>
</button>
<button type="button" data-theme-val="dark" onclick="_pickTheme('dark')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
<div style="width:100%;height:40px;border-radius:6px;background:#1a1a2e;border:1px solid rgba(255,255,255,.1);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
<svg width="16" height="16" fill="none" stroke="#666" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1111.21 3a7 7 0 009.79 9.79z"/></svg>
</div>
<span style="font-size:12px;font-weight:500;color:var(--text)">Dark</span>
</button>
<button type="button" data-theme-val="system" onclick="_pickTheme('system')" class="theme-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
<div style="width:100%;height:40px;border-radius:6px;background:linear-gradient(to right,#fff,#1a1a2e);border:1px solid rgba(0,0,0,.12);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
<svg width="16" height="16" fill="none" stroke="#888" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
</div>
<span style="font-size:12px;font-weight:500;color:var(--text)">System</span>
</button>
</div>
<input type="hidden" id="settingsTheme" value="dark">
</div>
<div class="settings-field">
<label data-i18n="settings_label_skin">Skin</label>
<div id="skinPickerGrid" style="display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-top:4px">
</div>
<input type="hidden" id="settingsSkin" value="default">
</div>
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
</div>
<div class="settings-pane" id="settingsPanePreferences" role="tabpanel" aria-labelledby="settingsTabPreferences">
<div class="settings-section-head">
<div>
@@ -482,19 +525,6 @@
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
</select>
</div>
<div class="settings-field">
<label for="settingsTheme" data-i18n="settings_label_theme">Theme</label>
<select id="settingsTheme" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px" onchange="_applyTheme(this.value)">
<option value="system">System (auto)</option>
<option value="dark">Dark (default)</option>
<option value="light">Light</option>
<option value="slate">Slate (charcoal)</option>
<option value="solarized">Solarized Dark</option>
<option value="monokai">Monokai</option>
<option value="nord">Nord</option>
<option value="oled">OLED</option>
</select>
</div>
<div class="settings-field">
<label for="settingsLanguage" data-i18n="settings_label_language">Language</label>
<select id="settingsLanguage" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
@@ -561,7 +591,7 @@
<div class="settings-section-title">System</div>
<div class="settings-section-meta">Instance version and access controls.</div>
</div>
<span class="settings-version-badge">v0.50.76</span>
<span class="settings-version-badge">v0.50.77</span>
</div>
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>

View File

@@ -50,7 +50,7 @@ async function loadCrons() {
? `<button class="cron-btn" onclick="cronResume('${job.id}')">${li('play',12)} ${esc(t('cron_resume'))}</button>`
: `<button class="cron-btn pause" onclick="cronPause('${job.id}')">${li('pause',12)} ${esc(t('cron_pause'))}</button>`}
<button class="cron-btn" onclick="cronEditOpen('${job.id}',${JSON.stringify(job).replace(/"/g,'&quot;')})">${li('pencil',12)} ${esc(t('edit'))}</button>
<button class="cron-btn" style="border-color:rgba(201,168,76,.3);color:var(--accent)" onclick="cronDelete('${job.id}')">${li('trash-2',12)} ${esc(t('delete_title'))}</button>
<button class="cron-btn" style="border-color:var(--accent-bg-strong);color:var(--accent-text)" onclick="cronDelete('${job.id}')">${li('trash-2',12)} ${esc(t('delete_title'))}</button>
</div>
<!-- Inline edit form, hidden by default -->
<div id="cron-edit-${job.id}" style="display:none;margin-top:8px;border-top:1px solid var(--border);padding-top:8px">
@@ -547,7 +547,9 @@ function syncWorkspaceDisplays(){
const composerLabel=$('composerWorkspaceLabel');
const composerDropdown=$('composerWsDropdown');
if(!hasSession && composerDropdown) composerDropdown.classList.remove('open');
if(composerLabel) composerLabel.textContent=label;
// Only show workspace label once boot has finished to prevent
// flash of "No workspace" before the saved session finishes loading.
if(composerLabel) composerLabel.textContent=S._bootReady?label:'';
if(composerChip){
composerChip.disabled=!hasSession;
composerChip.title=hasSession?ws:t('no_workspace');
@@ -1068,13 +1070,14 @@ document.addEventListener('drop',e=>{e.preventDefault();dragCounter=0;wrap.class
let _settingsDirty = false;
let _settingsThemeOnOpen = null; // track theme at open time for discard revert
let _settingsSkinOnOpen = null; // track skin at open time for discard revert
let _settingsSection = 'conversation';
function switchSettingsSection(name){
const section=(name==='preferences'||name==='system')?name:'conversation';
const section=(name==='appearance'||name==='preferences'||name==='system')?name:'conversation';
_settingsSection=section;
const map={conversation:'Conversation',preferences:'Preferences',system:'System'};
['conversation','preferences','system'].forEach(key=>{
const map={conversation:'Conversation',appearance:'Appearance',preferences:'Preferences',system:'System'};
['conversation','appearance','preferences','system'].forEach(key=>{
const tab=$('settingsTab'+map[key]);
const pane=$('settingsPane'+map[key]);
const active=key===section;
@@ -1112,7 +1115,8 @@ function toggleSettings(){
if(!overlay) return;
if(overlay.style.display==='none'){
_settingsDirty = false;
_settingsThemeOnOpen = localStorage.getItem('hermes-theme') || document.documentElement.dataset.theme || 'dark';
_settingsThemeOnOpen = localStorage.getItem('hermes-theme') || 'dark';
_settingsSkinOnOpen = localStorage.getItem('hermes-skin') || 'default';
_settingsSection = 'conversation';
overlay.style.display='';
loadSettingsPanel();
@@ -1152,7 +1156,10 @@ function _revertSettingsPreview(){
if(_settingsThemeOnOpen){
localStorage.setItem('hermes-theme', _settingsThemeOnOpen);
if(typeof _applyTheme==='function') _applyTheme(_settingsThemeOnOpen);
else document.documentElement.dataset.theme = _settingsThemeOnOpen;
}
if(_settingsSkinOnOpen){
localStorage.setItem('hermes-skin', _settingsSkinOnOpen);
if(typeof _applySkin==='function') _applySkin(_settingsSkinOnOpen);
}
}
@@ -1187,6 +1194,16 @@ function _markSettingsDirty(){
async function loadSettingsPanel(){
try{
const settings=await api('/api/settings');
// Hydrate appearance controls first so a slow /api/models request
// cannot overwrite an in-progress theme/skin selection.
const themeSel=$('settingsTheme');
const themeVal=settings.theme||'dark';
if(themeSel) themeSel.value=themeVal;
if(typeof _syncThemePicker==='function') _syncThemePicker(themeVal);
const skinVal=(settings.skin||'default').toLowerCase();
const skinSel=$('settingsSkin');
if(skinSel) skinSel.value=skinVal;
if(typeof _buildSkinPicker==='function') _buildSkinPicker(skinVal);
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'))
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
@@ -1218,9 +1235,6 @@ async function loadSettingsPanel(){
// Send key preference
const sendKeySel=$('settingsSendKey');
if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_markSettingsDirty,{once:false});}
// Theme preference
const themeSel=$('settingsTheme');
if(themeSel){themeSel.value=settings.theme||'dark';themeSel.addEventListener('change',_markSettingsDirty,{once:false});}
// Language preference — populate from LOCALES bundle
const langSel=$('settingsLanguage');
if(langSel){
@@ -1275,7 +1289,7 @@ function _setSettingsAuthButtonsVisible(active){
}
function _applySavedSettingsUi(saved, body, opts){
const {sendKey,showTokenUsage,showCliSessions,theme,language}=opts;
const {sendKey,showTokenUsage,showCliSessions,theme,skin,language}=opts;
window._sendKey=sendKey||'enter';
window._showTokenUsage=showTokenUsage;
window._showCliSessions=showCliSessions;
@@ -1293,6 +1307,7 @@ function _applySavedSettingsUi(saved, body, opts){
_setSettingsAuthButtonsVisible(!!saved.auth_enabled);
_settingsDirty=false;
_settingsThemeOnOpen=theme;
_settingsSkinOnOpen=skin||'default';
const bar=$('settingsUnsavedBar');
if(bar) bar.style.display='none';
renderMessages();
@@ -1307,12 +1322,14 @@ async function saveSettings(andClose){
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
const pw=($('settingsPassword')||{}).value;
const theme=($('settingsTheme')||{}).value||'dark';
const skin=($('settingsSkin')||{}).value||'default';
const language=($('settingsLanguage')||{}).value||'en';
const body={};
if(model) body.default_model=model;
if(sendKey) body.send_key=sendKey;
body.theme=theme;
body.skin=skin;
body.language=language;
body.show_token_usage=showTokenUsage;
body.show_cli_sessions=showCliSessions;
@@ -1328,7 +1345,7 @@ async function saveSettings(andClose){
if(pw && pw.trim()){
try{
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,language});
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,skin,language});
showToast(t(saved.auth_just_enabled?'settings_saved_pw':'settings_saved_pw_updated'));
_hideSettingsPanel();
return;
@@ -1336,7 +1353,7 @@ async function saveSettings(andClose){
}
try{
const saved=await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,language});
_applySavedSettingsUi(saved, body, {sendKey,showTokenUsage,showCliSessions,theme,skin,language});
showToast(t('settings_saved'));
_hideSettingsPanel();
}catch(e){

File diff suppressed because it is too large Load Diff

View File

@@ -191,6 +191,8 @@ function syncModelChip(){
const label=$('composerModelLabel');
const dd=$('composerModelDropdown');
if(!sel||!chip||!label) return;
// Don't show a model label until boot has finished loading to prevent flash of wrong default
if(!S._bootReady){ label.textContent=''; chip.title='Conversation model'; return; }
const opt=_selectedModelOption();
label.textContent=opt?opt.textContent:getModelLabel(sel.value||'');
chip.title=sel.value||'Conversation model';
@@ -654,6 +656,8 @@ function updateSendBtn(){
if(canSend&&!btn.classList.contains('visible')){
btn.classList.remove('visible');
requestAnimationFrame(()=>btn.classList.add('visible'));
} else if(!canSend){
btn.classList.remove('visible');
}
}
function setBusy(v){
@@ -1635,7 +1639,7 @@ function renderMermaidBlocks(){
script.crossOrigin='anonymous';
script.onload=()=>{
if(typeof mermaid!=='undefined'){
mermaid.initialize({startOnLoad:false,theme:'dark',themeVariables:{
mermaid.initialize({startOnLoad:false,theme:document.documentElement.classList.contains('dark')?'dark':'default',themeVariables:{
primaryColor:'#4a6fa5',primaryTextColor:'#e2e8f0',lineColor:'#718096',
secondaryColor:'#2d3748',tertiaryColor:'#1a202c',primaryBorderColor:'#4a5568',
}});

View File

@@ -119,7 +119,7 @@ class TestSystemTheme:
def test_apply_theme_resolves_system(self):
src = read("static/boot.js")
assert "name==='system'" in src or "=== 'system'" in src, (
assert "normalized.theme==='system'" in src or "=== 'system'" in src, (
"_applyTheme must branch on 'system' to resolve via matchMedia"
)
@@ -131,23 +131,23 @@ class TestSystemTheme:
def test_load_settings_calls_apply_theme(self):
src = read("static/boot.js")
assert "_applyTheme(_theme)" in src, (
assert "_applyTheme(appearance.theme)" in src, (
"loadSettings must call _applyTheme() instead of direct data-theme assignment"
)
def test_system_option_in_theme_select(self):
def test_system_option_in_theme_picker(self):
html = read("static/index.html")
assert 'value="system"' in html, (
"Theme <select> must include <option value=\"system\">"
assert "_pickTheme('system')" in html, (
"Theme picker must include a system theme button"
)
assert "System (auto)" in html, (
"Theme picker must show 'System (auto)' label"
assert ">System<" in html, (
"Theme picker must show 'System' label"
)
def test_theme_select_uses_apply_theme_onchange(self):
def test_theme_picker_uses_pick_theme(self):
html = read("static/index.html")
assert "_applyTheme(this.value)" in html, (
"Theme <select> onchange must call _applyTheme(this.value)"
assert "_pickTheme(" in html, (
"Theme buttons must call _pickTheme()"
)
def test_flicker_script_resolves_system(self):
@@ -156,6 +156,9 @@ class TestSystemTheme:
assert "==='system'" in html or "=== 'system'" in html, (
"Flicker-prevention head script must resolve 'system' before setting data-theme"
)
assert "legacy={slate:['dark','slate']" in html, (
"Flicker-prevention head script must normalize legacy theme names on first paint"
)
def test_system_in_commands_themes_list(self):
src = read("static/commands.js")
@@ -165,8 +168,14 @@ class TestSystemTheme:
def test_commands_uses_apply_theme(self):
src = read("static/commands.js")
assert "_applyTheme(themeName)" in src, (
"cmdTheme must call _applyTheme() to handle system resolution"
assert "_applyTheme(appearance.theme)" in src, (
"cmdTheme must call _applyTheme() with the normalized canonical theme"
)
def test_commands_accept_legacy_theme_aliases(self):
src = read("static/commands.js")
assert "const legacyThemes=Object.keys(_LEGACY_THEME_MAP||{});" in src, (
"cmdTheme must accept legacy theme aliases and map them onto canonical appearance values"
)
def test_panels_reverts_via_apply_theme(self):
@@ -197,3 +206,21 @@ class TestSystemTheme:
f"cmd_theme description should mention 'system' in all 5 locales; "
f"found {count}"
)
def test_theme_listener_cleanup_uses_stable_handler(self):
src = read("static/boot.js")
assert "_systemThemeMq&&_onSystemThemeChange" in src, (
"_applyTheme must track the active OS-theme listener so it can be removed cleanly"
)
assert "removeEventListener('change',_onSystemThemeChange)" in src, (
"_applyTheme must remove the previous OS-theme listener before adding a new one"
)
def test_panels_hydrates_appearance_before_models_fetch(self):
src = read("static/panels.js")
skin_idx = src.index("const skinVal=(settings.skin||'default').toLowerCase();")
models_idx = src.index("const models=await api('/api/models');")
assert skin_idx < models_idx, (
"loadSettingsPanel must hydrate theme/skin before awaiting /api/models, "
"otherwise a slow model fetch can clobber an in-progress skin selection"
)

View File

@@ -19,39 +19,38 @@ COMPOSE = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
# ── #594: light theme dialog overrides ───────────────────────────────────────
def test_594_app_dialog_has_light_theme_override():
"""style.css must have a light theme rule targeting .app-dialog background."""
assert ':root[data-theme="light"] .app-dialog{' in STYLE_CSS or \
":root[data-theme='light'] .app-dialog{" in STYLE_CSS, (
"Missing light theme override for .app-dialog — dialogs appear dark on light theme"
def test_594_app_dialog_has_light_mode_override():
"""style.css must have a light mode rule targeting .app-dialog background."""
assert ':root:not(.dark) .app-dialog{' in STYLE_CSS, (
"Missing light mode override for .app-dialog — dialogs appear dark on light theme"
)
def test_594_app_dialog_input_has_light_theme_override():
"""style.css must have a light theme rule for .app-dialog-input."""
assert ":root[data-theme=\"light\"] .app-dialog-input{" in STYLE_CSS, (
"Missing light theme override for .app-dialog-input"
def test_594_app_dialog_input_has_light_mode_override():
"""style.css must have a light mode rule for .app-dialog-input."""
assert ":root:not(.dark) .app-dialog-input{" in STYLE_CSS, (
"Missing light mode override for .app-dialog-input"
)
def test_594_app_dialog_btn_has_light_theme_override():
"""style.css must have a light theme rule for .app-dialog-btn."""
assert ":root[data-theme=\"light\"] .app-dialog-btn{" in STYLE_CSS, (
"Missing light theme override for .app-dialog-btn"
def test_594_app_dialog_btn_has_light_mode_override():
"""style.css must have a light mode rule for .app-dialog-btn."""
assert ":root:not(.dark) .app-dialog-btn{" in STYLE_CSS, (
"Missing light mode override for .app-dialog-btn"
)
def test_594_app_dialog_close_has_light_theme_override():
"""style.css must have a light theme rule for .app-dialog-close."""
assert ":root[data-theme=\"light\"] .app-dialog-close{" in STYLE_CSS, (
"Missing light theme override for .app-dialog-close"
def test_594_app_dialog_close_has_light_mode_override():
"""style.css must have a light mode rule for .app-dialog-close."""
assert ":root:not(.dark) .app-dialog-close{" in STYLE_CSS, (
"Missing light mode override for .app-dialog-close"
)
def test_594_file_rename_input_has_light_theme_override():
"""style.css must have a light theme rule for .file-rename-input."""
assert ":root[data-theme=\"light\"] .file-rename-input{" in STYLE_CSS, (
"Missing light theme override for .file-rename-input"
def test_594_file_rename_input_has_light_mode_override():
"""style.css must have a light mode rule for .file-rename-input."""
assert ":root:not(.dark) .file-rename-input{" in STYLE_CSS, (
"Missing light mode override for .file-rename-input"
)

View File

@@ -28,7 +28,7 @@ def test_msg_body_table_tr_stripe_present():
def test_msg_body_light_theme_overrides():
css = _read_css()
assert ':root[data-theme="light"] .msg-body th' in css, \
'Light-theme override for .msg-body th missing from style.css'
assert ':root[data-theme="light"] .msg-body td' in css, \
'Light-theme override for .msg-body td missing from style.css'
assert ':root:not(.dark) .msg-body th' in css, \
'Light-mode override for .msg-body th missing from style.css'
assert ':root:not(.dark) .msg-body td' in css, \
'Light-mode override for .msg-body td missing from style.css'

View File

@@ -694,12 +694,12 @@ def test_style_css_has_session_actions_dropdown(cleanup_test_sessions):
".session-action-menu must use position:fixed to avoid sidebar clipping"
def test_style_css_active_session_uses_gold(cleanup_test_sessions):
"""Active session style should use gold/amber color (#e8a030) not just blue."""
def test_style_css_active_session_uses_accent(cleanup_test_sessions):
"""Active session style should use accent color variable, not hardcoded hex."""
src = REPO_ROOT / "static" / "style.css"
code = src.read_text()
assert "#e8a030" in code, \
"Active session gold color (#e8a030) not found in style.css"
assert "var(--accent" in code and ".session-item.active" in code, \
"Active session must use var(--accent) variables in style.css"
def test_sessions_js_uses_action_menu_not_per_row_buttons(cleanup_test_sessions):

View File

@@ -120,15 +120,15 @@ def test_mic_btn_recording_state_css():
assert '.mic-btn.recording' in css
def test_mic_recording_color_red():
""".mic-btn.recording must use the red accent color #e94560."""
def test_mic_recording_color_error():
""".mic-btn.recording must use the error color variable or red."""
css, _ = get_text("/static/style.css")
recording_idx = css.find('.mic-btn.recording')
# Find the rule block after the selector
brace_open = css.find('{', recording_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
assert '#e94560' in rule or 'e94560' in rule
assert 'var(--error)' in rule or '#e94560' in rule
def test_mic_recording_has_animation():

View File

@@ -109,14 +109,14 @@ def test_send_btn_no_old_padding():
assert 'padding:7px' not in rule and 'padding: 7px' not in rule
def test_send_btn_blue_background():
"""send-btn background must use the blue accent (#7cb9ff or similar)."""
def test_send_btn_accent_background():
"""send-btn background must use the accent color variable."""
css, _ = get_text("/static/style.css")
send_idx = css.find('.send-btn{')
brace_open = css.find('{', send_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
assert '7cb9ff' in rule or '5ba8f5' in rule or 'var(--blue)' in rule
assert 'var(--accent)' in rule or 'var(--blue)' in rule or '7cb9ff' in rule
def test_send_btn_has_transition():

View File

@@ -1,11 +1,19 @@
"""
Sprint 26 Tests: pluggable UI themes — settings persistence, theme default,
custom theme names accepted.
Sprint 26 Tests: canonical appearance settings persist and legacy theme names
map onto the new theme + skin system.
"""
import json, urllib.error, urllib.request
import pathlib
import sys
from tests._pytest_port import BASE
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from api import config
def get(path):
with urllib.request.urlopen(BASE + path, timeout=10) as r:
@@ -32,7 +40,7 @@ def test_settings_default_theme():
assert d.get("theme") == "dark"
def test_settings_set_theme_light():
def test_settings_set_theme_light_persists():
"""Setting theme to 'light' should persist and round-trip."""
try:
d, status = post("/api/settings", {"theme": "light"})
@@ -44,55 +52,103 @@ def test_settings_set_theme_light():
post("/api/settings", {"theme": "dark"})
def test_settings_set_theme_solarized():
"""Setting theme to 'solarized' should persist."""
def test_settings_set_theme_light():
"""Setting theme to 'light' should persist."""
try:
post("/api/settings", {"theme": "solarized"})
post("/api/settings", {"theme": "light"})
d, _ = get("/api/settings")
assert d.get("theme") == "solarized"
assert d.get("theme") == "light"
finally:
post("/api/settings", {"theme": "dark"})
def test_settings_set_theme_monokai():
"""Setting theme to 'monokai' should persist."""
def test_settings_set_theme_system():
"""Setting theme to 'system' should persist."""
try:
post("/api/settings", {"theme": "monokai"})
post("/api/settings", {"theme": "system"})
d, _ = get("/api/settings")
assert d.get("theme") == "monokai"
assert d.get("theme") == "system"
finally:
post("/api/settings", {"theme": "dark"})
def test_settings_set_theme_nord():
"""Setting theme to 'nord' should persist."""
def test_settings_set_skin():
"""Setting skin should persist."""
try:
post("/api/settings", {"theme": "nord"})
post("/api/settings", {"skin": "ares"})
d, _ = get("/api/settings")
assert d.get("theme") == "nord"
assert d.get("skin") == "ares"
finally:
post("/api/settings", {"theme": "dark"})
post("/api/settings", {"skin": "default"})
def test_settings_set_theme_slate():
"""Setting theme to 'slate' should persist."""
def test_settings_set_skin_poseidon():
"""Setting skin to 'poseidon' should persist."""
try:
post("/api/settings", {"theme": "slate"})
post("/api/settings", {"skin": "poseidon"})
d, _ = get("/api/settings")
assert d.get("theme") == "slate"
assert d.get("skin") == "poseidon"
finally:
post("/api/settings", {"theme": "dark"})
post("/api/settings", {"skin": "default"})
def test_settings_custom_theme_accepted():
"""Custom theme names should be accepted (no enum gate)."""
def test_settings_legacy_theme_maps_to_dark_skin_pair():
"""Legacy theme names should map to the closest supported theme + skin."""
try:
d, status = post("/api/settings", {"theme": "slate"})
assert status == 200
d2, _ = get("/api/settings")
assert d2.get("theme") == "dark"
assert d2.get("skin") == "slate"
finally:
post("/api/settings", {"theme": "dark", "skin": "default"})
def test_settings_legacy_monokai_maps_to_sisyphus_skin():
"""Monokai should migrate onto the closest supported accent skin."""
try:
d, status = post("/api/settings", {"theme": "monokai"})
assert status == 200
d2, _ = get("/api/settings")
assert d2.get("theme") == "dark"
assert d2.get("skin") == "sisyphus"
finally:
post("/api/settings", {"theme": "dark", "skin": "default"})
def test_settings_unknown_theme_falls_back_to_dark_default():
"""Unknown themes should normalize to a safe canonical appearance."""
try:
d, status = post("/api/settings", {"theme": "my-custom-theme"})
assert status == 200
d2, _ = get("/api/settings")
assert d2.get("theme") == "my-custom-theme"
assert d2.get("theme") == "dark"
assert d2.get("skin") == "default"
finally:
post("/api/settings", {"theme": "dark"})
post("/api/settings", {"theme": "dark", "skin": "default"})
def test_settings_invalid_skin_falls_back_to_default():
"""Unknown skin names should normalize back to the default accent."""
try:
d, status = post("/api/settings", {"skin": "not-a-skin"})
assert status == 200
d2, _ = get("/api/settings")
assert d2.get("skin") == "default"
finally:
post("/api/settings", {"skin": "default"})
def test_load_settings_normalizes_legacy_theme_from_file(monkeypatch, tmp_path):
"""Existing settings.json files with legacy theme names should normalize on load."""
settings_path = tmp_path / "settings.json"
settings_path.write_text(json.dumps({"theme": "solarized"}), encoding="utf-8")
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
loaded = config.load_settings()
assert loaded["theme"] == "dark"
assert loaded["skin"] == "poseidon"
def test_theme_does_not_break_other_settings():
@@ -100,10 +156,10 @@ def test_theme_does_not_break_other_settings():
d_before, _ = get("/api/settings")
send_key_before = d_before.get("send_key")
try:
post("/api/settings", {"theme": "nord"})
post("/api/settings", {"theme": "light"})
d_after, _ = get("/api/settings")
assert d_after.get("send_key") == send_key_before
assert d_after.get("theme") == "nord"
assert d_after.get("theme") == "light"
finally:
post("/api/settings", {"theme": "dark"})
@@ -111,9 +167,9 @@ def test_theme_does_not_break_other_settings():
def test_theme_survives_round_trip():
"""Theme set via POST should appear in subsequent GET."""
try:
post("/api/settings", {"theme": "monokai"})
post("/api/settings", {"theme": "light"})
d, status = get("/api/settings")
assert status == 200
assert d["theme"] == "monokai"
assert d["theme"] == "light"
finally:
post("/api/settings", {"theme": "dark"})

View File

@@ -40,7 +40,7 @@ class TestActiveSessionTitleThemeColor(unittest.TestCase):
def test_active_session_title_uses_theme_variable(self):
"""
.session-item.active .session-title must use var(--gold) not a hardcoded hex.
The light-theme override line (data-theme="light") is allowed to keep its own
The light-mode override line (:not(.dark)) is allowed to keep its own
hardcoded color; we only check the base/dark rule.
"""
# Find all lines that match the active session title selector
@@ -48,7 +48,7 @@ class TestActiveSessionTitleThemeColor(unittest.TestCase):
base_rule_lines = [
line for line in lines
if ".session-item.active .session-title" in line
and 'data-theme="light"' not in line
and ':not(.dark)' not in line
]
self.assertTrue(
@@ -57,10 +57,9 @@ class TestActiveSessionTitleThemeColor(unittest.TestCase):
)
for line in base_rule_lines:
self.assertIn(
"var(--gold)",
line,
f"Expected var(--gold) in active session title rule, got: {line.strip()}"
self.assertTrue(
"var(--gold)" in line or "var(--accent-text)" in line,
f"Expected var(--gold) or var(--accent-text) in active session title rule, got: {line.strip()}"
)
self.assertNotIn(
"#e8a030",
@@ -69,6 +68,21 @@ class TestActiveSessionTitleThemeColor(unittest.TestCase):
)
class TestDarkTopbarSelector(unittest.TestCase):
def test_topbar_dark_border_uses_root_dark_selector(self):
self.assertIn(
":root.dark .topbar{border-bottom:1px solid rgba(255,255,255,.07);}",
STYLE_CSS,
"Topbar dark border override must target :root.dark after the theme-class migration",
)
self.assertNotIn(
'[data-theme="dark"] .topbar',
STYLE_CSS,
"Topbar dark border override must not keep the removed data-theme selector",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -23,9 +23,11 @@ def test_tool_card_detail_uses_transitionable_collapsed_state():
def test_thinking_card_toggle_and_body_use_animation_friendly_state():
assert ".thinking-card-toggle{margin-left:auto;font-size:10px;display:inline-flex;" in COMPACT_CSS
assert ".thinking-card-body{display:block;max-height:0;opacity:0;overflow:hidden;" in COMPACT_CSS
# Body uses div default (display:block); canonical rule lives in the
# consolidated block. Open state caps at 260px (intentional "quieter" sizing).
assert ".thinking-card-body{max-height:0;opacity:0;overflow:hidden;" in COMPACT_CSS
assert re.search(
r"\.thinking-card\.open\s+\.thinking-card-body\s*\{[^}]*max-height:\s*300px;[^}]*opacity:\s*1;",
r"\.thinking-card\.open\s+\.thinking-card-body\s*\{[^}]*max-height:\s*260px;[^}]*opacity:\s*1;",
STYLE_CSS,
)
@@ -36,8 +38,10 @@ def test_tool_card_toggle_uses_same_chevron_icon_markup_as_thinking_card():
def test_thinking_card_uses_panel_chrome_with_gold_palette():
# Canonical thinking-card rule lives in the consolidated block (border-radius
# tightened from 10px → 8px as part of the "quieter card" design pass).
assert re.search(
r"\.thinking-card\s*\{[^}]*background:\s*rgba\(201,168,76,.05\);[^}]*border:\s*1px\s+solid\s+rgba\(201,168,76,.18\);[^}]*border-radius:\s*8px;",
r"\.thinking-card\s*\{[^}]*background:\s*var\(--accent-bg\);[^}]*border:\s*1px\s+solid\s+var\(--accent-bg-strong\);[^}]*border-radius:\s*8px;",
STYLE_CSS,
)
assert "border-left: 2px solid rgba(201,168,76,.4);" not in STYLE_CSS