fix: settings unsaved-changes guard, add Slate theme, improve Light theme
Unsaved-changes guard: - _closeSettingsPanel() intercepts all three close paths (X button, overlay click, Escape key) and checks _settingsDirty before closing - If dirty: shows inline 'Unsaved changes' bar with Save & Close / Discard - Discard reverts the live theme preview to what it was when panel opened - _markSettingsDirty() wired to all inputs via addEventListener in loadSettingsPanel() - saveSettings() now resets dirty flag and hides the bar on successful save Theme improvements: - Add 'Slate' theme: warm charcoal (#2b2d30 bg), a softer/lighter dark option that sits between Dark and the full light themes - Rework 'Light' theme: replace pure white (#f5f5f7) with warm off-white (#f0ede8) -- warmer, lower contrast, less harsh on most displays - Update /theme command to include 'slate' in valid list - Add test_settings_set_theme_slate() to test_sprint26.py
This commit is contained in:
@@ -226,7 +226,7 @@ document.addEventListener('keydown',async e=>{
|
|||||||
if(e.key==='Escape'){
|
if(e.key==='Escape'){
|
||||||
// Close settings overlay if open
|
// Close settings overlay if open
|
||||||
const settingsOverlay=$('settingsOverlay');
|
const settingsOverlay=$('settingsOverlay');
|
||||||
if(settingsOverlay&&settingsOverlay.style.display!=='none'){toggleSettings();return;}
|
if(settingsOverlay&&settingsOverlay.style.display!=='none'){_closeSettingsPanel();return;}
|
||||||
// Close workspace dropdown
|
// Close workspace dropdown
|
||||||
closeWsDropdown();
|
closeWsDropdown();
|
||||||
// Clear session search
|
// Clear session search
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ async function cmdUsage(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cmdTheme(args){
|
async function cmdTheme(args){
|
||||||
const themes=['dark','light','solarized','monokai','nord'];
|
const themes=['dark','slate','light','solarized','monokai','nord'];
|
||||||
if(!args||!themes.includes(args.toLowerCase())){
|
if(!args||!themes.includes(args.toLowerCase())){
|
||||||
showToast('Usage: /theme '+themes.join('|'));
|
showToast('Usage: /theme '+themes.join('|'));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -312,7 +312,7 @@
|
|||||||
<div class="settings-panel">
|
<div class="settings-panel">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h3 style="margin:0;font-size:16px">Settings</h3>
|
<h3 style="margin:0;font-size:16px">Settings</h3>
|
||||||
<button class="panel-icon-btn" onclick="toggleSettings()" title="Close">✕</button>
|
<button class="panel-icon-btn" onclick="_closeSettingsPanel()" title="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-body">
|
<div class="settings-body">
|
||||||
<div class="settings-field">
|
<div class="settings-field">
|
||||||
@@ -334,7 +334,8 @@
|
|||||||
<label for="settingsTheme">Theme</label>
|
<label for="settingsTheme">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="document.documentElement.dataset.theme=this.value;localStorage.setItem('hermes-theme',this.value)">
|
||||||
<option value="dark">Dark (default)</option>
|
<option value="dark">Dark (default)</option>
|
||||||
<option value="light">Light</option>
|
<option value="slate">Slate (charcoal)</option>
|
||||||
|
<option value="light">Light (warm off-white)</option>
|
||||||
<option value="solarized">Solarized Dark</option>
|
<option value="solarized">Solarized Dark</option>
|
||||||
<option value="monokai">Monokai</option>
|
<option value="monokai">Monokai</option>
|
||||||
<option value="nord">Nord</option>
|
<option value="nord">Nord</option>
|
||||||
|
|||||||
@@ -908,17 +908,70 @@ document.addEventListener('drop',e=>{e.preventDefault();dragCounter=0;wrap.class
|
|||||||
|
|
||||||
// ── Settings panel ───────────────────────────────────────────────────────────
|
// ── Settings panel ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _settingsDirty = false;
|
||||||
|
let _settingsThemeOnOpen = null; // track theme at open time for discard revert
|
||||||
|
|
||||||
function toggleSettings(){
|
function toggleSettings(){
|
||||||
const overlay=$('settingsOverlay');
|
const overlay=$('settingsOverlay');
|
||||||
if(!overlay) return;
|
if(!overlay) return;
|
||||||
if(overlay.style.display==='none'){
|
if(overlay.style.display==='none'){
|
||||||
|
_settingsDirty = false;
|
||||||
|
_settingsThemeOnOpen = document.documentElement.dataset.theme || 'dark';
|
||||||
overlay.style.display='';
|
overlay.style.display='';
|
||||||
loadSettingsPanel();
|
loadSettingsPanel();
|
||||||
} else {
|
} else {
|
||||||
overlay.style.display='none';
|
_closeSettingsPanel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close with unsaved-changes check. If dirty, show a confirm dialog.
|
||||||
|
function _closeSettingsPanel(){
|
||||||
|
if(!_settingsDirty){
|
||||||
|
// Nothing changed -- revert any live preview and close
|
||||||
|
_revertSettingsPreview();
|
||||||
|
$('settingsOverlay').style.display='none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Dirty -- show inline confirm bar
|
||||||
|
_showSettingsUnsavedBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revert live DOM/localStorage to what they were when the panel opened
|
||||||
|
function _revertSettingsPreview(){
|
||||||
|
if(_settingsThemeOnOpen){
|
||||||
|
document.documentElement.dataset.theme = _settingsThemeOnOpen;
|
||||||
|
localStorage.setItem('hermes-theme', _settingsThemeOnOpen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the "Unsaved changes" bar inside the settings panel
|
||||||
|
function _showSettingsUnsavedBar(){
|
||||||
|
let bar = $('settingsUnsavedBar');
|
||||||
|
if(bar){ bar.style.display=''; return; }
|
||||||
|
// Create it
|
||||||
|
bar = document.createElement('div');
|
||||||
|
bar.id = 'settingsUnsavedBar';
|
||||||
|
bar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;background:rgba(233,69,96,.12);border:1px solid rgba(233,69,96,.3);border-radius:8px;padding:10px 14px;margin:0 0 12px;font-size:13px;';
|
||||||
|
bar.innerHTML = '<span style="color:var(--text)">You have unsaved changes.</span>'
|
||||||
|
+ '<span style="display:flex;gap:8px">'
|
||||||
|
+ '<button onclick="_discardSettings()" style="padding:5px 12px;border-radius:6px;border:1px solid var(--border2);background:rgba(255,255,255,.06);color:var(--muted);cursor:pointer;font-size:12px;font-weight:600">Discard</button>'
|
||||||
|
+ '<button onclick="saveSettings(true)" style="padding:5px 12px;border-radius:6px;border:none;background:var(--accent);color:#fff;cursor:pointer;font-size:12px;font-weight:600">Save & Close</button>'
|
||||||
|
+ '</span>';
|
||||||
|
const body = document.querySelector('.settings-body') || document.querySelector('.settings-panel');
|
||||||
|
if(body) body.prepend(bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _discardSettings(){
|
||||||
|
_revertSettingsPreview();
|
||||||
|
_settingsDirty = false;
|
||||||
|
$('settingsOverlay').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark settings as dirty whenever anything changes
|
||||||
|
function _markSettingsDirty(){
|
||||||
|
_settingsDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSettingsPanel(){
|
async function loadSettingsPanel(){
|
||||||
try{
|
try{
|
||||||
const settings=await api('/api/settings');
|
const settings=await api('/api/settings');
|
||||||
@@ -940,6 +993,7 @@ async function loadSettingsPanel(){
|
|||||||
}
|
}
|
||||||
}catch(e){}
|
}catch(e){}
|
||||||
modelSel.value=settings.default_model||'';
|
modelSel.value=settings.default_model||'';
|
||||||
|
modelSel.addEventListener('change',_markSettingsDirty,{once:false});
|
||||||
}
|
}
|
||||||
// Populate workspace dropdown from /api/workspaces
|
// Populate workspace dropdown from /api/workspaces
|
||||||
const wsSel=$('settingsWorkspace');
|
const wsSel=$('settingsWorkspace');
|
||||||
@@ -954,22 +1008,23 @@ async function loadSettingsPanel(){
|
|||||||
}
|
}
|
||||||
}catch(e){}
|
}catch(e){}
|
||||||
wsSel.value=settings.default_workspace||'';
|
wsSel.value=settings.default_workspace||'';
|
||||||
|
wsSel.addEventListener('change',_markSettingsDirty,{once:false});
|
||||||
}
|
}
|
||||||
// Send key preference
|
// Send key preference
|
||||||
const sendKeySel=$('settingsSendKey');
|
const sendKeySel=$('settingsSendKey');
|
||||||
if(sendKeySel) sendKeySel.value=settings.send_key||'enter';
|
if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||||
// Theme preference
|
// Theme preference
|
||||||
const themeSel=$('settingsTheme');
|
const themeSel=$('settingsTheme');
|
||||||
if(themeSel) themeSel.value=settings.theme||'dark';
|
if(themeSel){themeSel.value=settings.theme||'dark';themeSel.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||||
const showUsageCb=$('settingsShowTokenUsage');
|
const showUsageCb=$('settingsShowTokenUsage');
|
||||||
if(showUsageCb) showUsageCb.checked=!!settings.show_token_usage;
|
if(showUsageCb){showUsageCb.checked=!!settings.show_token_usage;showUsageCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||||
const showCliCb=$('settingsShowCliSessions');
|
const showCliCb=$('settingsShowCliSessions');
|
||||||
if(showCliCb) showCliCb.checked=!!settings.show_cli_sessions;
|
if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||||
const syncCb=$('settingsSyncInsights');
|
const syncCb=$('settingsSyncInsights');
|
||||||
if(syncCb) syncCb.checked=!!settings.sync_to_insights;
|
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||||
// Password field: always blank (we don't send hash back)
|
// Password field: always blank (we don't send hash back)
|
||||||
const pwField=$('settingsPassword');
|
const pwField=$('settingsPassword');
|
||||||
if(pwField) pwField.value='';
|
if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});}
|
||||||
// Show auth buttons only when auth is active
|
// Show auth buttons only when auth is active
|
||||||
try{
|
try{
|
||||||
const authStatus=await api('/api/auth/status');
|
const authStatus=await api('/api/auth/status');
|
||||||
@@ -984,18 +1039,19 @@ async function loadSettingsPanel(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettings(){
|
async function saveSettings(andClose){
|
||||||
const model=($('settingsModel')||{}).value;
|
const model=($('settingsModel')||{}).value;
|
||||||
const workspace=($('settingsWorkspace')||{}).value;
|
const workspace=($('settingsWorkspace')||{}).value;
|
||||||
const sendKey=($('settingsSendKey')||{}).value;
|
const sendKey=($('settingsSendKey')||{}).value;
|
||||||
const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked;
|
const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked;
|
||||||
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
|
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
|
||||||
const pw=($('settingsPassword')||{}).value;
|
const pw=($('settingsPassword')||{}).value;
|
||||||
|
const theme=($('settingsTheme')||{}).value||'dark';
|
||||||
const body={};
|
const body={};
|
||||||
if(model) body.default_model=model;
|
if(model) body.default_model=model;
|
||||||
if(workspace) body.default_workspace=workspace;
|
if(workspace) body.default_workspace=workspace;
|
||||||
if(sendKey) body.send_key=sendKey;
|
if(sendKey) body.send_key=sendKey;
|
||||||
body.theme=($('settingsTheme')||{}).value||'dark';
|
body.theme=theme;
|
||||||
body.show_token_usage=showTokenUsage;
|
body.show_token_usage=showTokenUsage;
|
||||||
body.show_cli_sessions=showCliSessions;
|
body.show_cli_sessions=showCliSessions;
|
||||||
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
|
||||||
@@ -1006,7 +1062,9 @@ async function saveSettings(){
|
|||||||
window._sendKey=sendKey||'enter';
|
window._sendKey=sendKey||'enter';
|
||||||
window._showTokenUsage=showTokenUsage;
|
window._showTokenUsage=showTokenUsage;
|
||||||
showToast('Settings saved (password set — login now required)');
|
showToast('Settings saved (password set — login now required)');
|
||||||
toggleSettings();
|
_settingsDirty=false; _settingsThemeOnOpen=theme;
|
||||||
|
const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none';
|
||||||
|
$('settingsOverlay').style.display='none';
|
||||||
return;
|
return;
|
||||||
}catch(e){showToast('Save failed: '+e.message);return;}
|
}catch(e){showToast('Save failed: '+e.message);return;}
|
||||||
}
|
}
|
||||||
@@ -1015,10 +1073,12 @@ async function saveSettings(){
|
|||||||
window._sendKey=sendKey||'enter';
|
window._sendKey=sendKey||'enter';
|
||||||
window._showTokenUsage=showTokenUsage;
|
window._showTokenUsage=showTokenUsage;
|
||||||
window._showCliSessions=showCliSessions;
|
window._showCliSessions=showCliSessions;
|
||||||
|
_settingsDirty=false; _settingsThemeOnOpen=theme;
|
||||||
|
const bar=$('settingsUnsavedBar'); if(bar) bar.style.display='none';
|
||||||
renderMessages();
|
renderMessages();
|
||||||
if(typeof renderSessionList==='function') renderSessionList();
|
if(typeof renderSessionList==='function') renderSessionList();
|
||||||
showToast('Settings saved');
|
showToast('Settings saved');
|
||||||
toggleSettings();
|
$('settingsOverlay').style.display='none';
|
||||||
}catch(e){
|
}catch(e){
|
||||||
showToast('Save failed: '+e.message);
|
showToast('Save failed: '+e.message);
|
||||||
}
|
}
|
||||||
@@ -1048,10 +1108,10 @@ async function disableAuth(){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close settings on overlay click (not panel click)
|
// Close settings on overlay click (not panel click) -- with unsaved-changes check
|
||||||
document.addEventListener('click',e=>{
|
document.addEventListener('click',e=>{
|
||||||
const overlay=$('settingsOverlay');
|
const overlay=$('settingsOverlay');
|
||||||
if(overlay&&e.target===overlay) toggleSettings();
|
if(overlay&&e.target===overlay) _closeSettingsPanel();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Cron completion alerts ────────────────────────────────────────────────────
|
// ── Cron completion alerts ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -4,14 +4,19 @@
|
|||||||
--text:#e8e8f0;--muted:#8888aa;--accent:#e94560;--blue:#7cb9ff;--gold:#c9a84c;--code-bg:#0d1117;
|
--text:#e8e8f0;--muted:#8888aa;--accent:#e94560;--blue:#7cb9ff;--gold:#c9a84c;--code-bg:#0d1117;
|
||||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-size:14px;line-height:1.6;
|
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-size:14px;line-height:1.6;
|
||||||
}
|
}
|
||||||
/* ── Light theme ── */
|
/* ── Slate theme (warm charcoal, lighter than dark, easier on the eyes) ── */
|
||||||
|
:root[data-theme="slate"]{
|
||||||
|
--bg:#2b2d30;--sidebar:#25272b;--border:rgba(255,255,255,0.09);--border2:rgba(255,255,255,0.16);
|
||||||
|
--text:#d4d4d8;--muted:#8a8a9a;--accent:#e06c75;--blue:#82aaff;--gold:#d4a85a;--code-bg:#1e2023;
|
||||||
|
}
|
||||||
|
/* ── Light theme (warm off-white, softer than pure white) ── */
|
||||||
:root[data-theme="light"]{
|
:root[data-theme="light"]{
|
||||||
--bg:#f5f5f7;--sidebar:#e8e8ed;--border:rgba(0,0,0,0.10);--border2:rgba(0,0,0,0.16);
|
--bg:#f0ede8;--sidebar:#e4e0d8;--border:rgba(0,0,0,0.09);--border2:rgba(0,0,0,0.15);
|
||||||
--text:#1c1c1e;--muted:#6e6e80;--accent:#c0392b;--blue:#0a6dc2;--gold:#a07a20;--code-bg:#f0f0f5;
|
--text:#2c2825;--muted:#7a746a;--accent:#b5451b;--blue:#2d6fa3;--gold:#8a6520;--code-bg:#e8e4de;
|
||||||
}
|
}
|
||||||
:root[data-theme="light"] ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);}
|
:root[data-theme="light"] ::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);}
|
||||||
:root[data-theme="light"] ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.3);}
|
:root[data-theme="light"] ::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.3);}
|
||||||
:root[data-theme="light"] ::selection{background:rgba(10,109,194,.2);}
|
:root[data-theme="light"] ::selection{background:rgba(45,111,163,.2);}
|
||||||
/* ── Solarized Dark theme ── */
|
/* ── Solarized Dark theme ── */
|
||||||
:root[data-theme="solarized"]{
|
:root[data-theme="solarized"]{
|
||||||
--bg:#002b36;--sidebar:#073642;--border:rgba(255,255,255,0.08);--border2:rgba(255,255,255,0.13);
|
--bg:#002b36;--sidebar:#073642;--border:rgba(255,255,255,0.08);--border2:rgba(255,255,255,0.13);
|
||||||
|
|||||||
@@ -74,6 +74,16 @@ def test_settings_set_theme_nord():
|
|||||||
post("/api/settings", {"theme": "dark"})
|
post("/api/settings", {"theme": "dark"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_set_theme_slate():
|
||||||
|
"""Setting theme to 'slate' should persist."""
|
||||||
|
try:
|
||||||
|
post("/api/settings", {"theme": "slate"})
|
||||||
|
d, _ = get("/api/settings")
|
||||||
|
assert d.get("theme") == "slate"
|
||||||
|
finally:
|
||||||
|
post("/api/settings", {"theme": "dark"})
|
||||||
|
|
||||||
|
|
||||||
def test_settings_custom_theme_accepted():
|
def test_settings_custom_theme_accepted():
|
||||||
"""Custom theme names should be accepted (no enum gate)."""
|
"""Custom theme names should be accepted (no enum gate)."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user