feat: add System (auto) theme following OS prefers-color-scheme (#504)

Synthesized from PRs #506, #509, #514 (all by armorbreak001 and cloudyun888).

Implementation:
- static/index.html: flicker-prevention head script resolves 'system' to
  'dark'/'light' via matchMedia before first paint. Adds 'System (auto)'
  as first option in theme picker. onchange calls _applyTheme().
- static/boot.js: new _applyTheme(name) helper — resolves 'system' via
  matchMedia, sets data-theme, registers a MQ change listener so the UI
  tracks OS switches live. loadSettings() now calls _applyTheme() instead
  of direct data-theme assignment.
- static/commands.js: adds 'system' to valid /theme command names,
  delegates apply to _applyTheme().
- static/panels.js: _settingsThemeOnOpen reads from localStorage (preserves
  'system' string, not the resolved 'dark'/'light'). _revertSettingsPreview
  calls _applyTheme() so reverting to 'system' correctly re-enables OS tracking.
- static/i18n.js: cmd_theme description now lists 'system' first in all 5
  locales (en, es, de, zh-Hans, zh-Hant).

Design choices vs submitted PRs:
- No separate system-theme.js file (unnecessary indirection).
- matchMedia listener does NOT POST to /api/settings (OS can change rapidly;
  persisting on every OS switch would hammer the server).

Co-authored-by: armorbreak001 <armorbreak001@users.noreply.github.com>
Co-authored-by: cloudyun888 <cloudyun888@users.noreply.github.com>
This commit is contained in:
Hermes Agent
2026-04-15 07:45:20 +00:00
parent 36830e3cd1
commit 44a544362f
5 changed files with 29 additions and 12 deletions

View File

@@ -568,6 +568,21 @@ window.addEventListener('resize',()=>{
}; };
})(); })();
// ── System theme helper ──────────────────────────────────────────────────────
function _applyTheme(name){
const resolved=(name==='system')
?(window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light')
:name;
document.documentElement.dataset.theme=resolved||'dark';
// 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);
}
}
function applyBotName(){ function applyBotName(){
const name=window._botName||'Hermes'; const name=window._botName||'Hermes';
document.title=name; document.title=name;
@@ -594,8 +609,8 @@ function applyBotName(){
window._notificationsEnabled=!!s.notifications_enabled; window._notificationsEnabled=!!s.notifications_enabled;
window._botName=s.bot_name||'Hermes'; window._botName=s.bot_name||'Hermes';
const _theme=s.theme||'dark'; const _theme=s.theme||'dark';
document.documentElement.dataset.theme=_theme;
localStorage.setItem('hermes-theme',_theme); localStorage.setItem('hermes-theme',_theme);
_applyTheme(_theme);
document.body.classList.toggle('bubble-layout',!!s.bubble_layout); document.body.classList.toggle('bubble-layout',!!s.bubble_layout);
if(typeof setLocale==='function'){ if(typeof setLocale==='function'){
const _lang=typeof resolvePreferredLocale==='function' const _lang=typeof resolvePreferredLocale==='function'

View File

@@ -121,14 +121,14 @@ async function cmdUsage(){
} }
async function cmdTheme(args){ async function cmdTheme(args){
const themes=['dark','light','slate','solarized','monokai','nord','oled']; const themes=['system','dark','light','slate','solarized','monokai','nord','oled'];
if(!args||!themes.includes(args.toLowerCase())){ if(!args||!themes.includes(args.toLowerCase())){
showToast(t('theme_usage')+themes.join('|')); showToast(t('theme_usage')+themes.join('|'));
return; return;
} }
const themeName=args.toLowerCase(); const themeName=args.toLowerCase();
document.documentElement.dataset.theme=themeName;
localStorage.setItem('hermes-theme',themeName); localStorage.setItem('hermes-theme',themeName);
_applyTheme(themeName);
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:themeName})});}catch(e){} try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:themeName})});}catch(e){}
// Update settings dropdown if panel is open // Update settings dropdown if panel is open
const sel=$('settingsTheme'); const sel=$('settingsTheme');

View File

