feat: Sprint 26 — pluggable UI themes (dark, light, solarized, monokai, nord)
Five built-in themes with instant switching, persistent preference, and zero-flicker loading. Custom themes are pure CSS additions. Theme system: - CSS variable overrides via :root[data-theme="name"] blocks - Flicker prevention: inline <script> reads localStorage before stylesheet parses, preventing dark-flash on light-mode users - Server-side persistence via settings.json (theme field) - Boot.js syncs server preference to DOM + localStorage Built-in themes: - Dark (default): deep navy/indigo, muted blue accents - Light: clean white/gray, high contrast, scrollbar overrides - Solarized Dark: teal background, warm accents - Monokai: warm dark, green/pink accents - Nord: arctic blue-gray, calm and minimal UI integration: - Settings panel: theme dropdown with instant live preview - /theme slash command: /theme dark|light|solarized|monokai|nord - No enum constraint on theme setting — custom themes just work Documentation: - THEMES.md: how to switch themes, create custom themes, contribute 8 new tests. All 408 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
116
THEMES.md
Normal file
116
THEMES.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# 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** | Clean white/gray with dark text. High contrast for bright environments. |
|
||||||
|
| **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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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/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','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">
|
||||||
@@ -329,6 +330,16 @@
|
|||||||
<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="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)">
|
||||||
|
|||||||
@@ -958,6 +958,9 @@ async function loadSettingsPanel(){
|
|||||||
// 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';
|
||||||
|
// Theme preference
|
||||||
|
const themeSel=$('settingsTheme');
|
||||||
|
if(themeSel) themeSel.value=settings.theme||'dark';
|
||||||
const showUsageCb=$('settingsShowTokenUsage');
|
const showUsageCb=$('settingsShowTokenUsage');
|
||||||
if(showUsageCb) showUsageCb.checked=!!settings.show_token_usage;
|
if(showUsageCb) showUsageCb.checked=!!settings.show_token_usage;
|
||||||
const showCliCb=$('settingsShowCliSessions');
|
const showCliCb=$('settingsShowCliSessions');
|
||||||
@@ -992,6 +995,7 @@ async function saveSettings(){
|
|||||||
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.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;
|
||||||
|
|||||||
@@ -4,6 +4,29 @@
|
|||||||
--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 ── */
|
||||||
|
:root[data-theme="light"]{
|
||||||
|
--bg:#f5f5f7;--sidebar:#e8e8ed;--border:rgba(0,0,0,0.10);--border2:rgba(0,0,0,0.16);
|
||||||
|
--text:#1c1c1e;--muted:#6e6e80;--accent:#c0392b;--blue:#0a6dc2;--gold:#a07a20;--code-bg:#f0f0f5;
|
||||||
|
}
|
||||||
|
: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(10,109,194,.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;}
|
||||||
|
|||||||
109
tests/test_sprint26.py
Normal file
109
tests/test_sprint26.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
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_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