Merge pull request #98 from nesquena/sprint-26-themes
Sprint 26: Pluggable UI themes — dark, light, solarized, monokai, nord
This commit is contained in:
38
CHANGELOG.md
38
CHANGELOG.md
@@ -5,6 +5,42 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [v0.34] Sprint 26 -- Pluggable UI Themes
|
||||||
|
*April 5, 2026 | 433 tests*
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- **6 built-in themes.** Dark (default), Light, Slate, Solarized Dark, Monokai,
|
||||||
|
Nord. Defined as CSS variable overrides on `:root[data-theme="name"]` — the
|
||||||
|
entire UI adapts automatically.
|
||||||
|
- **Theme picker in Settings.** Dropdown with instant live preview. Changes
|
||||||
|
apply immediately as you click through options.
|
||||||
|
- **`/theme` slash command.** `/theme dark`, `/theme light`, etc.
|
||||||
|
- **Theme persistence.** Saved server-side in `settings.json` and client-side
|
||||||
|
in `localStorage` for flicker-free loading on page refresh.
|
||||||
|
- **Flash prevention.** Inline `<script>` in `<head>` reads localStorage before
|
||||||
|
the stylesheet loads — no flash of the wrong theme.
|
||||||
|
- **Custom theme support.** Any theme name is accepted (no enum gate). Create a
|
||||||
|
`:root[data-theme="name"]` CSS block and it works. See `THEMES.md`.
|
||||||
|
- **Unsaved changes guard.** Settings panel now tracks dirty state and shows a
|
||||||
|
"You have unsaved changes" bar with Save/Discard buttons when closing with
|
||||||
|
unpersisted changes. Theme preview reverts on discard.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- `static/style.css`: 6 theme blocks using CSS variable overrides. Light theme
|
||||||
|
includes scrollbar and selection overrides.
|
||||||
|
- `static/commands.js`: `/theme` command with validation.
|
||||||
|
- `static/panels.js`: Settings dirty tracking, revert-on-discard, unsaved bar.
|
||||||
|
- `static/boot.js`: Theme applied from server settings on boot.
|
||||||
|
- `api/config.py`: `theme` field in `_SETTINGS_DEFAULTS` (no enum gate).
|
||||||
|
- `THEMES.md`: Full documentation for creating custom themes.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- 9 new tests in `test_sprint26.py`: default theme, round-trip persistence for
|
||||||
|
all 6 built-in themes, custom theme acceptance, settings isolation.
|
||||||
|
Total: **433 tests**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [v0.33] /insights Sync + state.db Bridge Fix
|
## [v0.33] /insights Sync + state.db Bridge Fix
|
||||||
*April 5, 2026 | 424 tests*
|
*April 5, 2026 | 424 tests*
|
||||||
|
|
||||||
@@ -1152,4 +1188,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Last updated: v0.32, April 5, 2026 | Tests: 424*
|
*Last updated: v0.34, April 5, 2026 | Tests: 433*
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -314,17 +314,24 @@ across 22 test files.
|
|||||||
- 20MB POST body size limit
|
- 20MB POST body size limit
|
||||||
- CDN resources pinned with SRI integrity hashes
|
- CDN resources pinned with SRI integrity hashes
|
||||||
|
|
||||||
|
### Themes
|
||||||
|
- 6 built-in themes: Dark (default), Light, Slate, Solarized Dark, Monokai, Nord
|
||||||
|
- Switch via Settings panel dropdown (instant live preview) or `/theme` command
|
||||||
|
- Persists across reloads (server-side in settings.json + localStorage for flicker-free loading)
|
||||||
|
- Custom themes: define a `:root[data-theme="name"]` CSS block and it works — see [THEMES.md](THEMES.md)
|
||||||
|
|
||||||
### Settings and configuration
|
### Settings and configuration
|
||||||
- Settings panel (gear icon) -- default model, default workspace, send key preference
|
- Settings panel (gear icon) -- default model, default workspace, send key, theme
|
||||||
- Send key: Enter (default) or Ctrl/Cmd+Enter
|
- Send key: Enter (default) or Ctrl/Cmd+Enter
|
||||||
- Show/hide CLI sessions toggle (enabled by default)
|
- Show/hide CLI sessions toggle (enabled by default)
|
||||||
- Token usage display toggle (off by default, also via `/usage` command)
|
- Token usage display toggle (off by default, also via `/usage` command)
|
||||||
|
- Unsaved changes guard -- discard/save prompt when closing with unpersisted changes
|
||||||
- Cron completion alerts -- toast notifications and unread badge on Tasks tab
|
- Cron completion alerts -- toast notifications and unread badge on Tasks tab
|
||||||
- Background agent error alerts -- banner when a non-active session encounters an error
|
- Background agent error alerts -- banner when a non-active session encounters an error
|
||||||
|
|
||||||
### Slash commands
|
### Slash commands
|
||||||
- Type `/` in the composer for autocomplete dropdown
|
- Type `/` in the composer for autocomplete dropdown
|
||||||
- Built-in: `/help`, `/clear`, `/model <name>`, `/workspace <name>`, `/new`, `/usage`
|
- Built-in: `/help`, `/clear`, `/model <name>`, `/workspace <name>`, `/new`, `/usage`, `/theme`, `/compact`
|
||||||
- Arrow keys navigate, Tab/Enter select, Escape closes
|
- Arrow keys navigate, Tab/Enter select, Escape closes
|
||||||
- Unrecognized commands pass through to the agent
|
- Unrecognized commands pass through to the agent
|
||||||
|
|
||||||
@@ -393,6 +400,7 @@ State lives outside the repo at `~/.hermes/webui-mvp/` by default
|
|||||||
- `TESTING.md` -- manual browser test plan and automated coverage reference
|
- `TESTING.md` -- manual browser test plan and automated coverage reference
|
||||||
- `CHANGELOG.md` -- release notes per sprint
|
- `CHANGELOG.md` -- release notes per sprint
|
||||||
- `SPRINTS.md` -- forward sprint plan with CLI + Claude parity targets
|
- `SPRINTS.md` -- forward sprint plan with CLI + Claude parity targets
|
||||||
|
- `THEMES.md` -- theme system documentation, custom theme guide
|
||||||
|
|
||||||
## Repo
|
## Repo
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Hermes Web UI -- Forward Sprint Plan
|
# Hermes Web UI -- Forward Sprint Plan
|
||||||
|
|
||||||
> Current state: v0.32 | 424 tests | Daily driver ready
|
> Current state: v0.34 | 433 tests | Daily driver ready
|
||||||
> This document plans the path from here to two targets:
|
> This document plans the path from here to two targets:
|
||||||
>
|
>
|
||||||
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
|
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
|
||||||
@@ -1156,6 +1156,6 @@ New test cases in `tests/test_sprint26.py`:
|
|||||||
---
|
---
|
||||||
|
|
||||||
*Last updated: April 5, 2026*
|
*Last updated: April 5, 2026*
|
||||||
*Current version: v0.33 | 424 tests*
|
*Current version: v0.34 | 433 tests*
|
||||||
*Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)*
|
*Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)*
|
||||||
*Horizon sprint: Sprint 26 (Pluggable UI Themes)*
|
*Horizon sprint: Sprint 26 (Pluggable UI Themes)*
|
||||||
|
|||||||
117
THEMES.md
Normal file
117
THEMES.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Hermes Web UI — Themes
|
||||||
|
|
||||||
|
Hermes Web UI supports pluggable color themes. Five themes ship built-in, and
|
||||||
|
you can create your own with pure CSS — no Python changes needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Switching Themes
|
||||||
|
|
||||||
|
**Settings panel:** Click the gear icon, select a theme from the dropdown. The
|
||||||
|
preview is instant — the UI updates as you click through options.
|
||||||
|
|
||||||
|
**Slash command:** Type `/theme dark` or `/theme light` in the composer.
|
||||||
|
|
||||||
|
**Themes persist** across page reloads and server restarts (stored in
|
||||||
|
`settings.json` server-side, with `localStorage` for flicker-free loading).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Built-in Themes
|
||||||
|
|
||||||
|
| Theme | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| **Dark** (default) | Deep navy/indigo with muted blue accents. Easy on the eyes for long sessions. |
|
||||||
|
| **Light** | Warm off-white with dark text. High contrast for bright environments. |
|
||||||
|
| **Slate** | Warm charcoal, lighter than Dark. Easier on the eyes for extended use. |
|
||||||
|
| **Solarized Dark** | Ethan Schoonover's classic dark palette. Teal background, warm accents. |
|
||||||
|
| **Monokai** | Warm dark theme inspired by the Monokai editor scheme. Green/pink accents. |
|
||||||
|
| **Nord** | Arctic blue-gray palette from the Nord color system. Calm and minimal. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Creating a Custom Theme
|
||||||
|
|
||||||
|
A theme is a CSS block that overrides the color variables. Add it to
|
||||||
|
`static/style.css` (or a separate file that you link after the main stylesheet).
|
||||||
|
|
||||||
|
### Step 1: Define your theme block
|
||||||
|
|
||||||
|
Every color in the UI comes from these CSS variables:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root[data-theme="your-theme-name"] {
|
||||||
|
--bg: #1a1a2e; /* Main background */
|
||||||
|
--sidebar: #16213e; /* Sidebar background */
|
||||||
|
--border: rgba(255,255,255,0.08); /* Subtle borders */
|
||||||
|
--border2: rgba(255,255,255,0.14); /* Stronger borders */
|
||||||
|
--text: #e8e8f0; /* Primary text color */
|
||||||
|
--muted: #8888aa; /* Secondary/muted text */
|
||||||
|
--accent: #e94560; /* Accent color (errors, warnings, delete) */
|
||||||
|
--blue: #7cb9ff; /* Primary action color (links, active states) */
|
||||||
|
--gold: #c9a84c; /* Secondary accent (pinned items, gold highlights) */
|
||||||
|
--code-bg: #0d1117; /* Code block background */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it. Override any or all of these variables. The entire UI adapts
|
||||||
|
automatically because every color reference uses `var(--name)`.
|
||||||
|
|
||||||
|
### Step 2: Add it to the theme picker (optional)
|
||||||
|
|
||||||
|
To make your theme appear in the Settings dropdown, add an `<option>` to the
|
||||||
|
theme `<select>` in `static/index.html`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<option value="your-theme-name">Your Theme Name</option>
|
||||||
|
```
|
||||||
|
|
||||||
|
And update the `/theme` command's valid theme list in `static/commands.js`.
|
||||||
|
|
||||||
|
### Step 3: Test it
|
||||||
|
|
||||||
|
Switch to your theme via `/theme your-theme-name` or the Settings panel.
|
||||||
|
Check these areas:
|
||||||
|
- Sidebar session list (hover states, active state, project borders)
|
||||||
|
- Message bubbles (user vs assistant styling)
|
||||||
|
- Code blocks (background contrast, copy button visibility)
|
||||||
|
- Tool cards (running indicator, expand/collapse)
|
||||||
|
- Settings panel and login page
|
||||||
|
- Mobile layout (hamburger sidebar, bottom nav)
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- **Light themes** need additional scrollbar overrides to avoid dark scrollbars
|
||||||
|
on a light background. See the built-in light theme for the pattern.
|
||||||
|
- The **logo gradient** uses `--accent` automatically, so it adapts to your
|
||||||
|
theme without extra work.
|
||||||
|
- **Prism.js syntax highlighting** uses its own CDN stylesheet (Tomorrow theme).
|
||||||
|
It works well on dark themes; on light themes the contrast is acceptable but
|
||||||
|
not perfect. Custom Prism theme support is planned for a future update.
|
||||||
|
- **No server changes needed.** The `theme` setting in `settings.json` accepts
|
||||||
|
any string — your custom theme name will persist without code changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How Themes Work Internally
|
||||||
|
|
||||||
|
1. Each theme is a `:root[data-theme="name"]` CSS block that overrides variables.
|
||||||
|
2. Switching themes sets `document.documentElement.dataset.theme = name` in JS.
|
||||||
|
3. A tiny inline `<script>` in `<head>` reads `localStorage` before the
|
||||||
|
stylesheet loads — this prevents a flash of the wrong theme on page load.
|
||||||
|
4. The theme preference is saved server-side via `POST /api/settings` and
|
||||||
|
loaded on boot via `GET /api/settings`.
|
||||||
|
5. The `/theme` command and Settings dropdown both update the DOM, localStorage,
|
||||||
|
and server settings simultaneously.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributing a Theme
|
||||||
|
|
||||||
|
To contribute a new built-in theme:
|
||||||
|
|
||||||
|
1. Add your `:root[data-theme="name"]` block to `static/style.css`
|
||||||
|
2. Add the `<option>` to the Settings panel in `static/index.html`
|
||||||
|
3. Add the theme name to the valid list in `cmdTheme()` in `static/commands.js`
|
||||||
|
4. Test on desktop and mobile
|
||||||
|
5. Open a PR — themes are pure CSS additions with no backend changes needed
|
||||||
@@ -653,6 +653,7 @@ _SETTINGS_DEFAULTS = {
|
|||||||
'show_token_usage': False, # show input/output token badge below assistant messages
|
'show_token_usage': False, # show input/output token badge below assistant messages
|
||||||
'show_cli_sessions': False, # merge CLI sessions from state.db into the sidebar
|
'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
|
'sync_to_insights': False, # mirror WebUI token usage to state.db for /insights
|
||||||
|
'theme': 'dark', # active UI theme name (no enum gate -- allows custom themes)
|
||||||
'password_hash': None, # SHA-256 hash; None = auth disabled
|
'password_hash': None, # SHA-256 hash; None = auth disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -308,7 +308,7 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
|
|||||||
|
|
||||||
(async()=>{
|
(async()=>{
|
||||||
// Load send key preference
|
// Load send key preference
|
||||||
try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;}
|
try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;}
|
||||||
// Fetch active profile
|
// Fetch active profile
|
||||||
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
|
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
|
||||||
// Update profile chip label immediately
|
// Update profile chip label immediately
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const COMMANDS=[
|
|||||||
{name:'workspace', desc:'Switch workspace by name', fn:cmdWorkspace, arg:'name'},
|
{name:'workspace', desc:'Switch workspace by name', fn:cmdWorkspace, arg:'name'},
|
||||||
{name:'new', desc:'Start a new chat session', fn:cmdNew},
|
{name:'new', desc:'Start a new chat session', fn:cmdNew},
|
||||||
{name:'usage', desc:'Toggle token usage display on/off', fn:cmdUsage},
|
{name:'usage', desc:'Toggle token usage display on/off', fn:cmdUsage},
|
||||||
|
{name:'theme', desc:'Switch theme (dark/light/slate/solarized/monokai/nord)', fn:cmdTheme, arg:'name'},
|
||||||
];
|
];
|
||||||
|
|
||||||
function parseCommand(text){
|
function parseCommand(text){
|
||||||
@@ -122,6 +123,22 @@ async function cmdUsage(){
|
|||||||
showToast('Token usage '+(next?'on':'off'));
|
showToast('Token usage '+(next?'on':'off'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cmdTheme(args){
|
||||||
|
const themes=['dark','light','slate','solarized','monokai','nord'];
|
||||||
|
if(!args||!themes.includes(args.toLowerCase())){
|
||||||
|
showToast('Usage: /theme '+themes.join('|'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t=args.toLowerCase();
|
||||||
|
document.documentElement.dataset.theme=t;
|
||||||
|
localStorage.setItem('hermes-theme',t);
|
||||||
|
try{await api('/api/settings',{method:'POST',body:JSON.stringify({theme:t})});}catch(e){}
|
||||||
|
// Update settings dropdown if panel is open
|
||||||
|
const sel=$('settingsTheme');
|
||||||
|
if(sel)sel.value=t;
|
||||||
|
showToast('Theme: '+t);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Autocomplete dropdown ───────────────────────────────────────────────────
|
// ── Autocomplete dropdown ───────────────────────────────────────────────────
|
||||||
|
|
||||||
let _cmdSelectedIdx=-1;
|
let _cmdSelectedIdx=-1;
|
||||||
|
|||||||
@@ -4,6 +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>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
|
<!-- Prism.js syntax highlighting (loaded async, non-blocking) -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" integrity="sha384-wFjoQjtV1y5jVHbt0p35Ui8aV8GVpEZkyF99OXWqP/eNJDU93D3Ugxkoyh6Y2I4A" crossorigin="anonymous">
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.33</div></div></div>
|
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.34</div></div></div>
|
||||||
<div class="sidebar-nav">
|
<div class="sidebar-nav">
|
||||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||||
@@ -311,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">
|
||||||
@@ -329,6 +330,17 @@
|
|||||||
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
|
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<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)">
|
||||||
|
<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>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="settings-field">
|
<div class="settings-field">
|
||||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
<input type="checkbox" id="settingsShowTokenUsage" style="width:15px;height:15px;accent-color:var(--accent)">
|
<input type="checkbox" id="settingsShowTokenUsage" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||||
|
|||||||
@@ -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</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,19 +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
|
||||||
|
const themeSel=$('settingsTheme');
|
||||||
|
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');
|
||||||
@@ -981,17 +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=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;
|
||||||
@@ -1002,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;}
|
||||||
}
|
}
|
||||||
@@ -1011,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);
|
||||||
}
|
}
|
||||||
@@ -1044,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,6 +4,34 @@
|
|||||||
--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;
|
||||||
}
|
}
|
||||||
|
/* ── 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"]{
|
||||||
|
--bg:#f0ede8;--sidebar:#e4e0d8;--border:rgba(0,0,0,0.09);--border2:rgba(0,0,0,0.15);
|
||||||
|
--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:hover{background:rgba(0,0,0,.3);}
|
||||||
|
:root[data-theme="light"] ::selection{background:rgba(45,111,163,.2);}
|
||||||
|
/* ── Solarized Dark theme ── */
|
||||||
|
:root[data-theme="solarized"]{
|
||||||
|
--bg:#002b36;--sidebar:#073642;--border:rgba(255,255,255,0.08);--border2:rgba(255,255,255,0.13);
|
||||||
|
--text:#839496;--muted:#657b83;--accent:#dc322f;--blue:#268bd2;--gold:#b58900;--code-bg:#073642;
|
||||||
|
}
|
||||||
|
/* ── Monokai theme ── */
|
||||||
|
:root[data-theme="monokai"]{
|
||||||
|
--bg:#272822;--sidebar:#1e1f1c;--border:rgba(255,255,255,0.07);--border2:rgba(255,255,255,0.12);
|
||||||
|
--text:#f8f8f2;--muted:#75715e;--accent:#f92672;--blue:#66d9e8;--gold:#e6db74;--code-bg:#1e1f1c;
|
||||||
|
}
|
||||||
|
/* ── Nord theme ── */
|
||||||
|
:root[data-theme="nord"]{
|
||||||
|
--bg:#2e3440;--sidebar:#272c36;--border:rgba(255,255,255,0.07);--border2:rgba(255,255,255,0.12);
|
||||||
|
--text:#eceff4;--muted:#9099aa;--accent:#bf616a;--blue:#81a1c1;--gold:#ebcb8b;--code-bg:#272c36;
|
||||||
|
}
|
||||||
body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;}
|
body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;}
|
||||||
.layout{display:flex;width:100%;height:100vh;height:100dvh;}
|
.layout{display:flex;width:100%;height:100vh;height:100dvh;}
|
||||||
.sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:visible;flex-shrink:0;}
|
.sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:visible;flex-shrink:0;}
|
||||||
@@ -353,7 +381,7 @@
|
|||||||
/* Tool cards */
|
/* Tool cards */
|
||||||
.tool-card{margin-left:0!important;font-size:12px;}
|
.tool-card{margin-left:0!important;font-size:12px;}
|
||||||
/* Settings modal */
|
/* Settings modal */
|
||||||
.settings-panel{width:95vw;max-width:95vw;}
|
.settings-panel{width:95vw;max-width:95vw;min-height:min(580px,88vh);max-height:92vh;}
|
||||||
/* Login page responsive */
|
/* Login page responsive */
|
||||||
.card{width:90vw;max-width:320px;padding:28px 24px;}
|
.card{width:90vw;max-width:320px;padding:28px 24px;}
|
||||||
}
|
}
|
||||||
@@ -624,7 +652,7 @@ body.resizing{user-select:none;cursor:col-resize;}
|
|||||||
|
|
||||||
/* ── Settings overlay ── */
|
/* ── Settings overlay ── */
|
||||||
.settings-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center;}
|
.settings-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center;}
|
||||||
.settings-panel{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:0;width:380px;max-width:90vw;max-height:80vh;overflow:visible;box-shadow:0 12px 40px rgba(0,0,0,.5);display:flex;flex-direction:column;}
|
.settings-panel{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:0;width:420px;max-width:92vw;max-height:92vh;min-height:min(680px,90vh);overflow:visible;box-shadow:0 12px 40px rgba(0,0,0,.5);display:flex;flex-direction:column;}
|
||||||
.settings-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px 12px;border-bottom:1px solid var(--border);}
|
.settings-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px 12px;border-bottom:1px solid var(--border);}
|
||||||
.settings-body{padding:20px;overflow-y:auto;flex:1;}
|
.settings-body{padding:20px;overflow-y:auto;flex:1;}
|
||||||
.settings-field{margin-bottom:16px;}
|
.settings-field{margin-bottom:16px;}
|
||||||
|
|||||||
119
tests/test_sprint26.py
Normal file
119
tests/test_sprint26.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""
|
||||||
|
Sprint 26 Tests: pluggable UI themes — settings persistence, theme default,
|
||||||
|
custom theme names accepted.
|
||||||
|
"""
|
||||||
|
import json, urllib.error, urllib.request
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:8788"
|
||||||
|
|
||||||
|
|
||||||
|
def get(path):
|
||||||
|
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||||
|
return json.loads(r.read()), r.status
|
||||||
|
|
||||||
|
|
||||||
|
def post(path, body=None):
|
||||||
|
data = json.dumps(body or {}).encode()
|
||||||
|
req = urllib.request.Request(BASE + path, data=data,
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
return json.loads(r.read()), r.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return json.loads(e.read()), e.code
|
||||||
|
|
||||||
|
|
||||||
|
# ── Theme settings ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_settings_default_theme():
|
||||||
|
"""Default theme should be 'dark'."""
|
||||||
|
d, status = get("/api/settings")
|
||||||
|
assert status == 200
|
||||||
|
assert d.get("theme") == "dark"
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_set_theme_light():
|
||||||
|
"""Setting theme to 'light' should persist and round-trip."""
|
||||||
|
try:
|
||||||
|
d, status = post("/api/settings", {"theme": "light"})
|
||||||
|
assert status == 200
|
||||||
|
d2, _ = get("/api/settings")
|
||||||
|
assert d2.get("theme") == "light"
|
||||||
|
finally:
|
||||||
|
# Reset to dark
|
||||||
|
post("/api/settings", {"theme": "dark"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_set_theme_solarized():
|
||||||
|
"""Setting theme to 'solarized' should persist."""
|
||||||
|
try:
|
||||||
|
post("/api/settings", {"theme": "solarized"})
|
||||||
|
d, _ = get("/api/settings")
|
||||||
|
assert d.get("theme") == "solarized"
|
||||||
|
finally:
|
||||||
|
post("/api/settings", {"theme": "dark"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_set_theme_monokai():
|
||||||
|
"""Setting theme to 'monokai' should persist."""
|
||||||
|
try:
|
||||||
|
post("/api/settings", {"theme": "monokai"})
|
||||||
|
d, _ = get("/api/settings")
|
||||||
|
assert d.get("theme") == "monokai"
|
||||||
|
finally:
|
||||||
|
post("/api/settings", {"theme": "dark"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_set_theme_nord():
|
||||||
|
"""Setting theme to 'nord' should persist."""
|
||||||
|
try:
|
||||||
|
post("/api/settings", {"theme": "nord"})
|
||||||
|
d, _ = get("/api/settings")
|
||||||
|
assert d.get("theme") == "nord"
|
||||||
|
finally:
|
||||||
|
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():
|
||||||
|
"""Custom theme names should be accepted (no enum gate)."""
|
||||||
|
try:
|
||||||
|
d, status = post("/api/settings", {"theme": "my-custom-theme"})
|
||||||
|
assert status == 200
|
||||||
|
d2, _ = get("/api/settings")
|
||||||
|
assert d2.get("theme") == "my-custom-theme"
|
||||||
|
finally:
|
||||||
|
post("/api/settings", {"theme": "dark"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_does_not_break_other_settings():
|
||||||
|
"""Setting theme should not disturb other settings."""
|
||||||
|
d_before, _ = get("/api/settings")
|
||||||
|
send_key_before = d_before.get("send_key")
|
||||||
|
try:
|
||||||
|
post("/api/settings", {"theme": "nord"})
|
||||||
|
d_after, _ = get("/api/settings")
|
||||||
|
assert d_after.get("send_key") == send_key_before
|
||||||
|
assert d_after.get("theme") == "nord"
|
||||||
|
finally:
|
||||||
|
post("/api/settings", {"theme": "dark"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_survives_round_trip():
|
||||||
|
"""Theme set via POST should appear in subsequent GET."""
|
||||||
|
try:
|
||||||
|
post("/api/settings", {"theme": "monokai"})
|
||||||
|
d, status = get("/api/settings")
|
||||||
|
assert status == 200
|
||||||
|
assert d["theme"] == "monokai"
|
||||||
|
finally:
|
||||||
|
post("/api/settings", {"theme": "dark"})
|
||||||
Reference in New Issue
Block a user