@@ -66,7 +66,7 @@ const LOCALES = {
cmd_workspace: 'Switch workspace by name', cmd_workspace: 'Switch workspace by name',
cmd_new: 'Start a new chat session', cmd_new: 'Start a new chat session',
cmd_usage: 'Toggle token usage display on/off', cmd_usage: 'Toggle token usage display on/off',
cmd_theme: 'Switch theme (dark/light/slate/solarized/monokai/nord/oled)', cmd_theme: 'Switch theme (system/dark/light/slate/solarized/monokai/nord/oled)',
cmd_personality: 'Switch agent personality', cmd_personality: 'Switch agent personality',
cmd_skills: 'List available Hermes skills', cmd_skills: 'List available Hermes skills',
available_commands: 'Available commands:', available_commands: 'Available commands:',
@@ -480,7 +480,7 @@ const LOCALES = {
cmd_workspace: 'Cambiar de espacio de trabajo por nombre', cmd_workspace: 'Cambiar de espacio de trabajo por nombre',
cmd_new: 'Iniciar una nueva sesión de chat', cmd_new: 'Iniciar una nueva sesión de chat',
cmd_usage: 'Activar o desactivar el uso de tokens', cmd_usage: 'Activar o desactivar el uso de tokens',
cmd_theme: 'Cambiar tema (dark/light/slate/solarized/monokai/nord/oled)', cmd_theme: 'Cambiar tema (system/dark/light/slate/solarized/monokai/nord/oled)',
cmd_personality: 'Cambiar la personalidad del agente', cmd_personality: 'Cambiar la personalidad del agente',
cmd_skills: 'Listar las skills de Hermes disponibles', cmd_skills: 'Listar las skills de Hermes disponibles',
available_commands: 'Comandos disponibles:', available_commands: 'Comandos disponibles:',
@@ -884,7 +884,7 @@ const LOCALES = {
cmd_workspace: 'Workspace nach Namen wechseln', cmd_workspace: 'Workspace nach Namen wechseln',
cmd_new: 'Neue Chat-Sitzung starten', cmd_new: 'Neue Chat-Sitzung starten',
cmd_usage: 'Token-Verbrauchsanzeige umschalten', cmd_usage: 'Token-Verbrauchsanzeige umschalten',
cmd_theme: 'Theme wechseln (dark/light/slate/solarized/monokai/nord/oled)', cmd_theme: 'Theme wechseln (system/dark/light/slate/solarized/monokai/nord/oled)',
cmd_personality: 'Agenten-Persönlichkeit wechseln', cmd_personality: 'Agenten-Persönlichkeit wechseln',
cmd_skills: 'Verfügbare Hermes-Skills auflisten', cmd_skills: 'Verfügbare Hermes-Skills auflisten',
available_commands: 'Verfügbare Befehle:', available_commands: 'Verfügbare Befehle:',
@@ -1096,7 +1096,7 @@ const LOCALES = {
cmd_workspace: '\u6309\u540d\u79f0\u5207\u6362\u5de5\u4f5c\u533a', cmd_workspace: '\u6309\u540d\u79f0\u5207\u6362\u5de5\u4f5c\u533a',
cmd_new: '\u65b0\u5efa\u804a\u5929\u4f1a\u8bdd', cmd_new: '\u65b0\u5efa\u804a\u5929\u4f1a\u8bdd',
cmd_usage: '\u5207\u6362 token \u7528\u91cf\u663e\u793a', cmd_usage: '\u5207\u6362 token \u7528\u91cf\u663e\u793a',
cmd_theme: '\u5207\u6362\u4e3b\u9898\uff08dark/light/slate/solarized/monokai/nord/oled\uff09', cmd_theme: '\u5207\u6362\u4e3b\u9898\uff08system/dark/light/slate/solarized/monokai/nord/oled\uff09',
cmd_personality: '\u5207\u6362 Agent \u4eba\u8bbe', cmd_personality: '\u5207\u6362 Agent \u4eba\u8bbe',
cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd', cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd',
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a', available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
@@ -1499,7 +1499,7 @@ const LOCALES = {
cmd_workspace: '\u6309\u540d\u7a31\u5207\u63db\u5de5\u4f5c\u5340', cmd_workspace: '\u6309\u540d\u7a31\u5207\u63db\u5de5\u4f5c\u5340',
cmd_new: '\u65b0\u5efa\u804a\u5929\u6703\u8a71', cmd_new: '\u65b0\u5efa\u804a\u5929\u6703\u8a71',
cmd_usage: '\u5207\u63db token \u7528\u91cf\u986f\u793a', cmd_usage: '\u5207\u63db token \u7528\u91cf\u986f\u793a',
cmd_theme: '\u5207\u63db\u4e3b\u984c\uff08dark/light/slate/solarized/monokai/nord/oled\uff09', cmd_theme: '\u5207\u63db\u4e3b\u984c\uff08system/dark/light/slate/solarized/monokai/nord/oled\uff09',
cmd_personality: '\u5207\u63db Agent \u4eba\u8a2d', cmd_personality: '\u5207\u63db Agent \u4eba\u8a2d',
cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd', cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd',
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a', available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hermes</title> <title>Hermes</title>
<script>(function(){var t=localStorage.getItem('hermes-theme');if(t&&t!=='dark')document.documentElement.dataset.theme=t;})()</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>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) --> <!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous">
@@ -475,7 +475,8 @@
</div> </div>
<div class="settings-field"> <div class="settings-field">
<label for="settingsTheme" data-i18n="settings_label_theme">Theme</label> <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="document.documentElement.dataset.theme=this.value;localStorage.setItem('hermes-theme',this.value)"> <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="dark">Dark (default)</option>
<option value="light">Light</option> <option value="light">Light</option>
<option value="slate">Slate (charcoal)</option> <option value="slate">Slate (charcoal)</option>

View File

@@ -1112,7 +1112,7 @@ function toggleSettings(){
if(!overlay) return; if(!overlay) return;
if(overlay.style.display==='none'){ if(overlay.style.display==='none'){
_settingsDirty = false; _settingsDirty = false;
_settingsThemeOnOpen = document.documentElement.dataset.theme || 'dark'; _settingsThemeOnOpen = localStorage.getItem('hermes-theme') || document.documentElement.dataset.theme || 'dark';
_settingsSection = 'conversation'; _settingsSection = 'conversation';
overlay.style.display=''; overlay.style.display='';
loadSettingsPanel(); loadSettingsPanel();
@@ -1150,8 +1150,9 @@ function _closeSettingsPanel(){
// Revert live DOM/localStorage to what they were when the panel opened // Revert live DOM/localStorage to what they were when the panel opened
function _revertSettingsPreview(){ function _revertSettingsPreview(){
if(_settingsThemeOnOpen){ if(_settingsThemeOnOpen){
document.documentElement.dataset.theme = _settingsThemeOnOpen;
localStorage.setItem('hermes-theme', _settingsThemeOnOpen); localStorage.setItem('hermes-theme', _settingsThemeOnOpen);
if(typeof _applyTheme==='function') _applyTheme(_settingsThemeOnOpen);
else document.documentElement.dataset.theme = _settingsThemeOnOpen;
} }
} }