feat(webchat): Add chat font size setting

Adds a configurable font size option for the webchat interface.

Changes:
- Add chatFontSize setting to UiSettings (small/medium/large/x-large)
- Add --font-size-chat CSS variable in base.css
- Update chat text styles to use the CSS variable
- Add applyChatFontSize function to apply setting to document
- Add font size dropdown control in chat controls UI
- Add styles for the font size control

Resolves #3027
This commit is contained in:
Josh Lewis 2026-01-27 17:34:05 -06:00
parent 0b1c8db0ca
commit 169ff8b8d9
7 changed files with 116 additions and 2 deletions

View File

@ -84,6 +84,7 @@
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace; --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
--font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-body: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-display: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-display: "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-size-chat: 14px; /* Adjustable via UI settings */
/* Shadows - Richer with subtle color */ /* Shadows - Richer with subtle color */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);

View File

@ -325,6 +325,53 @@
color: rgba(16, 24, 40, 0.3); color: rgba(16, 24, 40, 0.3);
} }
/* Font size control */
.chat-controls__font-size {
display: flex;
align-items: center;
gap: 6px;
}
.chat-controls__font-icon {
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.chat-controls__font-icon svg {
width: 16px;
height: 16px;
stroke: currentColor;
fill: none;
stroke-width: 2px;
}
.chat-controls__font-select {
padding: 4px 8px;
font-size: 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.06);
color: var(--text);
cursor: pointer;
min-width: 80px;
}
.chat-controls__font-select:hover {
border-color: var(--border-strong);
}
.chat-controls__font-select:focus {
outline: none;
border-color: var(--accent);
}
:root[data-theme="light"] .chat-controls__font-select {
background: #ffffff;
border-color: var(--border);
}
.btn--icon:hover { .btn--icon:hover {
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2); border-color: rgba(255, 255, 255, 0.2);

View File

@ -19,7 +19,7 @@
} }
.chat-text { .chat-text {
font-size: 14px; font-size: var(--font-size-chat, 14px);
line-height: 1.5; line-height: 1.5;
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;

View File

@ -9,6 +9,7 @@ import { syncUrlWithSessionKey } from "./app-settings";
import type { SessionsListResult } from "./types"; import type { SessionsListResult } from "./types";
import type { ThemeMode } from "./theme"; import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition"; import type { ThemeTransitionContext } from "./theme-transition";
import type { ChatFontSize } from "./storage";
export function renderTab(state: AppViewState, tab: Tab) { export function renderTab(state: AppViewState, tab: Tab) {
const href = pathForTab(tab, state.basePath); const href = pathForTab(tab, state.basePath);
@ -128,6 +129,44 @@ export function renderChatControls(state: AppViewState) {
> >
${focusIcon} ${focusIcon}
</button> </button>
<span class="chat-controls__separator">|</span>
${renderFontSizeControl(state)}
</div>
`;
}
const FONT_SIZE_OPTIONS: Array<{ value: ChatFontSize; label: string }> = [
{ value: "small", label: "Small" },
{ value: "medium", label: "Medium" },
{ value: "large", label: "Large" },
{ value: "x-large", label: "X-Large" },
];
function renderFontSizeControl(state: AppViewState) {
const currentSize = state.settings.chatFontSize ?? "medium";
const textIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"></path><path d="M9 20h6"></path><path d="M12 4v16"></path></svg>`;
return html`
<div class="chat-controls__font-size">
<span class="chat-controls__font-icon" title="Font size">${textIcon}</span>
<select
class="chat-controls__font-select"
.value=${currentSize}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value as ChatFontSize;
state.applySettings({
...state.settings,
chatFontSize: next,
});
}}
title="Chat font size"
>
${FONT_SIZE_OPTIONS.map(
(opt) =>
html`<option value=${opt.value} ?selected=${opt.value === currentSize}>
${opt.label}
</option>`,
)}
</select>
</div> </div>
`; `;
} }

View File

@ -17,6 +17,7 @@ const createHost = (tab: Tab): SettingsHost => ({
theme: "system", theme: "system",
chatFocusMode: false, chatFocusMode: false,
chatShowThinking: true, chatShowThinking: true,
chatFontSize: "medium",
splitRatio: 0.6, splitRatio: 0.6,
navCollapsed: false, navCollapsed: false,
navGroupsCollapsed: {}, navGroupsCollapsed: {},

View File

@ -10,7 +10,7 @@ import { loadPresence } from "./controllers/presence";
import { loadSessions } from "./controllers/sessions"; import { loadSessions } from "./controllers/sessions";
import { loadSkills } from "./controllers/skills"; import { loadSkills } from "./controllers/skills";
import { inferBasePathFromPathname, normalizeBasePath, normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation"; import { inferBasePathFromPathname, normalizeBasePath, normalizePath, pathForTab, tabFromPath, type Tab } from "./navigation";
import { saveSettings, type UiSettings } from "./storage"; import { saveSettings, CHAT_FONT_SIZE_PX, type UiSettings, type ChatFontSize } from "./storage";
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme"; import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition"; import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll"; import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
@ -46,6 +46,7 @@ export function applySettings(host: SettingsHost, next: UiSettings) {
host.theme = next.theme; host.theme = next.theme;
applyResolvedTheme(host, resolveTheme(next.theme)); applyResolvedTheme(host, resolveTheme(next.theme));
} }
applyChatFontSize(normalized.chatFontSize);
host.applySessionKey = host.settings.lastActiveSessionKey; host.applySessionKey = host.settings.lastActiveSessionKey;
} }
@ -191,6 +192,7 @@ export function inferBasePath() {
export function syncThemeWithSettings(host: SettingsHost) { export function syncThemeWithSettings(host: SettingsHost) {
host.theme = host.settings.theme ?? "system"; host.theme = host.settings.theme ?? "system";
applyResolvedTheme(host, resolveTheme(host.theme)); applyResolvedTheme(host, resolveTheme(host.theme));
applyChatFontSize(host.settings.chatFontSize ?? "medium");
} }
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) { export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
@ -201,6 +203,12 @@ export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme)
root.style.colorScheme = resolved; root.style.colorScheme = resolved;
} }
export function applyChatFontSize(size: ChatFontSize) {
if (typeof document === "undefined") return;
const px = CHAT_FONT_SIZE_PX[size] ?? CHAT_FONT_SIZE_PX.medium;
document.documentElement.style.setProperty("--font-size-chat", `${px}px`);
}
export function attachThemeListener(host: SettingsHost) { export function attachThemeListener(host: SettingsHost) {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)"); host.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");

View File

@ -2,6 +2,15 @@ const KEY = "moltbot.control.settings.v1";
import type { ThemeMode } from "./theme"; import type { ThemeMode } from "./theme";
export type ChatFontSize = "small" | "medium" | "large" | "x-large";
export const CHAT_FONT_SIZE_PX: Record<ChatFontSize, number> = {
small: 13,
medium: 14,
large: 16,
"x-large": 18,
};
export type UiSettings = { export type UiSettings = {
gatewayUrl: string; gatewayUrl: string;
token: string; token: string;
@ -10,6 +19,7 @@ export type UiSettings = {
theme: ThemeMode; theme: ThemeMode;
chatFocusMode: boolean; chatFocusMode: boolean;
chatShowThinking: boolean; chatShowThinking: boolean;
chatFontSize: ChatFontSize; // Chat text font size
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6) splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
navCollapsed: boolean; // Collapsible sidebar state navCollapsed: boolean; // Collapsible sidebar state
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
@ -29,6 +39,7 @@ export function loadSettings(): UiSettings {
theme: "system", theme: "system",
chatFocusMode: false, chatFocusMode: false,
chatShowThinking: true, chatShowThinking: true,
chatFontSize: "medium",
splitRatio: 0.6, splitRatio: 0.6,
navCollapsed: false, navCollapsed: false,
navGroupsCollapsed: {}, navGroupsCollapsed: {},
@ -69,6 +80,13 @@ export function loadSettings(): UiSettings {
typeof parsed.chatShowThinking === "boolean" typeof parsed.chatShowThinking === "boolean"
? parsed.chatShowThinking ? parsed.chatShowThinking
: defaults.chatShowThinking, : defaults.chatShowThinking,
chatFontSize:
parsed.chatFontSize === "small" ||
parsed.chatFontSize === "medium" ||
parsed.chatFontSize === "large" ||
parsed.chatFontSize === "x-large"
? parsed.chatFontSize
: defaults.chatFontSize,
splitRatio: splitRatio:
typeof parsed.splitRatio === "number" && typeof parsed.splitRatio === "number" &&
parsed.splitRatio >= 0.4 && parsed.splitRatio >= 0.4 &&