diff --git a/application/single_app/config.py b/application/single_app/config.py
index 0296c2451..2dc13e9a2 100644
--- a/application/single_app/config.py
+++ b/application/single_app/config.py
@@ -97,7 +97,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
-VERSION = "0.250.072"
+VERSION = "0.250.073"
IS_DEVELOPMENT = is_development_env_enabled()
SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py
index 9acbf63a9..709a6f908 100644
--- a/application/single_app/functions_settings.py
+++ b/application/single_app/functions_settings.py
@@ -27,6 +27,8 @@
USER_SETTINGS_REQUEST_CACHE_ATTR = "simplechat_user_settings_request_cache"
+FONT_SIZE_PREFERENCES = ("xs", "s", "m", "l", "xl")
+DEFAULT_FONT_SIZE_PREFERENCE = "m"
USER_UI_SETTINGS_KEYS = (
"profileImage",
"navLayout",
@@ -38,6 +40,7 @@
"sidebarToggleStyle",
"sidebarMenuState",
LATEST_FEATURES_HIDDEN_VERSION_SETTING,
+ "fontSizePreference",
)
ADMIN_SETTINGS_SECRET_REDACTED_VALUE = "***REDACTED***"
ADMIN_SETTINGS_FORM_SECRET_FIELDS = (
@@ -129,6 +132,13 @@ def _clone_user_settings_doc(doc):
return copy.deepcopy(doc or {})
+def normalize_font_size_preference(value):
+ normalized_value = str(value or "").strip().lower()
+ if normalized_value in FONT_SIZE_PREFERENCES:
+ return normalized_value
+ return DEFAULT_FONT_SIZE_PREFERENCE
+
+
def _get_user_settings_request_cache():
if not has_request_context():
return None
@@ -163,11 +173,15 @@ def _extract_user_ui_settings(doc):
settings = (doc or {}).get('settings', {})
if not isinstance(settings, dict):
settings = {}
- return {
+ ui_settings = {
key: copy.deepcopy(settings[key])
for key in USER_UI_SETTINGS_KEYS
if key in settings
}
+ ui_settings["fontSizePreference"] = normalize_font_size_preference(
+ settings.get("fontSizePreference")
+ )
+ return ui_settings
def _delete_user_ui_settings_cache(user_id):
diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py
index 643acac61..f33d0accc 100644
--- a/application/single_app/route_backend_users.py
+++ b/application/single_app/route_backend_users.py
@@ -506,7 +506,7 @@ def user_settings():
# Chat UI settings
'navbar_layout', 'chatLayout', 'showChatTitle', 'chatSplitSizes',
'deepResearchDefaultEnabled',
- 'sidebarToggleStyle', 'sidebarMenuState',
+ 'sidebarToggleStyle', 'sidebarMenuState', 'fontSizePreference',
LATEST_FEATURES_HIDDEN_VERSION_SETTING,
# Microphone permission settings
'microphonePermissionPreference', 'microphonePermissionState',
@@ -542,6 +542,14 @@ def user_settings():
return jsonify({"error": "Invalid sidebar toggle style"}), 400
settings_to_update["sidebarToggleStyle"] = sidebar_toggle_style
+ if "fontSizePreference" in settings_to_update:
+ font_size_preference = str(
+ settings_to_update.get("fontSizePreference") or ""
+ ).strip().lower()
+ if font_size_preference not in FONT_SIZE_PREFERENCES:
+ return jsonify({"error": "Invalid font size preference"}), 400
+ settings_to_update["fontSizePreference"] = font_size_preference
+
if "sidebarMenuState" in settings_to_update:
sidebar_menu_state = settings_to_update.get("sidebarMenuState")
allowed_sidebar_menu_keys = {
diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css
index d94a66f4e..1d0b80ef9 100644
--- a/application/single_app/static/css/chats.css
+++ b/application/single_app/static/css/chats.css
@@ -1,5 +1,52 @@
/* chats.css */
+body.chat-shell-page {
+ --chat-shell-top-offset: var(--top-nav-height, 66px);
+ height: 100dvh;
+ min-height: 0 !important;
+ overflow: hidden;
+}
+
+body.chat-shell-page.has-classification-banner {
+ --chat-shell-top-offset: calc(
+ var(--top-nav-height, 66px) + var(--classification-banner-height, 40px)
+ );
+}
+
+body.chat-shell-page.sidebar-nav-enabled {
+ --chat-shell-top-offset: 0px;
+}
+
+body.chat-shell-page.sidebar-nav-enabled.has-classification-banner {
+ --chat-shell-top-offset: var(--classification-banner-height, 40px);
+}
+
+body.chat-shell-page.sidebar-nav-enabled {
+ padding-top: var(--chat-shell-top-offset) !important;
+}
+
+body.chat-shell-page #main-content {
+ min-height: 0;
+}
+
+body.chat-shell-page.sidebar-nav-enabled #main-content {
+ padding-top: 0 !important;
+}
+
+body.chat-shell-page.sidebar-nav-enabled .chat-page-shell {
+ margin-left: 0 !important;
+ max-width: 100% !important;
+ padding-top: 0 !important;
+ width: 100% !important;
+}
+
+.chat-page-shell {
+ height: calc(100vh - var(--chat-shell-top-offset));
+ height: calc(100dvh - var(--chat-shell-top-offset));
+ min-height: 0;
+ overflow: hidden;
+}
+
/* Prevent initial layout shift by disabling transitions on page load */
.no-transitions *,
.no-transitions #left-pane,
@@ -57,7 +104,7 @@
}
.chat-shell-status-row {
- min-height: 1.5rem;
+ min-height: 0;
}
.assistant-follow-up-actions {
@@ -881,6 +928,7 @@ img.chat-searchable-select-item-icon {
#left-pane {
height: 100%; /* Make panes fill the container height */
+ min-height: 0;
overflow: hidden; /* Prevent panes themselves from scrolling */
margin-right: 10px; /* Optional: space between left and right panes */
/* d-flex and flex-column are already on the elements via HTML classes for internal layout */
@@ -888,6 +936,7 @@ img.chat-searchable-select-item-icon {
#right-pane {
height: 100%; /* Make panes fill the container height */
+ min-height: 0;
overflow: hidden; /* Prevent panes themselves from scrolling */
margin-left: 30px; /* Optional: space between left and right panes */
/* d-flex and flex-column are already on the elements via HTML classes for internal layout */
@@ -1147,10 +1196,10 @@ img.chat-searchable-select-item-icon {
/* Style when docked - CLEAN SIMPLE IMPLEMENTATION */
body.layout-docked #left-pane {
position: fixed !important;
- top: 66px !important; /* Below navbar */
+ top: var(--chat-shell-top-offset) !important;
left: 0 !important;
width: 320px !important;
- height: calc(100vh - 66px) !important;
+ height: calc(100dvh - var(--chat-shell-top-offset)) !important;
z-index: 1031 !important;
background: var(--bs-body-bg, white) !important;
border-right: 1px solid var(--bs-border-color, #dee2e6) !important;
@@ -1159,7 +1208,7 @@ body.layout-docked #left-pane {
body.layout-docked #right-pane {
margin-left: 320px !important;
width: calc(100% - 320px) !important;
- height: calc(100vh - 66px) !important;
+ height: calc(100dvh - var(--chat-shell-top-offset)) !important;
position: relative !important;
/* Center content horizontally */
display: flex !important;
@@ -1173,16 +1222,6 @@ body.layout-docked #right-pane > div {
max-width: 1000px !important;
}
-/* When classification banner is present */
-body.has-classification-banner.layout-docked #left-pane {
- top: 106px !important;
- height: calc(100vh - 106px) !important;
-}
-
-body.has-classification-banner.layout-docked #right-pane {
- height: calc(100vh - 106px) !important;
-}
-
/* Hide Split.js gutter when docked */
body.layout-docked .gutter {
display: none !important;
@@ -1630,6 +1669,7 @@ body.layout-split .gutter {
/* Chatbox styling */
#chatbox {
padding: 5px;
+ min-height: 0;
overflow-y: auto;
overflow-x: clip; /* Prevent horizontal scroll but allow content to be visible */
flex-grow: 1;
@@ -1644,6 +1684,44 @@ body.layout-split .gutter {
display: flex;
flex-direction: column;
height: 100%;
+ min-height: 0;
+}
+
+html[data-font-size="l"] .chat-shell-header,
+html[data-font-size="xl"] .chat-shell-header {
+ padding: 0.5rem 0.75rem !important;
+}
+
+html[data-font-size="l"] .chat-container > .border-top,
+html[data-font-size="xl"] .chat-container > .border-top {
+ padding: 0.5rem 0.75rem !important;
+}
+
+@media (max-height: 600px) {
+ .chat-shell-header {
+ padding: 0.375rem 0.75rem !important;
+ }
+
+ .chat-container > .border-top {
+ padding: 0.375rem 0.75rem !important;
+ }
+
+ .chat-toolbar {
+ gap: 0.375rem;
+ margin-bottom: 0.25rem !important;
+ }
+
+ .chat-toolbar-controls {
+ gap: 0.375rem;
+ }
+
+ #user-input {
+ min-height: 44px !important;
+ }
+
+ .chat-toolbar-mobile-panel {
+ --bs-offcanvas-height: min(24rem, 90dvh);
+ }
}
.classification-container {
diff --git a/application/single_app/static/css/navigation.css b/application/single_app/static/css/navigation.css
index 866361ece..4ca6625a4 100644
--- a/application/single_app/static/css/navigation.css
+++ b/application/single_app/static/css/navigation.css
@@ -1,8 +1,8 @@
/* Top Navigation Styles */
:root {
- --top-nav-height: 66px;
- --classification-banner-height: 40px;
+ --top-nav-height: 4.125rem;
+ --classification-banner-height: max(40px, 2.5rem);
}
/* Base body padding for top navigation */
@@ -93,12 +93,12 @@ body {
@media (max-width: 991.98px) {
.top-nav-mobile-drawer.offcanvas-start {
- height: calc(100vh - var(--top-nav-height));
+ height: calc(100dvh - var(--top-nav-height));
top: var(--top-nav-height);
}
body.has-classification-banner .top-nav-mobile-drawer.offcanvas-start {
- height: calc(100vh - var(--top-nav-height) - var(--classification-banner-height));
+ height: calc(100dvh - var(--top-nav-height) - var(--classification-banner-height));
top: calc(var(--top-nav-height) + var(--classification-banner-height));
}
}
@@ -159,7 +159,7 @@ body {
}
.custom-pages-drawer-list {
- max-height: calc(100vh - 9rem);
+ max-height: calc(100dvh - 9rem);
overflow-y: auto;
}
@@ -337,7 +337,7 @@ body {
@media (max-width: 991.98px) {
:root {
- --top-nav-height: 64px;
+ --top-nav-height: 4rem;
}
.top-nav-shell {
diff --git a/application/single_app/static/css/sidebar.css b/application/single_app/static/css/sidebar.css
index c103070d4..4dcba03a6 100644
--- a/application/single_app/static/css/sidebar.css
+++ b/application/single_app/static/css/sidebar.css
@@ -15,6 +15,7 @@ body.sidebar-nav-enabled {
/* Sidebar container */
#sidebar-nav {
display: none;
+ height: 100dvh !important;
transition: transform 0.3s ease;
}
@@ -56,20 +57,20 @@ body.sidebar-nav-enabled .container-fluid {
/* Classification banner adjustments for sidebar layout */
body.has-classification-banner #sidebar-nav {
- top: 40px !important; /* Start below the classification banner */
- height: calc(100vh - 40px) !important; /* Adjust height to account for banner */
+ top: var(--classification-banner-height) !important;
+ height: calc(100dvh - var(--classification-banner-height)) !important;
}
/* Chats top-nav layout: align the fixed sidebar just below the navbar */
nav.navbar.fixed-top + #sidebar-nav {
top: var(--top-nav-height) !important;
- height: calc(100vh - var(--top-nav-height));
+ height: calc(100dvh - var(--top-nav-height)) !important;
}
/* Account for classification banner when present */
body.has-classification-banner nav.navbar + #sidebar-nav {
top: calc(var(--top-nav-height) + var(--classification-banner-height)) !important;
- height: calc(100vh - var(--top-nav-height) - var(--classification-banner-height));
+ height: calc(100dvh - var(--top-nav-height) - var(--classification-banner-height)) !important;
}
body.chat-top-nav-shell {
@@ -116,7 +117,7 @@ body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav .chat-sidebar-mobile-secti
@media (min-width: 992px) {
body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav {
- height: calc(100vh - var(--chat-sidebar-top));
+ height: calc(100dvh - var(--chat-sidebar-top)) !important;
left: 0;
max-width: none;
position: fixed;
@@ -131,7 +132,7 @@ body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav .chat-sidebar-mobile-secti
@media (max-width: 991.98px) {
body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav {
- height: calc(100vh - var(--chat-sidebar-top));
+ height: calc(100dvh - var(--chat-sidebar-top)) !important;
max-width: min(88vw, 22rem);
top: var(--chat-sidebar-top);
z-index: 1045;
@@ -155,7 +156,7 @@ body.chat-top-nav-shell #sidebar-nav.chat-sidebar-nav .chat-sidebar-mobile-secti
/* Floating expand button positioning when classification banner is present */
body.has-classification-banner #floating-expand-btn {
- top: calc(0.5rem + 40px) !important; /* Start below the classification banner */
+ top: calc(0.5rem + var(--classification-banner-height)) !important;
}
#floating-expand-btn {
@@ -245,7 +246,7 @@ body.sidebar-nav-enabled.has-classification-banner .container-fluid {
}
#sidebar-nav:not(.chat-sidebar-nav) .sidebar-scroll-content {
- padding-bottom: 80px;
+ padding-bottom: max(80px, 4rem);
}
#conversations-section {
diff --git a/application/single_app/static/css/styles.css b/application/single_app/static/css/styles.css
index 397b38a8b..5bb96240a 100644
--- a/application/single_app/static/css/styles.css
+++ b/application/single_app/static/css/styles.css
@@ -1,5 +1,25 @@
/* css/styles.css */
+html[data-font-size="xs"] {
+ font-size: 75%;
+}
+
+html[data-font-size="s"] {
+ font-size: 87.5%;
+}
+
+html[data-font-size="m"] {
+ font-size: 100%;
+}
+
+html[data-font-size="l"] {
+ font-size: 150%;
+}
+
+html[data-font-size="xl"] {
+ font-size: 200%;
+}
+
body {
font-family: var(--bs-font-sans-serif);
margin: 0;
diff --git a/application/single_app/templates/base.html b/application/single_app/templates/base.html
index 8797073ed..c0763a29f 100644
--- a/application/single_app/templates/base.html
+++ b/application/single_app/templates/base.html
@@ -1,7 +1,14 @@
-
+
+{% set allowed_font_size_preferences = ['xs', 's', 'm', 'l', 'xl'] %}
+{% set saved_font_size_preference = user_settings.get('settings', {}).get('fontSizePreference', 'm') %}
-
+
@@ -176,7 +183,8 @@
.editor-toolbar a.fa-question-circle:before { content: "?"; }
.chat-container {
- max-height: calc(100vh - var(--top-nav-height, 66px));
+ max-height: 100%;
+ min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -256,7 +264,7 @@
{% if app_settings.classification_banner_enabled and app_settings.classification_banner_text %}
+ style="background: {{ app_settings.classification_banner_color or '#ffc107' }}; color: {{ app_settings.classification_banner_text_color or '#ffffff' }}; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: bold; padding: 0; height: var(--classification-banner-height); font-size: 1.1em; letter-spacing: 0.5px; position: fixed; top: 0; left: 0; width: 100%; z-index: 1051;">
{{ app_settings.classification_banner_text }}
{% endif %}
diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html
index 460a49b16..2970e2d63 100644
--- a/application/single_app/templates/chats.html
+++ b/application/single_app/templates/chats.html
@@ -235,7 +235,7 @@
{% endblock %}
{% block content %}
-
+
diff --git a/application/single_app/templates/profile.html b/application/single_app/templates/profile.html
index c73585d28..7b7871d45 100644
--- a/application/single_app/templates/profile.html
+++ b/application/single_app/templates/profile.html
@@ -151,6 +151,21 @@
min-height: 1.5rem;
}
+ .font-size-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ }
+
+ .font-size-options .btn {
+ min-width: 5.5rem;
+ }
+
+ .font-size-preview {
+ border-left: 0.25rem solid var(--bs-primary);
+ font-size: 1rem;
+ }
+
.fact-memory-summary-card {
background: linear-gradient(135deg, rgba(13, 110, 253, 0.08), rgba(13, 202, 240, 0.12));
border: 1px solid rgba(13, 110, 253, 0.14);
@@ -742,6 +757,73 @@
Account Information
aria-labelledby="profile-settings-tab"
>
+ {% set font_size_preference = user_settings.get('settings', {}).get('fontSizePreference', 'm') %}
+ {% if font_size_preference not in ['xs', 's', 'm', 'l', 'xl'] %}
+ {% set font_size_preference = 'm' %}
+ {% endif %}
+
+
+
+
+
+
+
+
Appearance Preferences
+
Choose the text size used across SimpleChat.
+
+ Selecting a size previews it immediately. Choose Save to use it on future page loads.
+
+
+
+
+
+
+
+
+
+ Preview: SimpleChat text will appear at this size.
+
+
+
+
+
+
+
+
+
+
{% set sidebar_toggle_style = user_settings.get('settings', {}).get('sidebarToggleStyle', 'large') %}
{% set latest_features_hidden_version = user_settings.get('settings', {}).get('latestFeaturesHiddenVersion') %}
{% set latest_features_hidden_for_current_version = latest_features_hidden_version == config['VERSION'] %}
@@ -2100,6 +2182,10 @@
const FACT_MEMORY_TYPE_LEGACY_DESCRIBER = 'describer';
let pendingFactMemoryDeleteTrigger = null;
const FACT_MEMORY_DELETE_MODAL_STACKED_CLASS = 'fact-memory-delete-modal-stacked';
+const FONT_SIZE_PREFERENCES = ['xs', 's', 'm', 'l', 'xl'];
+let persistedFontSizePreference = normalizeFontSizePreference(
+ document.documentElement.dataset.fontSize
+);
const FACT_MEMORY_DELETE_BACKDROP_STACKED_CLASS = 'fact-memory-delete-backdrop-stacked';
console.log('Profile page script loaded - preparing to initialize');
@@ -2351,6 +2437,7 @@
loadRetentionSettings();
handleProfileFeatureAction();
+ initializeFontSizePreferences();
loadNavigationPreferences();
loadTutorialPreferences();
initializeProfileStatsWindowControls();
@@ -2402,6 +2489,11 @@
saveNavigationPreferencesButton.addEventListener('click', saveNavigationPreferences);
}
+ const saveFontSizePreferenceButton = document.getElementById('save-font-size-preference-btn');
+ if (saveFontSizePreferenceButton) {
+ saveFontSizePreferenceButton.addEventListener('click', saveFontSizePreference);
+ }
+
// Initialize TTS settings if enabled
{% if app_settings.enable_text_to_speech %}
loadTTSSettings();
@@ -2479,6 +2571,104 @@
statusElement.className = `preference-status small mt-3 ${classMap[type] || classMap.muted}`;
}
+function normalizeFontSizePreference(value) {
+ return FONT_SIZE_PREFERENCES.includes(value) ? value : 'm';
+}
+
+function updateFontSizePreferenceStatus(message, type = 'muted') {
+ const statusElement = document.getElementById('font-size-preference-status');
+ if (!statusElement) {
+ return;
+ }
+
+ const classMap = {
+ muted: 'text-muted',
+ info: 'text-info',
+ success: 'text-success',
+ danger: 'text-danger'
+ };
+
+ statusElement.textContent = message || '';
+ statusElement.className = `preference-status small mt-3 ${classMap[type] || classMap.muted}`;
+}
+
+function setFontSizePreference(value) {
+ const normalizedValue = normalizeFontSizePreference(value);
+ const selectedInput = document.getElementById(`font-size-preference-${normalizedValue}`);
+ if (selectedInput) {
+ selectedInput.checked = true;
+ }
+ document.documentElement.dataset.fontSize = normalizedValue;
+ return normalizedValue;
+}
+
+function getSelectedFontSizePreference() {
+ const selectedInput = document.querySelector('input[name="font-size-preference"]:checked');
+ return normalizeFontSizePreference(selectedInput?.value);
+}
+
+function initializeFontSizePreferences() {
+ setFontSizePreference(persistedFontSizePreference);
+ document.querySelectorAll('input[name="font-size-preference"]').forEach(input => {
+ input.addEventListener('change', function() {
+ const previewValue = setFontSizePreference(this.value);
+ updateFontSizePreferenceStatus(
+ `Previewing ${previewValue.toUpperCase()}. Save to use this size on future page loads.`,
+ 'info'
+ );
+ });
+ });
+}
+
+function saveFontSizePreference() {
+ const button = document.getElementById('save-font-size-preference-btn');
+ if (!button) {
+ return;
+ }
+
+ const selectedPreference = getSelectedFontSizePreference();
+ button.disabled = true;
+ updateFontSizePreferenceStatus('Saving your font size preference...', 'info');
+
+ fetch('/api/user/settings', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ settings: {
+ fontSizePreference: selectedPreference
+ }
+ })
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error('Failed to save font size preference');
+ }
+ return response.json();
+ })
+ .then(() => {
+ persistedFontSizePreference = selectedPreference;
+ window.simplechatUserSettings.fontSizePreference = selectedPreference;
+ updateFontSizePreferenceStatus(
+ `${selectedPreference.toUpperCase()} is now your saved font size.`,
+ 'success'
+ );
+ showToastMessage('Font size preference saved successfully', 'success');
+ })
+ .catch(error => {
+ console.error('Error saving font size preference:', error);
+ setFontSizePreference(persistedFontSizePreference);
+ updateFontSizePreferenceStatus(
+ 'Failed to save font size preference. The previous size has been restored.',
+ 'danger'
+ );
+ })
+ .finally(() => {
+ button.disabled = false;
+ });
+}
+
function getSelectedSidebarToggleStyle() {
const selectedInput = document.querySelector('input[name="sidebar-toggle-style"]:checked');
return selectedInput && selectedInput.value === 'compact' ? 'compact' : 'large';
diff --git a/docs/explanation/fixes/FONT_SIZE_AND_200_PERCENT_ZOOM_FIX.md b/docs/explanation/fixes/FONT_SIZE_AND_200_PERCENT_ZOOM_FIX.md
new file mode 100644
index 000000000..1d0048b3f
--- /dev/null
+++ b/docs/explanation/fixes/FONT_SIZE_AND_200_PERCENT_ZOOM_FIX.md
@@ -0,0 +1,96 @@
+# Font Size and 200 Percent Zoom Fix
+
+Fixed in version: **0.250.073**
+
+## Issue Description
+
+Users who enlarged SimpleChat to 200 percent browser zoom on a typical laptop
+could lose most of the visible chat history because navigation, headers, tools,
+and the composer consumed or clipped the reduced effective viewport. Users also
+lacked an account-level text-size preference.
+
+## Root Cause Analysis
+
+- Chat height calculations repeated fixed 66px and 106px offsets instead of
+ sharing the active navigation and classification-banner offsets.
+- Fixed-height panes and hidden overflow could clip the composer when the
+ effective viewport height became small.
+- Desktop chat tools occupied significant vertical space until the existing
+ mobile breakpoint activated.
+- SimpleChat did not persist a validated global font-size preference.
+
+## Technical Details
+
+### Files Modified
+
+- `application/single_app/functions_settings.py`
+- `application/single_app/route_backend_users.py`
+- `application/single_app/templates/base.html`
+- `application/single_app/templates/profile.html`
+- `application/single_app/templates/chats.html`
+- `application/single_app/static/css/styles.css`
+- `application/single_app/static/css/navigation.css`
+- `application/single_app/static/css/sidebar.css`
+- `application/single_app/static/css/chats.css`
+- `functional_tests/test_font_size_preference.py`
+- `functional_tests/test_user_settings_allowlist_keys.py`
+- `functional_tests/test_user_settings_cache_optimization.py`
+- `ui_tests/test_profile_font_size_and_chat_zoom.py`
+- `application/single_app/config.py`
+
+### Code Changes Summary
+
+- Added a validated `fontSizePreference` setting with `xs`, `s`, `m`, `l`, and
+ `xl` values. Missing or unsupported stored values render as medium.
+- Mapped the choices to 75, 87.5, 100, 150, and 200 percent root font sizes.
+- Added an accessible Appearance Preferences radio group with immediate preview,
+ explicit Save persistence, live status updates, and rollback after save errors.
+- Applied the saved preference as a controlled HTML data attribute before CSS
+ renders, preventing a default-size flash and arbitrary style injection.
+- Replaced duplicated chat viewport offsets with shared navigation-aware values
+ and dynamic viewport units.
+- Made top-navigation and classification-banner offsets font-relative so large
+ preferences reserve enough space without overlapping page content.
+- Preserved a minimum-size flex message region, reduced nonessential spacing in
+ short viewports, and reused the mobile tools drawer at the 200 percent
+ zoom-equivalent breakpoint.
+- Preserved Development's whole-sidebar scroll-boundary behavior while updating
+ the sidebar shell to use dynamic viewport sizing and font-relative footer
+ clearance.
+
+## Testing Approach
+
+- Added a functional contract test for enum normalization, API validation,
+ pre-paint rendering, profile controls, and CSS percentage mappings.
+- Updated existing settings allowlist and lightweight UI cache tests.
+- Added an authenticated Playwright test for live preview, Save-only
+ persistence, XL rendering, top navigation, sidebar navigation, page overflow,
+ and chat/composer visibility at a 720x450 CSS viewport.
+
+## Validation
+
+### Test Results
+
+- Font-size functional contract tests pass.
+- User-settings allowlist and cache regression tests pass.
+- Updated Jinja templates parse successfully.
+- Python syntax validation passes for the new UI test.
+- The authenticated UI regression test requires `SIMPLECHAT_UI_BASE_URL` and a
+ valid Playwright storage-state file in the target environment.
+
+### Before and After
+
+- **Before:** At 200 percent zoom, fixed offsets and clipped panes could leave
+ only a line or two of chat visible and make lower controls unreachable.
+- **After:** Chat uses the reduced viewport as a flex layout with independently
+ scrolling content, compact tools, and a reachable composer.
+- **Before:** Users relied entirely on browser zoom for larger text.
+- **After:** Users can save a global XS through XL preference, including a
+ 200 percent text option, while browser zoom remains independently supported.
+
+### User Experience Improvements
+
+- Text size follows the signed-in user across SimpleChat pages.
+- Profile selections preview immediately without silently saving.
+- Navigation, messages, tools, and the composer remain reachable in compact
+ laptop and zoomed layouts.
diff --git a/docs/explanation/fixes/index.md b/docs/explanation/fixes/index.md
index d82e04a07..05f859853 100644
--- a/docs/explanation/fixes/index.md
+++ b/docs/explanation/fixes/index.md
@@ -6,6 +6,7 @@ order: 120
category: Version History
---
+- [Font Size and 200 Percent Zoom Fix](FONT_SIZE_AND_200_PERCENT_ZOOM_FIX.md)
- [Public Workspace Prompt Migration Fix](PUBLIC_WORKSPACE_PROMPT_MIGRATION_FIX.md)
- [Azure OpenAI Model Discovery Identity Fix](v0.250.001/AZURE_OPENAI_MODEL_DISCOVERY_IDENTITY_FIX.md)
- [CosmosClient Import Binding CodeQL Fix](COSMOSCLIENT_IMPORT_BINDING_CODEQL_FIX.md)
diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md
index a93e355c2..7930f786d 100644
--- a/docs/explanation/release_notes.md
+++ b/docs/explanation/release_notes.md
@@ -2,6 +2,21 @@
For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/).
+### **(v0.250.073)**
+
+#### New Features
+
+* **User Font Size Preferences**
+ * Added persisted XS, S, M, L, and XL font-size choices to the user profile, ranging from 75% to 200% with medium as the default.
+ * Font-size selections preview immediately and apply across SimpleChat after the user saves the preference.
+ * (Ref: microsoft/simplechat#1099, `profile.html`, `functions_settings.py`, `FONT_SIZE_AND_200_PERCENT_ZOOM_FIX.md`)
+
+#### User Interface Enhancements
+
+* **200% Zoom and Large-Text Layout Support**
+ * Updated Chat, top navigation, classification banners, and sidebar scrolling to reserve font-relative space and keep messages, navigation, tools, and the composer reachable at 200% browser zoom and large saved font sizes.
+ * (Ref: microsoft/simplechat#1099, `chats.css`, `navigation.css`, `sidebar.css`)
+
### **(v0.250.072)**
#### Bug Fixes
diff --git a/functional_tests/test_font_size_preference.py b/functional_tests/test_font_size_preference.py
new file mode 100644
index 000000000..cfb72900b
--- /dev/null
+++ b/functional_tests/test_font_size_preference.py
@@ -0,0 +1,157 @@
+#!/usr/bin/env python3
+"""
+Functional test for the global font size preference.
+Version: 0.250.073
+Implemented in: 0.250.073
+
+This test ensures that supported font size values are normalized, persisted
+through the user settings contract, and mapped to controlled global CSS.
+"""
+
+import ast
+import os
+import sys
+
+
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+
+def _read_repo_file(*parts):
+ file_path = os.path.join(REPO_ROOT, *parts)
+ with open(file_path, "r", encoding="utf-8") as file_handle:
+ return file_handle.read()
+
+
+def _load_font_size_contract():
+ settings_source = _read_repo_file(
+ "application",
+ "single_app",
+ "functions_settings.py",
+ )
+ settings_tree = ast.parse(settings_source)
+ selected_nodes = []
+ target_names = {
+ "FONT_SIZE_PREFERENCES",
+ "DEFAULT_FONT_SIZE_PREFERENCE",
+ }
+
+ for node in settings_tree.body:
+ if isinstance(node, ast.Assign):
+ assigned_names = {
+ target.id
+ for target in node.targets
+ if isinstance(target, ast.Name)
+ }
+ if assigned_names & target_names:
+ selected_nodes.append(node)
+ elif (
+ isinstance(node, ast.FunctionDef)
+ and node.name == "normalize_font_size_preference"
+ ):
+ selected_nodes.append(node)
+
+ namespace = {}
+ contract_module = ast.Module(body=selected_nodes, type_ignores=[])
+ exec(compile(contract_module, "functions_settings.py", "exec"), namespace)
+ return namespace
+
+
+def test_font_size_normalization_contract():
+ """Validate supported values and safe fallback behavior."""
+ contract = _load_font_size_contract()
+ normalize = contract["normalize_font_size_preference"]
+
+ assert contract["FONT_SIZE_PREFERENCES"] == ("xs", "s", "m", "l", "xl")
+ assert contract["DEFAULT_FONT_SIZE_PREFERENCE"] == "m"
+ assert normalize(" XL ") == "xl"
+ assert normalize("unsupported") == "m"
+ assert normalize(None) == "m"
+
+
+def test_font_size_api_and_render_contract():
+ """Validate API allowlisting, strict validation, and controlled CSS mappings."""
+ route_source = _read_repo_file(
+ "application",
+ "single_app",
+ "route_backend_users.py",
+ )
+ base_template = _read_repo_file(
+ "application",
+ "single_app",
+ "templates",
+ "base.html",
+ )
+ profile_template = _read_repo_file(
+ "application",
+ "single_app",
+ "templates",
+ "profile.html",
+ )
+ styles_source = _read_repo_file(
+ "application",
+ "single_app",
+ "static",
+ "css",
+ "styles.css",
+ )
+ navigation_source = _read_repo_file(
+ "application",
+ "single_app",
+ "static",
+ "css",
+ "navigation.css",
+ )
+ sidebar_source = _read_repo_file(
+ "application",
+ "single_app",
+ "static",
+ "css",
+ "sidebar.css",
+ )
+
+ assert "'fontSizePreference'" in route_source
+ assert "font_size_preference not in FONT_SIZE_PREFERENCES" in route_source
+ assert "Invalid font size preference" in route_source
+ assert 'data-font-size="{{ saved_font_size_preference' in base_template
+ assert 'name="font-size-preference"' in profile_template
+ assert "saveFontSizePreference" in profile_template
+ assert "--top-nav-height: 4.125rem;" in navigation_source
+ assert (
+ "--classification-banner-height: max(40px, 2.5rem);"
+ in navigation_source
+ )
+ assert "height: var(--classification-banner-height);" in base_template
+ assert "padding-bottom: max(80px, 4rem);" in sidebar_source
+
+ expected_mappings = {
+ "xs": "75%",
+ "s": "87.5%",
+ "m": "100%",
+ "l": "150%",
+ "xl": "200%",
+ }
+ for preference, percentage in expected_mappings.items():
+ selector = f'html[data-font-size="{preference}"]'
+ assert selector in styles_source
+ selector_block = styles_source.split(selector, 1)[1].split("}", 1)[0]
+ assert f"font-size: {percentage};" in selector_block
+
+
+if __name__ == "__main__":
+ tests = [
+ test_font_size_normalization_contract,
+ test_font_size_api_and_render_contract,
+ ]
+ results = []
+
+ for test in tests:
+ print(f"Running {test.__name__}...")
+ try:
+ test()
+ results.append(True)
+ print("PASS")
+ except Exception as exc:
+ results.append(False)
+ print(f"FAIL: {exc}")
+
+ sys.exit(0 if all(results) else 1)
diff --git a/functional_tests/test_user_settings_allowlist_keys.py b/functional_tests/test_user_settings_allowlist_keys.py
index 2c2393fed..46f72a21c 100644
--- a/functional_tests/test_user_settings_allowlist_keys.py
+++ b/functional_tests/test_user_settings_allowlist_keys.py
@@ -36,6 +36,7 @@ def test_user_settings_allowlist_contains_known_keys():
'personal_model_endpoints',
'tag_definitions',
'deepResearchDefaultEnabled',
+ 'fontSizePreference',
]
missing_keys = [
diff --git a/functional_tests/test_user_settings_cache_optimization.py b/functional_tests/test_user_settings_cache_optimization.py
index 65ec13065..7460ae6b3 100644
--- a/functional_tests/test_user_settings_cache_optimization.py
+++ b/functional_tests/test_user_settings_cache_optimization.py
@@ -88,6 +88,9 @@ def test_user_ui_settings_cache_contract():
assert '"sidebarMenuState"' in settings_content, (
"Expected sidebar menu state to be included in lightweight UI settings"
)
+ assert '"fontSizePreference"' in settings_content, (
+ "Expected font size preference to be included in lightweight UI settings"
+ )
print("PASS: user UI settings cache contract verified")
diff --git a/ui_tests/test_profile_font_size_and_chat_zoom.py b/ui_tests/test_profile_font_size_and_chat_zoom.py
new file mode 100644
index 000000000..4b1358862
--- /dev/null
+++ b/ui_tests/test_profile_font_size_and_chat_zoom.py
@@ -0,0 +1,145 @@
+# test_profile_font_size_and_chat_zoom.py
+"""
+UI test for profile font sizing and 200 percent chat zoom resilience.
+Version: 0.250.073
+Implemented in: 0.250.073
+
+This test ensures users can preview and save a global font size and that the
+chat remains usable at a 720x450 CSS viewport representing 200 percent zoom.
+"""
+
+import os
+from pathlib import Path
+
+import pytest
+from playwright.sync_api import expect
+
+
+BASE_URL = os.getenv("SIMPLECHAT_UI_BASE_URL", "").rstrip("/")
+STORAGE_STATE = os.getenv("SIMPLECHAT_UI_STORAGE_STATE", "")
+ADMIN_STORAGE_STATE = os.getenv("SIMPLECHAT_UI_ADMIN_STORAGE_STATE", "")
+
+
+def _require_base_url():
+ if not BASE_URL:
+ pytest.skip("Set SIMPLECHAT_UI_BASE_URL to run this UI test.")
+
+
+def _get_storage_state_path():
+ for candidate in (STORAGE_STATE, ADMIN_STORAGE_STATE):
+ if candidate and Path(candidate).exists():
+ return candidate
+ pytest.skip(
+ "Set SIMPLECHAT_UI_STORAGE_STATE or SIMPLECHAT_UI_ADMIN_STORAGE_STATE "
+ "to a valid authenticated Playwright storage state file."
+ )
+ return None
+
+
+def _get_user_settings(page):
+ return page.evaluate(
+ """
+ async () => {
+ const response = await fetch('/api/user/settings');
+ const data = await response.json();
+ return data.settings || {};
+ }
+ """
+ )
+
+
+def _set_user_settings(page, settings):
+ return page.evaluate(
+ """
+ async (nextSettings) => {
+ const response = await fetch('/api/user/settings', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ settings: nextSettings })
+ });
+ return response.ok;
+ }
+ """,
+ settings,
+ )
+
+
+def _assert_chat_viewport_is_usable(page):
+ expect(page.locator("#chatbox")).to_be_visible()
+ expect(page.locator("#user-input")).to_be_visible()
+ expect(page.locator("#chat-mobile-tools-toggle")).to_be_visible()
+
+ chatbox = page.locator("#chatbox").bounding_box()
+ user_input = page.locator("#user-input").bounding_box()
+ assert chatbox is not None and chatbox["height"] >= 40
+ assert user_input is not None
+ assert user_input["y"] + user_input["height"] <= 450
+ assert page.evaluate(
+ "document.documentElement.scrollWidth <= window.innerWidth + 1"
+ )
+
+
+@pytest.mark.ui
+def test_profile_font_size_and_chat_zoom_reflow(playwright):
+ """Validate preview, persistence, and zoom-equivalent chat reflow."""
+ _require_base_url()
+ storage_state = _get_storage_state_path()
+
+ browser = playwright.chromium.launch()
+ context = browser.new_context(
+ storage_state=storage_state,
+ viewport={"width": 1440, "height": 900},
+ )
+ page = context.new_page()
+ original_settings = None
+
+ try:
+ response = page.goto(f"{BASE_URL}/profile?tab=settings", wait_until="domcontentloaded")
+ assert response is not None and response.ok
+ expect(page.get_by_role("heading", name="Appearance Preferences")).to_be_visible()
+ original_settings = _get_user_settings(page)
+
+ original_preference = original_settings.get("fontSizePreference", "m")
+ page.locator("#font-size-preference-xl").check()
+ expect(page.locator("html")).to_have_attribute("data-font-size", "xl")
+ expect(page.locator("html")).to_have_css("font-size", "32px")
+
+ unsaved_settings = _get_user_settings(page)
+ assert unsaved_settings.get("fontSizePreference", "m") == original_preference
+
+ page.locator("#save-font-size-preference-btn").click()
+ expect(page.locator("#font-size-preference-status")).to_contain_text(
+ "XL is now your saved font size"
+ )
+ page.reload(wait_until="domcontentloaded")
+ expect(page.locator("html")).to_have_attribute("data-font-size", "xl")
+
+ assert _set_user_settings(
+ page,
+ {"fontSizePreference": "m", "navLayout": "top"},
+ )
+ page.set_viewport_size({"width": 720, "height": 450})
+ page.goto(f"{BASE_URL}/chats", wait_until="domcontentloaded")
+ _assert_chat_viewport_is_usable(page)
+
+ assert _set_user_settings(page, {"navLayout": "sidebar"})
+ page.reload(wait_until="domcontentloaded")
+ assert page.locator("body").evaluate(
+ "element => element.classList.contains('sidebar-nav-enabled')"
+ )
+ expect(page.locator("#sidebar-content")).to_be_visible()
+ _assert_chat_viewport_is_usable(page)
+ finally:
+ if original_settings is not None:
+ _set_user_settings(
+ page,
+ {
+ "fontSizePreference": original_settings.get(
+ "fontSizePreference",
+ "m",
+ ),
+ "navLayout": original_settings.get("navLayout", ""),
+ },
+ )
+ context.close()
+ browser.close()