feat(appearance): font size setting with Small/Default/Large toggle (closes #833)
* feat(appearance): font size setting with Small/Default/Large toggle Add a font size preference to the Appearance settings pane. Three options (12px/14px/16px) follow the same three-button visual pattern as the Theme picker. Closes #833. - static/style.css: :root[data-font-size=small|large] CSS overrides - static/index.html: boot script applies from localStorage before CSS renders (no FOUC); fontSizePickerGrid HTML in Appearance pane - static/boot.js: _applyFontSize(), _pickFontSize(), _syncFontSizePicker() - static/panels.js: loadSettingsPanel syncs picker on open; _revertSettingsPreview restores on discard - static/i18n.js: settings_label_font_size + font_size_{small,default,large} keys in all 6 locales (en, ru, es, de, zh, zh-Hant) - tests/test_font_size_setting.py: 14 new tests * fix(ui): remove duplicate font-size picker + correct CHANGELOG issue ref Two small fixes on the font size feature: 1. Duplicate HTML IDs — the picker block was injected into BOTH settingsPaneAppearance (correct, next to Theme/Skin) AND settingsPanePreferences (accidental copy-paste). Duplicate IDs #fontSizePickerGrid and #settingsFontSize violate HTML spec and break the _syncFontSizePicker visual sync which reads via document.querySelectorAll('#fontSizePickerGrid .font-size-pick-btn') — only the first grid would update its highlight, leaving the second stale. $('settingsFontSize') via getElementById also always returns the first match, so the second hidden input never reflected the user's choice. Removed the Preferences-pane copy. The Appearance-pane copy is the one the PR description describes and is the correct home for it (next to Theme and Skin). 2. CHANGELOG trailer said `Closes #830.` but #830 is the session-search autocomplete PR — this feature closes #833. Fixed. Added two regression tests: - test_font_size_picker_not_duplicated: asserts each ID appears exactly once in index.html. - test_font_size_picker_lives_in_appearance_pane: asserts the picker sits inside settingsPaneAppearance and not any other pane. Full suite: 1754 passed, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,5 +1,14 @@
|
|||||||
# Hermes Web UI -- Changelog
|
# 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 `<html>` 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
|
## [v0.50.142] — 2026-04-22
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -24,6 +33,7 @@
|
|||||||
newly created ones). Added `autocomplete="off"` to the search input and an explicit
|
newly created ones). Added `autocomplete="off"` to the search input and an explicit
|
||||||
value-clear at boot before the first render. Closes #822. (#830)
|
value-clear at boot before the first render. Closes #822. (#830)
|
||||||
|
|
||||||
|
|
||||||
## [v0.50.140] — 2026-04-22
|
## [v0.50.140] — 2026-04-22
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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){
|
function _buildSkinPicker(activeSkin){
|
||||||
const grid=$('skinPickerGrid');
|
const grid=$('skinPickerGrid');
|
||||||
if(!grid) return;
|
if(!grid) return;
|
||||||
|
|||||||
@@ -192,6 +192,10 @@ const LOCALES = {
|
|||||||
settings_label_send_key: 'Send Key',
|
settings_label_send_key: 'Send Key',
|
||||||
settings_label_theme: 'Theme',
|
settings_label_theme: 'Theme',
|
||||||
settings_label_skin: 'Skin',
|
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_language: 'Language',
|
||||||
settings_label_token_usage: 'Show token usage',
|
settings_label_token_usage: 'Show token usage',
|
||||||
settings_label_sidebar_density: 'Sidebar density',
|
settings_label_sidebar_density: 'Sidebar density',
|
||||||
@@ -575,6 +579,10 @@ const LOCALES = {
|
|||||||
model_search_placeholder: 'Поиск моделей…',
|
model_search_placeholder: 'Поиск моделей…',
|
||||||
reference_only_label: 'Только справка',
|
reference_only_label: 'Только справка',
|
||||||
settings_label_skin: 'Скин',
|
settings_label_skin: 'Скин',
|
||||||
|
settings_label_font_size: 'Размер шрифта',
|
||||||
|
font_size_small: 'Маленький',
|
||||||
|
font_size_default: 'Стандарт',
|
||||||
|
font_size_large: 'Большой',
|
||||||
workspace_empty_dir: 'Это рабочее пространство пусто.',
|
workspace_empty_dir: 'Это рабочее пространство пусто.',
|
||||||
workspace_empty_no_path: 'Рабочее пространство не выбрано. Настройте его в Настройки → Рабочее пространство.',
|
workspace_empty_no_path: 'Рабочее пространство не выбрано. Настройте его в Настройки → Рабочее пространство.',
|
||||||
available_personalities: 'Доступные личности:',
|
available_personalities: 'Доступные личности:',
|
||||||
@@ -1084,6 +1092,10 @@ const LOCALES = {
|
|||||||
settings_label_send_key: 'Tecla de envío',
|
settings_label_send_key: 'Tecla de envío',
|
||||||
settings_label_theme: 'Tema',
|
settings_label_theme: 'Tema',
|
||||||
settings_label_skin: 'Piel',
|
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_language: 'Idioma',
|
||||||
settings_label_token_usage: 'Mostrar uso de tokens',
|
settings_label_token_usage: 'Mostrar uso de tokens',
|
||||||
settings_label_sidebar_density: 'Densidad de la barra lateral',
|
settings_label_sidebar_density: 'Densidad de la barra lateral',
|
||||||
@@ -1516,6 +1528,10 @@ const LOCALES = {
|
|||||||
settings_label_send_key: 'Sende-Taste',
|
settings_label_send_key: 'Sende-Taste',
|
||||||
settings_label_theme: 'Theme',
|
settings_label_theme: 'Theme',
|
||||||
settings_label_skin: 'Skin',
|
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_language: 'Sprache',
|
||||||
settings_label_token_usage: 'Token-Verbrauch anzeigen',
|
settings_label_token_usage: 'Token-Verbrauch anzeigen',
|
||||||
settings_label_sidebar_density: 'Seitenleistendichte',
|
settings_label_sidebar_density: 'Seitenleistendichte',
|
||||||
@@ -1749,6 +1765,10 @@ const LOCALES = {
|
|||||||
settings_label_send_key: '\u53d1\u9001\u5feb\u6377\u952e',
|
settings_label_send_key: '\u53d1\u9001\u5feb\u6377\u952e',
|
||||||
settings_label_theme: '\u4e3b\u9898',
|
settings_label_theme: '\u4e3b\u9898',
|
||||||
settings_label_skin: '\u76ae\u80a4',
|
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_language: '\u8bed\u8a00',
|
||||||
settings_label_token_usage: '\u663e\u793a token \u7528\u91cf',
|
settings_label_token_usage: '\u663e\u793a token \u7528\u91cf',
|
||||||
settings_label_sidebar_density: '侧边栏密度',
|
settings_label_sidebar_density: '侧边栏密度',
|
||||||
@@ -2165,6 +2185,10 @@ const LOCALES = {
|
|||||||
settings_label_send_key: '\u767c\u9001\u5feb\u6377\u9375',
|
settings_label_send_key: '\u767c\u9001\u5feb\u6377\u9375',
|
||||||
settings_label_theme: '\u4e3b\u984c',
|
settings_label_theme: '\u4e3b\u984c',
|
||||||
settings_label_skin: '\u76ae\u819a',
|
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_language: '\u8a9d\u8a00',
|
||||||
settings_label_token_usage: '\u986f\u793a token \u7528\u91cf',
|
settings_label_token_usage: '\u986f\u793a token \u7528\u91cf',
|
||||||
settings_label_sidebar_density: '側邊欄密度',
|
settings_label_sidebar_density: '側邊欄密度',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) -->
|
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash) -->
|
||||||
<script>(function(){var p=location.pathname.endsWith('/')?location.pathname:(location.pathname.replace(/\/[^\/]*$/,'/')||'/');document.write('<base href="'+location.origin+p+'">');})()</script>
|
<script>(function(){var p=location.pathname.endsWith('/')?location.pathname:(location.pathname.replace(/\/[^\/]*$/,'/')||'/');document.write('<base href="'+location.origin+p+'">');})()</script>
|
||||||
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
|
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
|
||||||
|
<script>(function(){var fs=localStorage.getItem('hermes-font-size');if(fs&&fs!=='default')document.documentElement.dataset.fontSize=fs;})()</script>
|
||||||
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
|
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
|
||||||
<link rel="stylesheet" href="static/style.css">
|
<link rel="stylesheet" href="static/style.css">
|
||||||
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
|
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
|
||||||
@@ -512,6 +513,30 @@
|
|||||||
<div id="skinPickerGrid" style="display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-top:4px">
|
<div id="skinPickerGrid" style="display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-top:4px">
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="settingsSkin" value="default">
|
<input type="hidden" id="settingsSkin" value="default">
|
||||||
|
</div>
|
||||||
|
<div class="settings-field">
|
||||||
|
<label data-i18n="settings_label_font_size">Font size</label>
|
||||||
|
<div id="fontSizePickerGrid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:4px">
|
||||||
|
<button type="button" data-font-size-val="small" onclick="_pickFontSize('small')" class="font-size-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||||
|
<div style="width:100%;height:40px;border-radius:6px;background:var(--surface);border:1px solid var(--border);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||||
|
<span style="font-size:10px;font-weight:600;color:var(--muted)">Aa</span>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:12px;font-weight:500;color:var(--text)" data-i18n="font_size_small">Small</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-font-size-val="default" onclick="_pickFontSize('default')" class="font-size-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||||
|
<div style="width:100%;height:40px;border-radius:6px;background:var(--surface);border:1px solid var(--border);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||||
|
<span style="font-size:13px;font-weight:600;color:var(--muted)">Aa</span>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:12px;font-weight:500;color:var(--text)" data-i18n="font_size_default">Default</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" data-font-size-val="large" onclick="_pickFontSize('large')" class="font-size-pick-btn" style="border:1px solid var(--border2);border-radius:10px;padding:10px 8px;text-align:center;cursor:pointer;background:none;transition:all .15s">
|
||||||
|
<div style="width:100%;height:40px;border-radius:6px;background:var(--surface);border:1px solid var(--border);margin-bottom:6px;display:flex;align-items:center;justify-content:center">
|
||||||
|
<span style="font-size:17px;font-weight:600;color:var(--muted)">Aa</span>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:12px;font-weight:500;color:var(--text)" data-i18n="font_size_large">Large</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="settingsFontSize" value="default">
|
||||||
</div>
|
</div>
|
||||||
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
|
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1105,6 +1105,7 @@ document.addEventListener('drop',e=>{e.preventDefault();dragCounter=0;wrap.class
|
|||||||
let _settingsDirty = false;
|
let _settingsDirty = false;
|
||||||
let _settingsThemeOnOpen = null; // track theme at open time for discard revert
|
let _settingsThemeOnOpen = null; // track theme at open time for discard revert
|
||||||
let _settingsSkinOnOpen = null; // track skin 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 _settingsHermesDefaultModelOnOpen = '';
|
||||||
let _settingsSection = 'conversation';
|
let _settingsSection = 'conversation';
|
||||||
|
|
||||||
@@ -1152,6 +1153,7 @@ function toggleSettings(){
|
|||||||
_settingsDirty = false;
|
_settingsDirty = false;
|
||||||
_settingsThemeOnOpen = localStorage.getItem('hermes-theme') || 'dark';
|
_settingsThemeOnOpen = localStorage.getItem('hermes-theme') || 'dark';
|
||||||
_settingsSkinOnOpen = localStorage.getItem('hermes-skin') || 'default';
|
_settingsSkinOnOpen = localStorage.getItem('hermes-skin') || 'default';
|
||||||
|
_settingsFontSizeOnOpen = localStorage.getItem('hermes-font-size') || 'default';
|
||||||
_settingsSection = 'conversation';
|
_settingsSection = 'conversation';
|
||||||
overlay.style.display='';
|
overlay.style.display='';
|
||||||
loadSettingsPanel();
|
loadSettingsPanel();
|
||||||
@@ -1196,6 +1198,10 @@ function _revertSettingsPreview(){
|
|||||||
localStorage.setItem('hermes-skin', _settingsSkinOnOpen);
|
localStorage.setItem('hermes-skin', _settingsSkinOnOpen);
|
||||||
if(typeof _applySkin==='function') _applySkin(_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
|
// Show the "Unsaved changes" bar inside the settings panel
|
||||||
@@ -1243,6 +1249,10 @@ async function loadSettingsPanel(){
|
|||||||
const skinSel=$('settingsSkin');
|
const skinSel=$('settingsSkin');
|
||||||
if(skinSel) skinSel.value=skinVal;
|
if(skinSel) skinSel.value=skinVal;
|
||||||
if(typeof _buildSkinPicker==='function') _buildSkinPicker(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')
|
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
|
||||||
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'))
|
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'))
|
||||||
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
|
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
--error:#C62828;--success:#3D8B40;--warning:#E68A00;--info:#0288A8;
|
--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-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 ── */
|
/* ── Dark mode — navy-black + gold accent matching Hermes terminal ── */
|
||||||
:root.dark {
|
:root.dark {
|
||||||
--bg:#0D0D1A;--sidebar:#141425;--border:#2A2A45;--border2:rgba(255,255,255,0.14);
|
--bg:#0D0D1A;--sidebar:#141425;--border:#2A2A45;--border2:rgba(255,255,255,0.14);
|
||||||
|
|||||||
173
tests/test_font_size_setting.py
Normal file
173
tests/test_font_size_setting.py
Normal file
@@ -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}"
|
||||||
Reference in New Issue
Block a user