diff --git a/CHANGELOG.md b/CHANGELOG.md index 8baac29..04aafbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Hermes Web UI -- Changelog +## [v0.50.143] — 2026-04-22 + +### Added +- **Font size setting in Appearance** — users can now choose between Small (12px), + Default (14px), and Large (16px) text size from the Appearance settings tab. The choice + is stored in `localStorage` and applied via a `data-font-size` attribute on `` at + boot time (no FOUC). Follows the same three-button visual pattern as the Theme picker. + Localized for all 6 supported locales. Closes #833. (#834) + ## [v0.50.142] — 2026-04-22 ### Fixed @@ -24,6 +33,7 @@ newly created ones). Added `autocomplete="off"` to the search input and an explicit value-clear at boot before the first render. Closes #822. (#830) + ## [v0.50.140] — 2026-04-22 ### Fixed diff --git a/static/boot.js b/static/boot.js index 8563701..b729344 100644 --- a/static/boot.js +++ b/static/boot.js @@ -721,6 +721,31 @@ function _syncSkinPicker(active){ }); } +function _applyFontSize(size){ + if(size&&size!=='default'){ + document.documentElement.dataset.fontSize=size; + } else { + delete document.documentElement.dataset.fontSize; + } +} + +function _pickFontSize(size){ + localStorage.setItem('hermes-font-size',size); + _applyFontSize(size); + _syncFontSizePicker(size); + if(typeof _markSettingsDirty==='function') _markSettingsDirty(); + const hidden=$('settingsFontSize'); + if(hidden) hidden.value=size; +} + +function _syncFontSizePicker(active){ + document.querySelectorAll('#fontSizePickerGrid .font-size-pick-btn').forEach(btn=>{ + const sel=btn.dataset.fontSizeVal===(active||'default'); + btn.style.borderColor=sel?'var(--accent)':'var(--border2)'; + btn.style.boxShadow=sel?'0 0 0 1px var(--accent-bg-strong)':'none'; + }); +} + function _buildSkinPicker(activeSkin){ const grid=$('skinPickerGrid'); if(!grid) return; diff --git a/static/i18n.js b/static/i18n.js index bdeec05..fd377df 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -192,6 +192,10 @@ const LOCALES = { settings_label_send_key: 'Send Key', settings_label_theme: 'Theme', settings_label_skin: 'Skin', + settings_label_font_size: 'Font size', + font_size_small: 'Small', + font_size_default: 'Default', + font_size_large: 'Large', settings_label_language: 'Language', settings_label_token_usage: 'Show token usage', settings_label_sidebar_density: 'Sidebar density', @@ -575,6 +579,10 @@ const LOCALES = { model_search_placeholder: 'Поиск моделей…', reference_only_label: 'Только справка', settings_label_skin: 'Скин', + settings_label_font_size: 'Размер шрифта', + font_size_small: 'Маленький', + font_size_default: 'Стандарт', + font_size_large: 'Большой', workspace_empty_dir: 'Это рабочее пространство пусто.', workspace_empty_no_path: 'Рабочее пространство не выбрано. Настройте его в Настройки → Рабочее пространство.', available_personalities: 'Доступные личности:', @@ -1084,6 +1092,10 @@ const LOCALES = { settings_label_send_key: 'Tecla de envío', settings_label_theme: 'Tema', settings_label_skin: 'Piel', + settings_label_font_size: 'Tamaño de fuente', + font_size_small: 'Pequeño', + font_size_default: 'Por defecto', + font_size_large: 'Grande', settings_label_language: 'Idioma', settings_label_token_usage: 'Mostrar uso de tokens', settings_label_sidebar_density: 'Densidad de la barra lateral', @@ -1516,6 +1528,10 @@ const LOCALES = { settings_label_send_key: 'Sende-Taste', settings_label_theme: 'Theme', settings_label_skin: 'Skin', + settings_label_font_size: 'Font size', + font_size_small: 'Small', + font_size_default: 'Default', + font_size_large: 'Large', settings_label_language: 'Sprache', settings_label_token_usage: 'Token-Verbrauch anzeigen', settings_label_sidebar_density: 'Seitenleistendichte', @@ -1749,6 +1765,10 @@ const LOCALES = { settings_label_send_key: '\u53d1\u9001\u5feb\u6377\u952e', settings_label_theme: '\u4e3b\u9898', settings_label_skin: '\u76ae\u80a4', + settings_label_font_size: '\u5b57\u4f53\u5927\u5c0f', + font_size_small: '\u5c0f', + font_size_default: '\u9ed8\u8ba4', + font_size_large: '\u5927', settings_label_language: '\u8bed\u8a00', settings_label_token_usage: '\u663e\u793a token \u7528\u91cf', settings_label_sidebar_density: '侧边栏密度', @@ -2165,6 +2185,10 @@ const LOCALES = { settings_label_send_key: '\u767c\u9001\u5feb\u6377\u9375', settings_label_theme: '\u4e3b\u984c', settings_label_skin: '\u76ae\u819a', + settings_label_font_size: '\u5b57\u9ad4\u5927\u5c0f', + font_size_small: '\u5c0f', + font_size_default: '\u9810\u8a2d', + font_size_large: '\u5927', settings_label_language: '\u8a9d\u8a00', settings_label_token_usage: '\u986f\u793a token \u7528\u91cf', settings_label_sidebar_density: '側邊欄密度', diff --git a/static/index.html b/static/index.html index 968ed7b..ab46fed 100644 --- a/static/index.html +++ b/static/index.html @@ -10,6 +10,7 @@ + @@ -512,6 +513,30 @@
+ +
+ +
+ + + +
+
diff --git a/static/panels.js b/static/panels.js index a422dad..b032a50 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1105,6 +1105,7 @@ document.addEventListener('drop',e=>{e.preventDefault();dragCounter=0;wrap.class let _settingsDirty = false; let _settingsThemeOnOpen = null; // track theme at open time for discard revert let _settingsSkinOnOpen = null; // track skin at open time for discard revert +let _settingsFontSizeOnOpen = null; // track font size at open time for discard revert let _settingsHermesDefaultModelOnOpen = ''; let _settingsSection = 'conversation'; @@ -1152,6 +1153,7 @@ function toggleSettings(){ _settingsDirty = false; _settingsThemeOnOpen = localStorage.getItem('hermes-theme') || 'dark'; _settingsSkinOnOpen = localStorage.getItem('hermes-skin') || 'default'; + _settingsFontSizeOnOpen = localStorage.getItem('hermes-font-size') || 'default'; _settingsSection = 'conversation'; overlay.style.display=''; loadSettingsPanel(); @@ -1196,6 +1198,10 @@ function _revertSettingsPreview(){ localStorage.setItem('hermes-skin', _settingsSkinOnOpen); if(typeof _applySkin==='function') _applySkin(_settingsSkinOnOpen); } + if(_settingsFontSizeOnOpen){ + localStorage.setItem('hermes-font-size', _settingsFontSizeOnOpen); + if(typeof _applyFontSize==='function') _applyFontSize(_settingsFontSizeOnOpen); + } } // Show the "Unsaved changes" bar inside the settings panel @@ -1243,6 +1249,10 @@ async function loadSettingsPanel(){ const skinSel=$('settingsSkin'); if(skinSel) skinSel.value=skinVal; if(typeof _buildSkinPicker==='function') _buildSkinPicker(skinVal); + const fontSizeVal=localStorage.getItem('hermes-font-size')||'default'; + const fontSizeSel=$('settingsFontSize'); + if(fontSizeSel) fontSizeSel.value=fontSizeVal; + if(typeof _syncFontSizePicker==='function') _syncFontSizePicker(fontSizeVal); const resolvedLanguage=(typeof resolvePreferredLocale==='function') ? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang')) : (settings.language || localStorage.getItem('hermes-lang') || 'en'); diff --git a/static/style.css b/static/style.css index c136001..35c0590 100644 --- a/static/style.css +++ b/static/style.css @@ -11,6 +11,9 @@ --error:#C62828;--success:#3D8B40;--warning:#E68A00;--info:#0288A8; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-size:14px;line-height:1.6; } + /* ── Font size modifiers ── */ + :root[data-font-size="small"]{font-size:12px;} + :root[data-font-size="large"]{font-size:16px;} /* ── Dark mode — navy-black + gold accent matching Hermes terminal ── */ :root.dark { --bg:#0D0D1A;--sidebar:#141425;--border:#2A2A45;--border2:rgba(255,255,255,0.14); diff --git a/tests/test_font_size_setting.py b/tests/test_font_size_setting.py new file mode 100644 index 0000000..abfc088 --- /dev/null +++ b/tests/test_font_size_setting.py @@ -0,0 +1,173 @@ +"""Tests for font size setting (#833) — 3-toggle Small/Default/Large in Appearance.""" +import os +import re + +_SRC = os.path.join(os.path.dirname(__file__), "..") + +def _read(name): + return open(os.path.join(_SRC, name), encoding="utf-8").read() + + +class TestFontSizeCssModifiers: + """CSS must define font-size overrides for small and large via data attribute.""" + + def test_small_font_size_rule_exists(self): + css = _read("static/style.css") + assert 'data-font-size="small"' in css, ( + "style.css must have :root[data-font-size=\"small\"] font-size rule" + ) + + def test_large_font_size_rule_exists(self): + css = _read("static/style.css") + assert 'data-font-size="large"' in css, ( + "style.css must have :root[data-font-size=\"large\"] font-size rule" + ) + + def test_small_is_smaller_than_default(self): + css = _read("static/style.css") + m_small = re.search(r':root\[data-font-size="small"\]\{font-size:(\d+)px', css) + m_large = re.search(r':root\[data-font-size="large"\]\{font-size:(\d+)px', css) + assert m_small and m_large, "Both small and large font-size rules must set px values" + assert int(m_small.group(1)) < 14, "Small font size must be < 14px (default)" + assert int(m_large.group(1)) > 14, "Large font size must be > 14px (default)" + + +class TestFontSizeBootScript: + """The boot script must apply font size from localStorage before page renders.""" + + def test_boot_script_reads_hermes_font_size(self): + html = _read("static/index.html") + assert "hermes-font-size" in html, ( + "index.html boot script must read 'hermes-font-size' from localStorage" + ) + assert "data-font-size" in html, ( + "boot script must set document.documentElement.dataset.fontSize" + ) + + def test_font_size_picker_html_present(self): + html = _read("static/index.html") + assert "fontSizePickerGrid" in html, ( + "Appearance pane must contain a fontSizePickerGrid element" + ) + assert "settingsFontSize" in html, ( + "Appearance pane must contain a hidden #settingsFontSize input" + ) + assert "font-size-pick-btn" in html, ( + "Font size picker buttons must have font-size-pick-btn class" + ) + + def test_three_font_size_values_present(self): + html = _read("static/index.html") + assert 'data-font-size-val="small"' in html, "Small button must exist" + assert 'data-font-size-val="default"' in html, "Default button must exist" + assert 'data-font-size-val="large"' in html, "Large button must exist" + + def test_font_size_picker_not_duplicated(self): + """Regression guard: the font size picker grid must appear exactly once + in index.html. Earlier versions of this PR accidentally injected the + block into both settingsPaneAppearance (correct) and + settingsPanePreferences (copy-paste duplicate), creating duplicate IDs + that break _syncFontSizePicker visual sync on one of the grids.""" + html = _read("static/index.html") + assert html.count('id="fontSizePickerGrid"') == 1, ( + "fontSizePickerGrid must appear exactly once — duplicate IDs " + "violate HTML spec and break querySelectorAll-based sync." + ) + assert html.count('id="settingsFontSize"') == 1, ( + "settingsFontSize hidden input must appear exactly once" + ) + + def test_font_size_picker_lives_in_appearance_pane(self): + """The font size picker must be under settingsPaneAppearance, + not Preferences/System/Conversation.""" + html = _read("static/index.html") + appearance_start = html.find('id="settingsPaneAppearance"') + next_pane_markers = [ + 'id="settingsPanePreferences"', + 'id="settingsPaneSystem"', + 'id="settingsPaneConversation"', + ] + next_pane_starts = [ + html.find(m, appearance_start + 1) for m in next_pane_markers + ] + after_appearance = min( + [p for p in next_pane_starts if p != -1] or [len(html)] + ) + picker_pos = html.find('id="fontSizePickerGrid"') + assert appearance_start != -1, "settingsPaneAppearance not found" + assert picker_pos != -1, "fontSizePickerGrid not found" + assert appearance_start < picker_pos < after_appearance, ( + "Font size picker must live inside settingsPaneAppearance " + "(same section as Theme and Skin)" + ) + + +class TestFontSizeJsFunctions: + """JS must expose _pickFontSize, _applyFontSize, and _syncFontSizePicker.""" + + def test_pick_font_size_function_exists(self): + boot = _read("static/boot.js") + assert "function _pickFontSize(" in boot, ( + "boot.js must define _pickFontSize()" + ) + + def test_apply_font_size_function_exists(self): + boot = _read("static/boot.js") + assert "function _applyFontSize(" in boot, ( + "boot.js must define _applyFontSize()" + ) + + def test_sync_font_size_picker_function_exists(self): + boot = _read("static/boot.js") + assert "function _syncFontSizePicker(" in boot, ( + "boot.js must define _syncFontSizePicker()" + ) + + def test_pick_font_size_persists_to_localstorage(self): + boot = _read("static/boot.js") + idx = boot.find("function _pickFontSize(") + block = boot[idx:idx+400] + assert "localStorage.setItem('hermes-font-size'" in block, ( + "_pickFontSize must persist choice to localStorage" + ) + + def test_apply_font_size_sets_data_attribute(self): + boot = _read("static/boot.js") + idx = boot.find("function _applyFontSize(") + block = boot[idx:idx+300] + assert "dataset.fontSize" in block, ( + "_applyFontSize must set document.documentElement.dataset.fontSize" + ) + + +class TestFontSizeI18nCoverage: + """All locales must include the font size i18n keys.""" + + def _get_locale_keys(self, src, locale_marker_after, stop_marker): + """Extract keys from a locale block.""" + start = src.find(locale_marker_after) + if start < 0: + return set() + end = src.find(stop_marker, start) + block = src[start:end if end > 0 else start + 20000] + return set(re.findall(r"(\w[\w_]+):", block)) + + REQUIRED_KEYS = {"settings_label_font_size", "font_size_small", "font_size_default", "font_size_large"} + + def test_all_locales_have_font_size_keys(self): + src = _read("static/i18n.js") + count = src.count("settings_label_font_size") + # 6 locales: en, ru, es, de, zh, zh-Hant + assert count >= 6, ( + f"settings_label_font_size must appear in all 6 locales, found {count}" + ) + + def test_font_size_small_key_in_all_locales(self): + src = _read("static/i18n.js") + count = src.count("font_size_small") + assert count >= 6, f"font_size_small must appear in all 6 locales, found {count}" + + def test_font_size_large_key_in_all_locales(self): + src = _read("static/i18n.js") + count = src.count("font_size_large") + assert count >= 6, f"font_size_large must appear in all 6 locales, found {count}"