* feat(ui): opt-in chat bubble layout Closes #336. Adds a settings toggle that right-aligns user messages and left-aligns assistant replies. Off by default - the current full-width layout is friendlier to code blocks and tool output, so bubbles are strictly opt-in per the maintainer note on the issue. Wiring follows the existing token-usage / cli-sessions pattern: - api/config.py: new bubble_layout bool in _SETTINGS_DEFAULTS and _SETTINGS_BOOL_KEYS, validated + persisted like the rest. - static/style.css: .bubble-layout gated selectors using :has() to tag msg-rows by .msg-role.user / .msg-role.assistant without any JS changes to message creation. User rows get align-self: flex-end, max-width: 75%, and a row-reverse header; assistant rows flex-start. A 700px media query widens the max to 92% on narrow screens. - static/index.html: new checkbox with i18n keys next to the existing token-usage toggle. - static/panels.js: loads the setting into the checkbox, saves it back, and toggles body.bubble-layout immediately on save. - static/boot.js: applies the class on initial load so refreshed tabs honor the persisted setting without a flash. - static/i18n.js: English label + description. Test suite errors are environmental (test server fails to start on port 8788 on main as well). * i18n(es): add Spanish translations for bubble_layout setting * fix+test: boot.js bubble-layout reset on failure; add 22 tests for issue #336 * docs: v0.50.24 release — version badge and CHANGELOG --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,5 +1,16 @@
|
|||||||
# Hermes Web UI -- Changelog
|
# Hermes Web UI -- Changelog
|
||||||
|
|
||||||
|
## [v0.50.24] feat: opt-in chat bubble layout (closes #336)
|
||||||
|
|
||||||
|
- `api/config.py`: Add `bubble_layout` bool to `_SETTINGS_DEFAULTS` (default `False`) and `_SETTINGS_BOOL_KEYS` — new setting is opt-in, server-persisted, and coerced to bool on save
|
||||||
|
- `static/style.css`: 11 lines of CSS-only bubble layout — user rows `align-self:flex-end` / max-width 75%, assistant rows `flex-start`, all gated on `body.bubble-layout` class so the default full-width canvas is untouched; 700px responsive rule widens to 92%
|
||||||
|
- `static/boot.js`: Apply `body.bubble-layout` class from settings on page load; explicitly remove the class in the catch path so the feature stays off on API failure
|
||||||
|
- `static/panels.js`: Load checkbox state in `loadSettingsPanel`; write `body.bubble_layout` in `saveSettings` and immediately toggle `body.bubble-layout` class for live preview without a page reload
|
||||||
|
- `static/index.html`: Checkbox in the Appearance settings group, positioned between Show token usage and Show agent sessions
|
||||||
|
- `static/i18n.js`: English label + description keys; Spanish translations included in the same PR
|
||||||
|
- `tests/test_issue336.py`: 22 new tests covering config registration, JS class management in boot and panels, CSS selectors, HTML structure, i18n coverage for en+es, and API round-trip (default false, persist true/false, bool coercion)
|
||||||
|
- 1003 tests total (up from 981)
|
||||||
|
|
||||||
## [v0.50.23] Add OpenCode Zen and Go provider support (fixes #362)
|
## [v0.50.23] Add OpenCode Zen and Go provider support (fixes #362)
|
||||||
|
|
||||||
- `api/config.py`: Add `opencode-zen` and `opencode-go` to `_PROVIDER_DISPLAY` — providers now show human-readable names in the UI instead of raw IDs
|
- `api/config.py`: Add `opencode-zen` and `opencode-go` to `_PROVIDER_DISPLAY` — providers now show human-readable names in the UI instead of raw IDs
|
||||||
|
|||||||
@@ -1117,6 +1117,7 @@ _SETTINGS_DEFAULTS = {
|
|||||||
), # display name for the assistant
|
), # display name for the assistant
|
||||||
"sound_enabled": False, # play notification sound when assistant finishes
|
"sound_enabled": False, # play notification sound when assistant finishes
|
||||||
"notifications_enabled": False, # browser notification when tab is in background
|
"notifications_enabled": False, # browser notification when tab is in background
|
||||||
|
"bubble_layout": False, # right-aligned user / left-aligned assistant chat bubbles
|
||||||
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
|
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1146,6 +1147,7 @@ _SETTINGS_BOOL_KEYS = {
|
|||||||
"check_for_updates",
|
"check_for_updates",
|
||||||
"sound_enabled",
|
"sound_enabled",
|
||||||
"notifications_enabled",
|
"notifications_enabled",
|
||||||
|
"bubble_layout",
|
||||||
}
|
}
|
||||||
# Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr')
|
# Language codes are validated as short alphanumeric BCP-47-like tags (e.g. 'en', 'zh', 'fr')
|
||||||
_SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$")
|
_SETTINGS_LANG_RE = __import__("re").compile(r"^[a-zA-Z]{2,10}(-[a-zA-Z0-9]{2,8})?$")
|
||||||
|
|||||||
@@ -479,7 +479,7 @@ function applyBotName(){
|
|||||||
(async()=>{
|
(async()=>{
|
||||||
// Load send key preference
|
// Load send key preference
|
||||||
let _bootSettings={};
|
let _bootSettings={};
|
||||||
try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;window._soundEnabled=!!s.sound_enabled;window._notificationsEnabled=!!s.notifications_enabled;window._botName=s.bot_name||'Hermes';const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);if(s.language&&typeof setLocale==='function'){setLocale(s.language);if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();}applyBotName();}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;window._soundEnabled=false;window._notificationsEnabled=false;window._botName='Hermes';_bootSettings={check_for_updates:false};}
|
try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;window._soundEnabled=!!s.sound_enabled;window._notificationsEnabled=!!s.notifications_enabled;window._botName=s.bot_name||'Hermes';const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);document.body.classList.toggle('bubble-layout',!!s.bubble_layout);if(s.language&&typeof setLocale==='function'){setLocale(s.language);if(typeof applyLocaleToDOM==='function')applyLocaleToDOM();}applyBotName();}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;window._soundEnabled=false;window._notificationsEnabled=false;window._botName='Hermes';_bootSettings={check_for_updates:false};document.body.classList.remove('bubble-layout');}
|
||||||
// Non-blocking update check (fire-and-forget, once per tab session)
|
// Non-blocking update check (fire-and-forget, once per tab session)
|
||||||
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
|
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
|
||||||
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
|
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ const LOCALES = {
|
|||||||
settings_label_theme: 'Theme',
|
settings_label_theme: 'Theme',
|
||||||
settings_label_language: 'Language',
|
settings_label_language: 'Language',
|
||||||
settings_label_token_usage: 'Show token usage',
|
settings_label_token_usage: 'Show token usage',
|
||||||
|
settings_label_bubble_layout: 'Chat bubble layout',
|
||||||
settings_label_cli_sessions: 'Show agent sessions',
|
settings_label_cli_sessions: 'Show agent sessions',
|
||||||
settings_label_sync_insights: 'Sync to insights',
|
settings_label_sync_insights: 'Sync to insights',
|
||||||
settings_label_check_updates: 'Check for updates',
|
settings_label_check_updates: 'Check for updates',
|
||||||
@@ -183,6 +184,7 @@ const LOCALES = {
|
|||||||
settings_label_notifications: 'Browser notifications',
|
settings_label_notifications: 'Browser notifications',
|
||||||
settings_desc_notifications: 'Show a system notification when a response completes while the tab is in the background.',
|
settings_desc_notifications: 'Show a system notification when a response completes while the tab is in the background.',
|
||||||
settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.',
|
settings_desc_token_usage: 'Displays input/output token count below each assistant reply. Also toggled with /usage.',
|
||||||
|
settings_desc_bubble_layout: 'Right-align user messages and left-align assistant replies. Off by default to keep code blocks and tool output full-width.',
|
||||||
settings_desc_cli_sessions: 'Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.',
|
settings_desc_cli_sessions: 'Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.',
|
||||||
settings_desc_sync_insights: 'Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.',
|
settings_desc_sync_insights: 'Mirrors WebUI token usage to state.db so hermes /insights includes browser session data. Off by default.',
|
||||||
settings_desc_check_updates: 'Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.',
|
settings_desc_check_updates: 'Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.',
|
||||||
@@ -396,6 +398,7 @@ const LOCALES = {
|
|||||||
settings_label_theme: 'Tema',
|
settings_label_theme: 'Tema',
|
||||||
settings_label_language: 'Idioma',
|
settings_label_language: 'Idioma',
|
||||||
settings_label_token_usage: 'Mostrar uso de tokens',
|
settings_label_token_usage: 'Mostrar uso de tokens',
|
||||||
|
settings_label_bubble_layout: 'Disposición en burbujas',
|
||||||
settings_label_cli_sessions: 'Mostrar sesiones de CLI',
|
settings_label_cli_sessions: 'Mostrar sesiones de CLI',
|
||||||
settings_label_sync_insights: 'Sincronizar con insights',
|
settings_label_sync_insights: 'Sincronizar con insights',
|
||||||
settings_label_check_updates: 'Buscar actualizaciones',
|
settings_label_check_updates: 'Buscar actualizaciones',
|
||||||
@@ -448,6 +451,7 @@ const LOCALES = {
|
|||||||
settings_label_notifications: 'Notificaciones del navegador',
|
settings_label_notifications: 'Notificaciones del navegador',
|
||||||
settings_desc_notifications: 'Muestra una notificación del sistema cuando una respuesta termina mientras la pestaña está en segundo plano.',
|
settings_desc_notifications: 'Muestra una notificación del sistema cuando una respuesta termina mientras la pestaña está en segundo plano.',
|
||||||
settings_desc_token_usage: 'Muestra el conteo de tokens de entrada/salida debajo de cada respuesta del asistente. También se puede alternar con /usage.',
|
settings_desc_token_usage: 'Muestra el conteo de tokens de entrada/salida debajo de cada respuesta del asistente. También se puede alternar con /usage.',
|
||||||
|
settings_desc_bubble_layout: 'Alinea los mensajes del usuario a la derecha y las respuestas del asistente a la izquierda. Desactivado por defecto para mantener los bloques de código y la salida de herramientas a ancho completo.',
|
||||||
settings_desc_cli_sessions: 'Fusiona las sesiones del CLI de Hermes (state.db) en la lista de sesiones. Haz clic en una sesión de CLI para importarla y continuar la conversación.',
|
settings_desc_cli_sessions: 'Fusiona las sesiones del CLI de Hermes (state.db) en la lista de sesiones. Haz clic en una sesión de CLI para importarla y continuar la conversación.',
|
||||||
settings_desc_sync_insights: 'Refleja el uso de tokens de la WebUI en state.db para que hermes /insights incluya datos de sesiones del navegador. Desactivado por defecto.',
|
settings_desc_sync_insights: 'Refleja el uso de tokens de la WebUI en state.db para que hermes /insights incluya datos de sesiones del navegador. Desactivado por defecto.',
|
||||||
settings_desc_check_updates: 'Muestra un banner cuando haya versiones más nuevas de la WebUI o del Agent. Ejecuta periódicamente un git fetch en segundo plano.',
|
settings_desc_check_updates: 'Muestra un banner cuando haya versiones más nuevas de la WebUI o del Agent. Ejecuta periódicamente un git fetch en segundo plano.',
|
||||||
|
|||||||
@@ -494,6 +494,13 @@
|
|||||||
</label>
|
</label>
|
||||||
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_token_usage">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
|
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_token_usage">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="settingsBubbleLayout" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||||
|
<span data-i18n="settings_label_bubble_layout">Chat bubble layout</span>
|
||||||
|
</label>
|
||||||
|
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_bubble_layout">Right-align user messages and left-align assistant replies. Off by default to keep code blocks and tool output full-width.</div>
|
||||||
|
</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="settingsShowCliSessions" style="width:15px;height:15px;accent-color:var(--accent)">
|
<input type="checkbox" id="settingsShowCliSessions" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||||
@@ -528,7 +535,7 @@
|
|||||||
<div class="settings-section-title">System</div>
|
<div class="settings-section-title">System</div>
|
||||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="settings-version-badge">v0.50.23</span>
|
<span class="settings-version-badge">v0.50.24</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||||
|
|||||||
@@ -1225,6 +1225,8 @@ async function loadSettingsPanel(){
|
|||||||
if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||||
const notifCb=$('settingsNotificationsEnabled');
|
const notifCb=$('settingsNotificationsEnabled');
|
||||||
if(notifCb){notifCb.checked=!!settings.notifications_enabled;notifCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
if(notifCb){notifCb.checked=!!settings.notifications_enabled;notifCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||||
|
const bubbleCb=$('settingsBubbleLayout');
|
||||||
|
if(bubbleCb){bubbleCb.checked=!!settings.bubble_layout;bubbleCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||||
// Bot name
|
// Bot name
|
||||||
const botNameField=$('settingsBotName');
|
const botNameField=$('settingsBotName');
|
||||||
if(botNameField){botNameField.value=settings.bot_name||'Hermes';botNameField.addEventListener('input',_markSettingsDirty,{once:false});}
|
if(botNameField){botNameField.value=settings.bot_name||'Hermes';botNameField.addEventListener('input',_markSettingsDirty,{once:false});}
|
||||||
@@ -1267,6 +1269,8 @@ async function saveSettings(andClose){
|
|||||||
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
|
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
|
||||||
body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked;
|
body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked;
|
||||||
body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked;
|
body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked;
|
||||||
|
body.bubble_layout=!!($('settingsBubbleLayout')||{}).checked;
|
||||||
|
document.body.classList.toggle('bubble-layout', body.bubble_layout);
|
||||||
const botName=(($('settingsBotName')||{}).value||'').trim();
|
const botName=(($('settingsBotName')||{}).value||'').trim();
|
||||||
body.bot_name=botName||'Hermes';
|
body.bot_name=botName||'Hermes';
|
||||||
// Password: only act if the field has content; blank = leave auth unchanged
|
// Password: only act if the field has content; blank = leave auth unchanged
|
||||||
|
|||||||
@@ -350,6 +350,17 @@
|
|||||||
@media(min-width:1800px){.messages-inner{max-width:1200px;}}
|
@media(min-width:1800px){.messages-inner{max-width:1200px;}}
|
||||||
.msg-row{padding:10px 0;}
|
.msg-row{padding:10px 0;}
|
||||||
.msg-row+.msg-row{border-top:none;}
|
.msg-row+.msg-row{border-top:none;}
|
||||||
|
/* Bubble layout (issue #336): opt-in chat-bubble look with user messages right-aligned
|
||||||
|
and assistant messages left-aligned. Uses :has() to tag rows by role without JS
|
||||||
|
changes. Full-width by default -- enabled via body.bubble-layout from settings. */
|
||||||
|
body.bubble-layout .msg-row:has(.msg-role.user){align-self:flex-end;max-width:75%;}
|
||||||
|
body.bubble-layout .msg-row:has(.msg-role.user) .msg-body{padding-left:0;padding-right:30px;max-width:none;}
|
||||||
|
body.bubble-layout .msg-row:has(.msg-role.user) .msg-role{flex-direction:row-reverse;}
|
||||||
|
body.bubble-layout .msg-row:has(.msg-role.assistant){align-self:flex-start;max-width:75%;}
|
||||||
|
@media(max-width:700px){
|
||||||
|
body.bubble-layout .msg-row:has(.msg-role.user),
|
||||||
|
body.bubble-layout .msg-row:has(.msg-role.assistant){max-width:92%;}
|
||||||
|
}
|
||||||
.msg-role{font-size:12px;font-weight:500;letter-spacing:.01em;margin-bottom:8px;display:flex;align-items:center;gap:8px;}
|
.msg-role{font-size:12px;font-weight:500;letter-spacing:.01em;margin-bottom:8px;display:flex;align-items:center;gap:8px;}
|
||||||
.msg-role.user{color:rgba(124,185,255,0.65);}
|
.msg-role.user{color:rgba(124,185,255,0.65);}
|
||||||
.msg-role.assistant{color:rgba(201,168,76,0.6);}
|
.msg-role.assistant{color:rgba(201,168,76,0.6);}
|
||||||
|
|||||||
322
tests/test_issue336.py
Normal file
322
tests/test_issue336.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"""
|
||||||
|
Tests for issue #336 — opt-in chat bubble layout (PR #398).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- api/config.py: bubble_layout present in _SETTINGS_DEFAULTS with default False
|
||||||
|
- api/config.py: bubble_layout present in _SETTINGS_BOOL_KEYS
|
||||||
|
- api/config.py: bubble_layout not in password-filtered keys (safe to expose)
|
||||||
|
- static/boot.js: boot path applies bubble-layout class from settings
|
||||||
|
- static/boot.js: catch path removes bubble-layout class on API failure
|
||||||
|
- static/panels.js: loadSettingsPanel reads bubble_layout checkbox
|
||||||
|
- static/panels.js: saveSettings writes bubble_layout and toggles body class
|
||||||
|
- static/style.css: body.bubble-layout CSS selectors present
|
||||||
|
- static/style.css: responsive max-width rule for bubble layout
|
||||||
|
- static/index.html: settingsBubbleLayout checkbox element present
|
||||||
|
- static/index.html: i18n keys wired on label and description
|
||||||
|
- static/i18n.js: English label and description keys present
|
||||||
|
- static/i18n.js: Spanish label and description keys present
|
||||||
|
- Integration: bubble_layout default is False in GET /api/settings
|
||||||
|
- Integration: bubble_layout persists via POST /api/settings
|
||||||
|
- Integration: non-bool value is coerced to bool on POST
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||||
|
CONFIG_PY = (REPO_ROOT / "api" / "config.py").read_text()
|
||||||
|
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text()
|
||||||
|
PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text()
|
||||||
|
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text()
|
||||||
|
INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text()
|
||||||
|
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text()
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ── config.py static checks ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBubbleLayoutConfig(unittest.TestCase):
|
||||||
|
"""Verify bubble_layout is correctly registered in config.py."""
|
||||||
|
|
||||||
|
def test_bubble_layout_in_settings_defaults(self):
|
||||||
|
"""bubble_layout must appear in _SETTINGS_DEFAULTS."""
|
||||||
|
self.assertIn(
|
||||||
|
'"bubble_layout"',
|
||||||
|
CONFIG_PY,
|
||||||
|
"bubble_layout key missing from _SETTINGS_DEFAULTS in api/config.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bubble_layout_default_is_false(self):
|
||||||
|
"""bubble_layout default value must be False (opt-in, off by default)."""
|
||||||
|
# Match "bubble_layout": False with optional spacing
|
||||||
|
self.assertRegex(
|
||||||
|
CONFIG_PY,
|
||||||
|
r'"bubble_layout"\s*:\s*False',
|
||||||
|
"bubble_layout default must be False in _SETTINGS_DEFAULTS",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bubble_layout_in_bool_keys(self):
|
||||||
|
"""bubble_layout must be in _SETTINGS_BOOL_KEYS for coercion."""
|
||||||
|
# Find the _SETTINGS_BOOL_KEYS block and verify membership
|
||||||
|
bool_keys_match = re.search(
|
||||||
|
r"_SETTINGS_BOOL_KEYS\s*=\s*\{([^}]+)\}", CONFIG_PY, re.DOTALL
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(
|
||||||
|
bool_keys_match, "_SETTINGS_BOOL_KEYS block not found in config.py"
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'"bubble_layout"',
|
||||||
|
bool_keys_match.group(1),
|
||||||
|
"bubble_layout missing from _SETTINGS_BOOL_KEYS",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── boot.js static checks ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBubbleLayoutBootJS(unittest.TestCase):
|
||||||
|
"""Verify bubble-layout class management in boot.js."""
|
||||||
|
|
||||||
|
def test_boot_applies_bubble_layout_class(self):
|
||||||
|
"""boot.js success path must toggle body.bubble-layout from settings."""
|
||||||
|
self.assertIn(
|
||||||
|
"classList.toggle('bubble-layout',!!s.bubble_layout)",
|
||||||
|
BOOT_JS,
|
||||||
|
"boot.js must call classList.toggle('bubble-layout', ...) on settings load",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_boot_catch_removes_bubble_layout_class(self):
|
||||||
|
"""boot.js catch path must remove bubble-layout (default off on API failure)."""
|
||||||
|
self.assertIn(
|
||||||
|
"classList.remove('bubble-layout')",
|
||||||
|
BOOT_JS,
|
||||||
|
"boot.js catch block must call classList.remove('bubble-layout') on API failure",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── panels.js static checks ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBubbleLayoutPanelsJS(unittest.TestCase):
|
||||||
|
"""Verify settings panel wires the bubble_layout checkbox."""
|
||||||
|
|
||||||
|
def test_load_settings_reads_bubble_layout_checkbox(self):
|
||||||
|
"""loadSettingsPanel must read the settingsBubbleLayout checkbox state."""
|
||||||
|
self.assertIn(
|
||||||
|
"settingsBubbleLayout",
|
||||||
|
PANELS_JS,
|
||||||
|
"panels.js must reference settingsBubbleLayout checkbox",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_save_settings_writes_bubble_layout(self):
|
||||||
|
"""saveSettings must write body.bubble_layout from the checkbox."""
|
||||||
|
self.assertIn(
|
||||||
|
"body.bubble_layout",
|
||||||
|
PANELS_JS,
|
||||||
|
"saveSettings must set body.bubble_layout from checkbox",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_save_settings_toggles_body_class(self):
|
||||||
|
"""saveSettings must apply body class toggle for live preview."""
|
||||||
|
self.assertIn(
|
||||||
|
"classList.toggle('bubble-layout', body.bubble_layout)",
|
||||||
|
PANELS_JS,
|
||||||
|
"saveSettings must toggle 'bubble-layout' on document.body for live preview",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── style.css static checks ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBubbleLayoutCSS(unittest.TestCase):
|
||||||
|
"""Verify CSS selectors for bubble layout are present and gated on body class."""
|
||||||
|
|
||||||
|
def test_user_row_right_align_selector_present(self):
|
||||||
|
"""CSS must right-align user message rows when bubble-layout is active."""
|
||||||
|
self.assertIn(
|
||||||
|
"body.bubble-layout .msg-row:has(.msg-role.user)",
|
||||||
|
STYLE_CSS,
|
||||||
|
"CSS selector for user bubble alignment missing from style.css",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_assistant_row_left_align_selector_present(self):
|
||||||
|
"""CSS must left-align assistant message rows when bubble-layout is active."""
|
||||||
|
self.assertIn(
|
||||||
|
"body.bubble-layout .msg-row:has(.msg-role.assistant)",
|
||||||
|
STYLE_CSS,
|
||||||
|
"CSS selector for assistant bubble alignment missing from style.css",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bubble_layout_responsive_rule_present(self):
|
||||||
|
"""A responsive max-width rule for narrow screens must be present."""
|
||||||
|
# Both selectors must appear inside a @media block
|
||||||
|
self.assertRegex(
|
||||||
|
STYLE_CSS,
|
||||||
|
r"@media\([^)]*700px[^)]*\)[^{]*\{[^}]*bubble-layout",
|
||||||
|
"Responsive bubble-layout rule (700px breakpoint) missing from style.css",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── index.html static checks ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBubbleLayoutHTML(unittest.TestCase):
|
||||||
|
"""Verify the settings checkbox is present and correctly wired in index.html."""
|
||||||
|
|
||||||
|
def test_settings_checkbox_present(self):
|
||||||
|
"""The settingsBubbleLayout checkbox must exist in index.html."""
|
||||||
|
self.assertIn(
|
||||||
|
'id="settingsBubbleLayout"',
|
||||||
|
INDEX_HTML,
|
||||||
|
"settingsBubbleLayout checkbox missing from index.html",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_settings_label_i18n_key_wired(self):
|
||||||
|
"""Label span must carry the settings_label_bubble_layout i18n key."""
|
||||||
|
self.assertIn(
|
||||||
|
'data-i18n="settings_label_bubble_layout"',
|
||||||
|
INDEX_HTML,
|
||||||
|
"settings_label_bubble_layout i18n key not wired on label span",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_settings_desc_i18n_key_wired(self):
|
||||||
|
"""Description div must carry the settings_desc_bubble_layout i18n key."""
|
||||||
|
self.assertIn(
|
||||||
|
'data-i18n="settings_desc_bubble_layout"',
|
||||||
|
INDEX_HTML,
|
||||||
|
"settings_desc_bubble_layout i18n key not wired on description div",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── i18n.js static checks ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBubbleLayoutI18N(unittest.TestCase):
|
||||||
|
"""Verify English and Spanish locale keys are present in i18n.js."""
|
||||||
|
|
||||||
|
def _extract_locale_block(self, lang_start_marker, lang_end_marker):
|
||||||
|
"""Extract the content between two locale markers."""
|
||||||
|
start = I18N_JS.find(lang_start_marker)
|
||||||
|
end = I18N_JS.find(lang_end_marker, start)
|
||||||
|
self.assertGreater(start, -1, f"Start marker '{lang_start_marker}' not found")
|
||||||
|
self.assertGreater(end, start, f"End marker '{lang_end_marker}' not found after start")
|
||||||
|
return I18N_JS[start:end]
|
||||||
|
|
||||||
|
def test_english_label_key_present(self):
|
||||||
|
"""English locale must have settings_label_bubble_layout."""
|
||||||
|
en_block = self._extract_locale_block("\n en: {", "\n es: {")
|
||||||
|
self.assertIn(
|
||||||
|
"settings_label_bubble_layout",
|
||||||
|
en_block,
|
||||||
|
"settings_label_bubble_layout missing from English locale",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_english_desc_key_present(self):
|
||||||
|
"""English locale must have settings_desc_bubble_layout."""
|
||||||
|
en_block = self._extract_locale_block("\n en: {", "\n es: {")
|
||||||
|
self.assertIn(
|
||||||
|
"settings_desc_bubble_layout",
|
||||||
|
en_block,
|
||||||
|
"settings_desc_bubble_layout missing from English locale",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_spanish_label_key_present(self):
|
||||||
|
"""Spanish locale must have settings_label_bubble_layout."""
|
||||||
|
es_block = self._extract_locale_block("\n es: {", "\n de: {")
|
||||||
|
self.assertIn(
|
||||||
|
"settings_label_bubble_layout",
|
||||||
|
es_block,
|
||||||
|
"settings_label_bubble_layout missing from Spanish locale",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_spanish_desc_key_present(self):
|
||||||
|
"""Spanish locale must have settings_desc_bubble_layout."""
|
||||||
|
es_block = self._extract_locale_block("\n es: {", "\n de: {")
|
||||||
|
self.assertIn(
|
||||||
|
"settings_desc_bubble_layout",
|
||||||
|
es_block,
|
||||||
|
"settings_desc_bubble_layout missing from Spanish locale",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Integration tests (require live server on port 8788) ─────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBubbleLayoutSettingsAPI(unittest.TestCase):
|
||||||
|
"""Integration tests: bubble_layout via GET/POST /api/settings."""
|
||||||
|
|
||||||
|
def test_bubble_layout_default_is_false(self):
|
||||||
|
"""GET /api/settings must return bubble_layout: false by default."""
|
||||||
|
try:
|
||||||
|
d, status = _get("/api/settings")
|
||||||
|
except OSError:
|
||||||
|
self.skipTest("Server not running on port 8788")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertIn(
|
||||||
|
"bubble_layout",
|
||||||
|
d,
|
||||||
|
"bubble_layout missing from GET /api/settings response",
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
d["bubble_layout"],
|
||||||
|
"bubble_layout default must be False (opt-in feature)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bubble_layout_persists_true(self):
|
||||||
|
"""POST /api/settings with bubble_layout:true must persist and round-trip."""
|
||||||
|
try:
|
||||||
|
_, status = _post("/api/settings", {"bubble_layout": True})
|
||||||
|
except OSError:
|
||||||
|
self.skipTest("Server not running on port 8788")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
d, _ = _get("/api/settings")
|
||||||
|
self.assertTrue(d["bubble_layout"], "bubble_layout=True must persist after POST")
|
||||||
|
# Restore
|
||||||
|
_post("/api/settings", {"bubble_layout": False})
|
||||||
|
|
||||||
|
def test_bubble_layout_persists_false(self):
|
||||||
|
"""POST /api/settings with bubble_layout:false must persist and round-trip."""
|
||||||
|
try:
|
||||||
|
_post("/api/settings", {"bubble_layout": True})
|
||||||
|
_post("/api/settings", {"bubble_layout": False})
|
||||||
|
except OSError:
|
||||||
|
self.skipTest("Server not running on port 8788")
|
||||||
|
d, _ = _get("/api/settings")
|
||||||
|
self.assertFalse(d["bubble_layout"], "bubble_layout=False must persist after POST")
|
||||||
|
|
||||||
|
def test_bubble_layout_truthy_string_coerced_to_bool(self):
|
||||||
|
"""Non-bool truthy value must be coerced to bool by _SETTINGS_BOOL_KEYS logic."""
|
||||||
|
try:
|
||||||
|
_post("/api/settings", {"bubble_layout": "1"})
|
||||||
|
except OSError:
|
||||||
|
self.skipTest("Server not running on port 8788")
|
||||||
|
d, _ = _get("/api/settings")
|
||||||
|
self.assertIsInstance(
|
||||||
|
d["bubble_layout"],
|
||||||
|
bool,
|
||||||
|
"bubble_layout must be a bool in API response (bool coercion via _SETTINGS_BOOL_KEYS)",
|
||||||
|
)
|
||||||
|
# Restore
|
||||||
|
_post("/api/settings", {"bubble_layout": False})
|
||||||
Reference in New Issue
Block a user