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}"