feat: remove bubble_layout setting end-to-end (#777)
Removes the bubble_layout toggle from Settings, all persistence, CSS, i18n strings, and the UI docs demo. The CSS was already effectively dead. Users with a saved bubble_layout value in settings.json get a clean migration via _SETTINGS_LEGACY_DROP_KEYS. Credit: @aronprins (PR #760 / #777) Co-authored-by: aronprins <aronprins@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# Hermes Web UI -- Changelog
|
||||
|
||||
## [v0.50.115] — 2026-04-20
|
||||
|
||||
### Removed
|
||||
- **Chat bubble layout setting removed** — the opt-in `bubble_layout` toggle (issue #336) is removed end-to-end: the Settings checkbox, all related CSS (`.bubble-layout` selectors), the config.py default/bool-key entries, the boot.js/panels.js class toggles, and all locale strings across 6 languages. Stale `bubble_layout` values in existing `settings.json` files are silently dropped on load via the legacy-drop-keys migration path. (Fixes #760, credit: @aronprins)
|
||||
|
||||
## [v0.50.114] — 2026-04-20
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1339,11 +1339,10 @@ _SETTINGS_DEFAULTS = {
|
||||
), # display name for the assistant
|
||||
"sound_enabled": False, # play notification sound when assistant finishes
|
||||
"notifications_enabled": False, # browser notification when tab is in background
|
||||
"bubble_layout": False, # right-aligned user / left-aligned assistant chat bubbles
|
||||
"sidebar_density": "compact", # compact | detailed
|
||||
"password_hash": None, # PBKDF2-HMAC-SHA256 hash; None = auth disabled
|
||||
}
|
||||
_SETTINGS_LEGACY_DROP_KEYS = {"assistant_language", "default_model"}
|
||||
_SETTINGS_LEGACY_DROP_KEYS = {"assistant_language", "bubble_layout", "default_model"}
|
||||
_SETTINGS_THEME_VALUES = {"light", "dark", "system"}
|
||||
_SETTINGS_SKIN_VALUES = {
|
||||
"default",
|
||||
@@ -1450,7 +1449,6 @@ _SETTINGS_BOOL_KEYS = {
|
||||
"check_for_updates",
|
||||
"sound_enabled",
|
||||
"notifications_enabled",
|
||||
"bubble_layout",
|
||||
}
|
||||
# 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})?$")
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
<button data-theme-btn="nord">Nord</button>
|
||||
<button data-theme-btn="oled">OLED</button>
|
||||
<span style="width:1px;height:18px;background:var(--border);margin:0 4px;align-self:center;"></span>
|
||||
<button id="toggleBubble">Bubble layout: off</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -664,24 +663,7 @@ Run typecheck to confirm, then patch.</pre></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
<div class="doc-kicker">11 · Bubble layout</div>
|
||||
<h2 class="doc-h">Opt-in via <code>body.bubble-layout</code> — extra bubble padding for assistant too</h2>
|
||||
<p class="doc-note">The default layout already right-aligns user messages (the redesign adopted it globally), so this toggle mostly affects additional padding / boundary handling. Flip the <strong>Bubble layout</strong> toggle in the header to see the mode applied.</p>
|
||||
<div class="doc-card"><span class="doc-label">Conversation sample</span>
|
||||
<div class="messages doc-messages"><div class="messages-inner doc-inner">
|
||||
<div class="msg-row" data-role="user"><div class="msg-body"><p>Can you add a retry button next to the regenerate one?</p></div></div>
|
||||
<div class="msg-row assistant-turn" data-role="assistant">
|
||||
<div class="msg-role assistant"><span class="role-icon assistant">H</span><span>Hermes</span></div>
|
||||
<div class="assistant-turn-blocks"><div class="assistant-segment">
|
||||
<div class="msg-body"><p>Yes — it can share <code>.msg-action-btn</code> and live in the same <code>.msg-actions</code> container. I'll wire it up on <code>_lastError</code>.</p></div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="msg-row" data-role="user"><div class="msg-body"><p>Perfect, go for it.</p></div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- ============================================================= -->
|
||||
<section class="doc-section">
|
||||
@@ -845,13 +827,7 @@ Run typecheck to confirm, then patch.</pre></div>
|
||||
});
|
||||
});
|
||||
// Bubble-layout toggle
|
||||
const bubbleBtn = document.getElementById('toggleBubble');
|
||||
bubbleBtn.addEventListener('click', () => {
|
||||
document.body.classList.toggle('bubble-layout');
|
||||
const on = document.body.classList.contains('bubble-layout');
|
||||
bubbleBtn.textContent = 'Bubble layout: ' + (on ? 'on' : 'off');
|
||||
bubbleBtn.classList.toggle('on', on);
|
||||
});
|
||||
|
||||
// Thinking / tool-card click-to-toggle (so the demo feels live)
|
||||
document.querySelectorAll('.thinking-card-header, .tool-card-header').forEach(h => {
|
||||
h.addEventListener('click', () => h.parentElement.classList.toggle('open'));
|
||||
|
||||
@@ -771,7 +771,6 @@ function applyBotName(){
|
||||
_applyTheme(appearance.theme);
|
||||
localStorage.setItem('hermes-skin',appearance.skin);
|
||||
_applySkin(appearance.skin);
|
||||
document.body.classList.toggle('bubble-layout',!!s.bubble_layout);
|
||||
if(typeof setLocale==='function'){
|
||||
const _lang=typeof resolvePreferredLocale==='function'
|
||||
? resolvePreferredLocale(s.language, localStorage.getItem('hermes-lang'))
|
||||
@@ -789,7 +788,6 @@ function applyBotName(){
|
||||
window._sidebarDensity='compact';
|
||||
window._botName='Hermes';
|
||||
_bootSettings={check_for_updates:false};
|
||||
document.body.classList.remove('bubble-layout');
|
||||
if(typeof setLocale==='function'){
|
||||
const _lang=typeof resolvePreferredLocale==='function'
|
||||
? resolvePreferredLocale(null, localStorage.getItem('hermes-lang'))
|
||||
|
||||
@@ -194,7 +194,6 @@ const LOCALES = {
|
||||
settings_label_skin: 'Skin',
|
||||
settings_label_language: 'Language',
|
||||
settings_label_token_usage: 'Show token usage',
|
||||
settings_label_bubble_layout: 'Chat bubble layout',
|
||||
settings_label_sidebar_density: 'Sidebar density',
|
||||
settings_label_cli_sessions: 'Show agent sessions',
|
||||
settings_label_sync_insights: 'Sync to insights',
|
||||
@@ -261,7 +260,6 @@ const LOCALES = {
|
||||
settings_label_notifications: 'Browser notifications',
|
||||
settings_desc_notifications: 'Show a system notification when a response completes while the app is in the background.',
|
||||
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_sidebar_density_compact: 'Compact',
|
||||
settings_sidebar_density_detailed: 'Detailed',
|
||||
settings_desc_sidebar_density: 'Controls how much metadata the session list shows in the left sidebar.',
|
||||
@@ -627,7 +625,6 @@ const LOCALES = {
|
||||
settings_label_theme: 'Тема',
|
||||
settings_label_language: 'Язык',
|
||||
settings_label_token_usage: 'Показывать использование токенов',
|
||||
settings_label_bubble_layout: 'Раскладка пузырьков чата',
|
||||
settings_label_sidebar_density: 'Плотность боковой панели',
|
||||
settings_label_cli_sessions: 'Показывать сеансы агента',
|
||||
settings_label_sync_insights: 'Синхронизировать с Insights',
|
||||
@@ -710,7 +707,6 @@ const LOCALES = {
|
||||
settings_label_notifications: 'Уведомления браузера',
|
||||
settings_desc_notifications: 'Показывать системное уведомление, когда ответ готов, а вкладка находится в фоне.',
|
||||
settings_desc_token_usage: 'Показывает количество входных и выходных токенов под каждым ответом помощника. Также переключается через /usage.',
|
||||
settings_desc_bubble_layout: 'Выравнивает сообщения пользователя справа, а ответы помощника слева. Выключено по умолчанию, чтобы блоки кода и вывод инструментов занимали всю ширину.',
|
||||
settings_sidebar_density_compact: 'Компактно',
|
||||
settings_sidebar_density_detailed: 'Подробно',
|
||||
settings_desc_sidebar_density: 'Управляет тем, сколько метаданных показывается в списке сеансов на левой панели.',
|
||||
@@ -1088,7 +1084,6 @@ const LOCALES = {
|
||||
settings_label_skin: 'Piel',
|
||||
settings_label_language: 'Idioma',
|
||||
settings_label_token_usage: 'Mostrar uso de tokens',
|
||||
settings_label_bubble_layout: 'Disposición en burbujas',
|
||||
settings_label_sidebar_density: 'Densidad de la barra lateral',
|
||||
settings_label_cli_sessions: 'Mostrar sesiones de CLI',
|
||||
settings_label_sync_insights: 'Sincronizar con insights',
|
||||
@@ -1155,7 +1150,6 @@ const LOCALES = {
|
||||
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_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_sidebar_density_compact: 'Compacta',
|
||||
settings_sidebar_density_detailed: 'Detallada',
|
||||
settings_desc_sidebar_density: 'Controla cuántos metadatos muestra la lista de sesiones en la barra lateral izquierda.',
|
||||
@@ -1521,7 +1515,6 @@ const LOCALES = {
|
||||
settings_label_skin: 'Skin',
|
||||
settings_label_language: 'Sprache',
|
||||
settings_label_token_usage: 'Token-Verbrauch anzeigen',
|
||||
settings_label_bubble_layout: 'Chat-Bubble-Layout',
|
||||
settings_label_sidebar_density: 'Seitenleistendichte',
|
||||
settings_label_cli_sessions: 'Agent-Sitzungen anzeigen',
|
||||
settings_label_sync_insights: 'Mit Insights synchronisieren',
|
||||
@@ -1577,7 +1570,6 @@ const LOCALES = {
|
||||
settings_label_notifications: 'Browser-Benachrichtigungen',
|
||||
settings_desc_notifications: 'Zeigt eine Systembenachrichtigung an, wenn eine Antwort fertiggestellt wird, während der Tab im Hintergrund ist.',
|
||||
settings_desc_token_usage: 'Zeigt die Anzahl der Input/Output-Token unter jeder Antwort des Assistenten an. Auch umschaltbar mit /usage.',
|
||||
settings_desc_bubble_layout: 'Richtet Benutzernachrichten rechts und Assistentenantworten links aus. Standardmäßig deaktiviert, damit Codeblöcke und Tool-Ausgaben die volle Breite behalten.',
|
||||
settings_sidebar_density_compact: 'Kompakt',
|
||||
settings_sidebar_density_detailed: 'Detailliert',
|
||||
settings_desc_sidebar_density: 'Steuert, wie viele Metadaten die Sitzungsliste in der linken Seitenleiste anzeigt.',
|
||||
@@ -1755,7 +1747,6 @@ const LOCALES = {
|
||||
settings_label_skin: '\u76ae\u80a4',
|
||||
settings_label_language: '\u8bed\u8a00',
|
||||
settings_label_token_usage: '\u663e\u793a token \u7528\u91cf',
|
||||
settings_label_bubble_layout: '聊天气泡布局',
|
||||
settings_label_sidebar_density: '侧边栏密度',
|
||||
settings_label_cli_sessions: '\u663e\u793a CLI \u4f1a\u8bdd',
|
||||
settings_label_sync_insights: '\u540c\u6b65\u5230 insights',
|
||||
@@ -1825,7 +1816,6 @@ const LOCALES = {
|
||||
settings_desc_sound: '助手完成回复时播放提示音。',
|
||||
settings_desc_notifications: '当标签页在后台时,回复完成后显示系统通知。',
|
||||
settings_desc_token_usage: '在助手每次回复下方显示输入/输出 token 数量。也可以用 /usage 切换。',
|
||||
settings_desc_bubble_layout: '开启后将用户消息右对齐、助手消息左对齐。默认关闭,以保持代码块和工具输出为全宽显示。',
|
||||
settings_sidebar_density_compact: '紧凑',
|
||||
settings_sidebar_density_detailed: '详细',
|
||||
settings_desc_sidebar_density: '控制左侧会话列表展示多少元信息。',
|
||||
@@ -2172,7 +2162,6 @@ const LOCALES = {
|
||||
settings_label_skin: '\u76ae\u819a',
|
||||
settings_label_language: '\u8a9d\u8a00',
|
||||
settings_label_token_usage: '\u986f\u793a token \u7528\u91cf',
|
||||
settings_label_bubble_layout: '聊天泡泡版面',
|
||||
settings_label_sidebar_density: '側邊欄密度',
|
||||
settings_label_cli_sessions: '\u986f\u793a CLI \u6703\u8a71',
|
||||
settings_label_sync_insights: '\u540c\u6b65\u5230 insights',
|
||||
@@ -2237,7 +2226,6 @@ const LOCALES = {
|
||||
settings_desc_sound: '\u52a9\u624b\u5b8c\u6210\u56de\u7b54\u6642\u64a9\u653e\u8072\u97f3\u3002',
|
||||
settings_desc_notifications: '\u7576\u5206\u9801\u5728\u5f8c\u81ea\u6642\uff0c\u6709\u56de\u7b54\u5b8c\u6210\u6e05\u55ae\u6703\u986f\u793a\u7cfb\u7d71\u901a\u77e5\u3002',
|
||||
settings_desc_token_usage: '\u5728\u52a9\u624b\u6bcf\u6b21\u56de\u7b54\u4e0b\u65b9\u986f\u793a Input/Output token \u6578\u91cf\u3002\u4e5f\u53ef\u4ee5\u7528 /usage \u5207\u63db\u3002',
|
||||
settings_desc_bubble_layout: '開啟後將使用者訊息靠右、助理訊息靠左。預設關閉,以保持程式碼區塊與工具輸出為全寬顯示。',
|
||||
settings_sidebar_density_compact: '精簡',
|
||||
settings_sidebar_density_detailed: '詳細',
|
||||
settings_desc_sidebar_density: '控制左側對話清單要顯示多少額外資訊。',
|
||||
|
||||
@@ -554,13 +554,6 @@
|
||||
</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>
|
||||
<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">
|
||||
<label for="settingsSidebarDensity" data-i18n="settings_label_sidebar_density">Sidebar density</label>
|
||||
<select id="settingsSidebarDensity" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
|
||||
@@ -1268,8 +1268,6 @@ async function loadSettingsPanel(){
|
||||
if(soundCb){soundCb.checked=!!settings.sound_enabled;soundCb.addEventListener('change',_markSettingsDirty,{once:false});}
|
||||
const notifCb=$('settingsNotificationsEnabled');
|
||||
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});}
|
||||
const sidebarDensitySel=$('settingsSidebarDensity');
|
||||
if(sidebarDensitySel){
|
||||
sidebarDensitySel.value=settings.sidebar_density==='detailed'?'detailed':'compact';
|
||||
@@ -1309,7 +1307,6 @@ function _applySavedSettingsUi(saved, body, opts){
|
||||
window._notificationsEnabled=body.notifications_enabled;
|
||||
window._sidebarDensity=sidebarDensity==='detailed'?'detailed':'compact';
|
||||
window._botName=body.bot_name||'Hermes';
|
||||
document.body.classList.toggle('bubble-layout', !!body.bubble_layout);
|
||||
if(typeof applyBotName==='function') applyBotName();
|
||||
if(typeof setLocale==='function') setLocale(language);
|
||||
if(typeof applyLocaleToDOM==='function') applyLocaleToDOM();
|
||||
@@ -1352,9 +1349,7 @@ async function saveSettings(andClose){
|
||||
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
|
||||
body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked;
|
||||
body.notifications_enabled=!!($('settingsNotificationsEnabled')||{}).checked;
|
||||
body.bubble_layout=!!($('settingsBubbleLayout')||{}).checked;
|
||||
body.sidebar_density=sidebarDensity;
|
||||
document.body.classList.toggle('bubble-layout', body.bubble_layout);
|
||||
const botName=(($('settingsBotName')||{}).value||'').trim();
|
||||
body.bot_name=botName||'Hermes';
|
||||
// Password: only act if the field has content; blank = leave auth unchanged
|
||||
|
||||
@@ -438,17 +438,6 @@
|
||||
@media(min-width:1800px){.messages-inner{max-width:1200px;}}
|
||||
.msg-row{padding:10px 0;}
|
||||
.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.user{color:var(--accent);}
|
||||
.msg-role.assistant{color:var(--accent-text);opacity:.6;}
|
||||
@@ -1619,13 +1608,6 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
.msg-row[data-role="user"][data-editing="1"] .msg-edit-area { background: var(--user-bubble-bg); border-color: var(--user-bubble-border); color: var(--user-bubble-text); box-shadow: 0 0 0 3px var(--focus-ring); }
|
||||
.msg-row[data-role="user"][data-editing="1"] .msg-edit-area::placeholder { color: var(--user-bubble-placeholder); }
|
||||
|
||||
/* Bubble-layout mode: user-card stays intact, just drop the rail margin.
|
||||
(:has() form matches the existing bubble-layout rule's specificity so this
|
||||
wins by source order rather than relying on !important.) */
|
||||
body.bubble-layout .msg-row:has(.msg-role.user) .msg-body { margin-left: 0; padding: 10px 14px; max-width: none; }
|
||||
body.bubble-layout .msg-row:has(.msg-role.user) .msg-files { margin-left: 0; padding-left: 0; }
|
||||
body.bubble-layout .msg-row + .msg-row[data-role="user"] { border-top: none; padding-top: 10px; margin-top: 0; }
|
||||
|
||||
/* Turn boundary: right alignment already separates user turns — keep only vertical spacing */
|
||||
.msg-row + .msg-row[data-role="user"] {
|
||||
border-top: none;
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
|
||||
from tests._pytest_port import BASE
|
||||
|
||||
|
||||
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 test server port) ─────────────────
|
||||
|
||||
|
||||
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 test server port")
|
||||
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 test server port")
|
||||
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 test server port")
|
||||
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 test server port")
|
||||
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