feat(ui): add browser-local Control UI text size setting

Adds a bounded browser-local Control UI text size setting in Appearance and Quick Settings, persists it in UiSettings, and applies CSS text-scale variables across chat text, composer input, sidebars, and tool cards while preserving mobile Safari input zoom safety.

Fixes #8547.
Thanks @BunsDev.
This commit is contained in:
Val Alexander
2026-05-13 19:18:05 -05:00
committed by GitHub
parent 0b55317494
commit 52370c5998
22 changed files with 275 additions and 15 deletions

View File

@@ -117,6 +117,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Control UI: add a browser-local Text size setting in Appearance and Quick Settings, scaling chat and dense UI text while keeping inputs above the mobile Safari focus-zoom threshold. Fixes #8547. Thanks @BunsDev.
- Docs: add a dedicated ds4 provider page with local DeepSeek V4 Flash config, on-demand startup, context sizing, and live verification steps.
- Release validation: add a package-installed Docker user-journey lane that verifies onboarding, mocked model setup, external plugin install/uninstall, ClickClack outbound/inbound messaging, Gateway restart survival, and doctor.
- Release validation: add package-installed Docker lanes for real TTY onboarding, media and memory persistence, published-package upgrade journeys, and local marketplace plugin install/update/uninstall coverage.

View File

@@ -89,6 +89,8 @@ Docs translations are generated for the same non-English locale set, but the doc
The Appearance panel keeps the built-in Claw, Knot, and Dash themes, plus one browser-local tweakcn import slot. To import a theme, open [tweakcn editor](https://tweakcn.com/editor/theme), choose or create a theme, click **Share**, and paste the copied theme link into Appearance. The importer also accepts `https://tweakcn.com/r/themes/<id>` registry URLs, editor URLs like `https://tweakcn.com/editor/theme?theme=amethyst-haze`, relative `/themes/<id>` paths, raw theme IDs, and default theme names such as `amethyst-haze`.
Appearance also includes a browser-local Text size setting. The setting is stored with the rest of Control UI preferences, applies to chat text, composer text, tool cards, and chat sidebars, and keeps text inputs at least 16px so mobile Safari does not auto-zoom on focus.
Imported themes are stored only in the current browser profile. They are not written to gateway config and do not sync across devices. Replacing the imported theme updates the one local slot; clearing it switches the active theme back to Claw if the imported theme was selected.
## What it can do (today)

View File

@@ -85,6 +85,13 @@
"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-display: var(--font-body);
--control-ui-text-scale: 1;
--control-ui-text-xs: calc(11px * var(--control-ui-text-scale));
--control-ui-text-sm: calc(12px * var(--control-ui-text-scale));
--control-ui-text-md: calc(14px * var(--control-ui-text-scale));
--control-ui-text-lg: calc(16px * var(--control-ui-text-scale));
--control-ui-input-text-size: max(16px, calc(14px * var(--control-ui-text-scale)));
--chat-text-size: var(--control-ui-text-md);
/* Shadows - Subtle, layered depth */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25);

View File

@@ -517,7 +517,7 @@
resize: none;
white-space: pre-wrap;
font-family: var(--font-body);
font-size: 14px;
font-size: var(--control-ui-input-text-size);
line-height: 1.45;
}
@@ -597,7 +597,7 @@
border: none;
background: transparent;
color: var(--text);
font-size: 0.92rem;
font-size: var(--control-ui-input-text-size);
font-family: inherit;
line-height: 1.4;
outline: none;
@@ -615,7 +615,7 @@
.agent-chat__stt-interim {
padding: 10px 14px 0;
color: var(--muted);
font-size: 0.82rem;
font-size: var(--control-ui-text-sm);
line-height: 1.35;
overflow-wrap: anywhere;
}

View File

@@ -22,4 +22,12 @@ describe("chat layout styles", () => {
expect(css).toContain("font-size: 20px;");
expect(css).toContain("place-items: center;");
});
it("keeps composer text scale-driven while preserving mobile input zoom safety", () => {
const css = readLayoutCss();
expect(css).toContain("font-size: var(--control-ui-input-text-size);");
expect(css).toContain(".agent-chat__composer-combobox > textarea");
expect(css).toContain(".chat-compose .chat-compose__field textarea");
});
});

View File

@@ -68,7 +68,7 @@
.sidebar-title {
font-weight: 600;
font-size: 14px;
font-size: var(--control-ui-text-md);
}
.sidebar-content {
@@ -78,7 +78,7 @@
}
.sidebar-markdown {
font-size: 14px;
font-size: var(--control-ui-text-md);
line-height: 1.6;
color: var(--text);
}
@@ -123,7 +123,7 @@
.sidebar-markdown-shell__hint {
color: var(--muted);
font-size: 12px;
font-size: var(--control-ui-text-sm);
line-height: 1.45;
}
@@ -158,7 +158,7 @@
}
.sidebar-markdown-reader.sidebar-markdown {
font-size: 14.5px;
font-size: calc(14.5px * var(--control-ui-text-scale));
line-height: 1.72;
}

View File

@@ -9,7 +9,7 @@
border: 1px dashed rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.04);
color: var(--muted);
font-size: 12px;
font-size: var(--control-ui-text-sm);
line-height: 1.4;
}
@@ -19,7 +19,7 @@
}
.chat-text {
font-size: 14px;
font-size: var(--chat-text-size);
line-height: 1.5;
word-wrap: break-word;
overflow-wrap: break-word;

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { readStyleSheet } from "../../../../test/helpers/ui-style-fixtures.js";
function readTextCss(): string {
return readStyleSheet("ui/src/styles/chat/text.css");
}
describe("chat text styles", () => {
it("uses browser-local text scale variables for message text", () => {
const css = readTextCss();
expect(css).toContain("font-size: var(--chat-text-size);");
expect(css).toContain("font-size: var(--control-ui-text-sm);");
});
});

View File

@@ -64,7 +64,7 @@
align-items: center;
gap: 6px;
font-weight: 600;
font-size: 14px;
font-size: var(--control-ui-text-md);
line-height: 1.2;
min-width: 0;
}
@@ -157,12 +157,12 @@
}
.chat-tool-card__status-text {
font-size: 11px;
font-size: var(--control-ui-text-xs);
margin-top: 10px;
}
.chat-tool-card__detail {
font-size: 12px;
font-size: var(--control-ui-text-sm);
color: var(--muted);
margin-top: 6px;
}

View File

@@ -801,6 +801,62 @@
color: var(--accent);
}
.settings-text-scale {
display: grid;
gap: 8px;
}
.settings-text-scale__options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
gap: 8px;
}
.settings-text-scale__btn {
display: grid;
gap: 4px;
justify-items: start;
min-height: 58px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
color: var(--text);
cursor: pointer;
text-align: left;
transition:
border-color var(--duration-fast) ease,
background var(--duration-fast) ease,
transform var(--duration-fast) ease;
}
.settings-text-scale__btn:hover {
border-color: var(--border-strong);
background: var(--bg-elevated);
transform: translateY(-1px);
}
.settings-text-scale__btn.active {
border-color: var(--accent);
background: var(--accent-subtle);
}
.settings-text-scale__sample {
font-weight: 650;
font-size: 13px;
line-height: 1.2;
}
.settings-text-scale__label {
color: var(--muted);
font-size: 11px;
line-height: 1.2;
}
.settings-text-scale__btn.active .settings-text-scale__label {
color: var(--accent);
}
.settings-info-grid {
display: grid;
gap: 10px;

View File

@@ -52,6 +52,7 @@ function createState(overrides: Partial<AppViewState> = {}): AppViewState {
navCollapsed: false,
navGroupsCollapsed: {},
borderRadius: 50,
textScale: 100,
chatFocusMode: false,
chatShowThinking: false,
chatShowToolCalls: true,
@@ -200,6 +201,7 @@ function createState(overrides: Partial<AppViewState> = {}): AppViewState {
importCustomTheme: vi.fn(),
clearCustomTheme: vi.fn(),
setBorderRadius: vi.fn(),
setTextScale: vi.fn(),
applySettings: vi.fn(),
applyLocalUserIdentity: vi.fn(),
loadOverview: vi.fn(),

View File

@@ -984,6 +984,8 @@ export function renderApp(state: AppViewState) {
onClearCustomTheme: () => state.clearCustomTheme(),
borderRadius: state.settings.borderRadius,
setBorderRadius: (value) => state.setBorderRadius(value),
textScale: state.settings.textScale ?? 100,
setTextScale: (value) => state.setTextScale(value),
gatewayUrl: state.settings.gatewayUrl,
assistantName: state.assistantName,
configPath: state.configSnapshot?.path ?? null,
@@ -1142,6 +1144,7 @@ export function renderApp(state: AppViewState) {
hasCustomTheme: Boolean(state.settings.customTheme),
customThemeLabel: state.settings.customTheme?.label ?? null,
borderRadius: state.settings.borderRadius,
textScale: state.settings.textScale ?? 100,
setTheme: (theme, context) => state.setTheme(theme, context),
onOpenCustomThemeImport: () => {
state.setTab("appearance");
@@ -1154,6 +1157,7 @@ export function renderApp(state: AppViewState) {
},
setThemeMode: (mode, context) => state.setThemeMode(mode, context),
setBorderRadius: (value) => state.setBorderRadius(value),
setTextScale: (value) => state.setTextScale(value),
userAvatar: state.userAvatar ?? null,
onUserAvatarChange: (avatar) => state.applyLocalUserIdentity?.({ avatar }),
assistantAvatar: configAssistantAvatar,

View File

@@ -46,6 +46,7 @@ type SettingsHost = {
navWidth: number;
navGroupsCollapsed: Record<string, boolean>;
borderRadius: number;
textScale?: import("./storage.ts").TextScaleStop;
customTheme?: import("./custom-theme.ts").ImportedCustomTheme;
};
theme: ThemeName & ThemeMode;
@@ -144,6 +145,7 @@ const createHost = (tab: Tab): SettingsHost => ({
navWidth: 220,
navGroupsCollapsed: {},
borderRadius: 50,
textScale: 100,
},
theme: "claw" as unknown as ThemeName & ThemeMode,
themeMode: "system",
@@ -292,6 +294,18 @@ describe("setTabFromRoute", () => {
expect(host.themeResolved).toBe("openknot-light");
});
it("applies normalized browser-local text scale", () => {
const host = createHost("chat");
applySettings(host, {
...host.settings,
textScale: 125,
});
expect(host.settings.textScale).toBe(125);
expect(document.documentElement.style.getPropertyValue("--control-ui-text-scale")).toBe("1.25");
});
it("syncs both theme family and mode from persisted settings", () => {
const host = createHost("chat");
host.settings.theme = "dash";

View File

@@ -65,6 +65,7 @@ import {
type Tab,
} from "./navigation.ts";
import {
normalizeTextScale,
saveLocalUserIdentity,
saveSettings,
type LocalUserIdentity,
@@ -152,6 +153,7 @@ type SettingsAppHost = SettingsHost &
export function applySettings(host: SettingsHost, next: UiSettings) {
const normalized = {
...next,
textScale: normalizeTextScale(next.textScale),
lastActiveSessionKey:
normalizeOptionalString(next.lastActiveSessionKey) ??
normalizeOptionalString(next.sessionKey) ??
@@ -165,7 +167,8 @@ export function applySettings(host: SettingsHost, next: UiSettings) {
host.themeMode = next.themeMode;
applyResolvedTheme(host, resolveTheme(next.theme, next.themeMode));
}
applyBorderRadius(next.borderRadius);
applyBorderRadius(normalized.borderRadius);
applyTextScale(normalized.textScale);
host.applySessionKey = host.settings.lastActiveSessionKey;
}
@@ -450,6 +453,7 @@ export function syncThemeWithSettings(host: SettingsHost) {
}
applyResolvedTheme(host, resolveTheme(host.theme, host.themeMode));
applyBorderRadius(host.settings.borderRadius ?? 50);
applyTextScale(host.settings.textScale);
syncSystemThemeListener(host);
}
@@ -474,6 +478,15 @@ export function applyBorderRadius(value: number) {
root.style.setProperty("--radius", `${Math.round(BASE_RADII.default * scale)}px`);
}
export function applyTextScale(value: unknown) {
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
const scale = normalizeTextScale(value) / 100;
root.style.setProperty("--control-ui-text-scale", scale.toFixed(2));
}
export function applyResolvedTheme(host: SettingsHost, resolved: ResolvedTheme) {
host.themeResolved = resolved;
if (typeof document === "undefined") {

View File

@@ -439,6 +439,7 @@ export type AppViewState = {
importCustomTheme: () => Promise<void>;
clearCustomTheme: () => void;
setBorderRadius: (value: number) => void;
setTextScale: (value: number) => void;
applySettings: (next: UiSettings) => void;
applyLocalUserIdentity?: (next: { name?: string | null; avatar?: string | null }) => void;
loadOverview: (opts?: { refresh?: boolean }) => Promise<void>;

View File

@@ -890,6 +890,14 @@ export class OpenClawApp extends LitElement {
this.requestUpdate();
}
setTextScale(value: number) {
applySettingsInternal(this as unknown as Parameters<typeof applySettingsInternal>[0], {
...this.settings,
textScale: value as typeof this.settings.textScale,
});
this.requestUpdate();
}
announceSessionSwitch(sessionKey: string, label: string) {
const id = ++this.sessionSwitchNoticeSeq;
if (this.sessionSwitchNoticeTimer !== null) {

View File

@@ -196,6 +196,7 @@ describe("loadSettings default gateway URL derivation", () => {
navWidth: 220,
navGroupsCollapsed: {},
borderRadius: 50,
textScale: 100,
sessionsByGateway: {
"wss://gateway.example:8443/openclaw": {
sessionKey: "agent",
@@ -229,6 +230,7 @@ describe("loadSettings default gateway URL derivation", () => {
navWidth: 220,
navGroupsCollapsed: {},
borderRadius: 50,
textScale: 100,
});
const settings = loadSettings();
@@ -325,6 +327,7 @@ describe("loadSettings default gateway URL derivation", () => {
navWidth: 220,
navGroupsCollapsed: {},
borderRadius: 50,
textScale: 100,
sessionsByGateway: {
[gwUrl]: {
sessionKey: "main",
@@ -335,6 +338,25 @@ describe("loadSettings default gateway URL derivation", () => {
expect(sessionStorage.length).toBe(1);
});
it("normalizes persisted text scale to the nearest supported stop", () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
pathname: "/",
});
const gwUrl = expectedGatewayUrl("");
localStorage.setItem(
`openclaw.control.settings.v1:${gwUrl}`,
JSON.stringify({
gatewayUrl: gwUrl,
textScale: 123,
}),
);
expect(loadSettings().textScale).toBe(125);
});
it("clears the current-tab token when saving an empty token", () => {
setTestLocation({
protocol: "https:",

View File

@@ -37,6 +37,9 @@ import {
export const BORDER_RADIUS_STOPS = [0, 25, 50, 75, 100] as const;
export type BorderRadiusStop = (typeof BORDER_RADIUS_STOPS)[number];
export const TEXT_SCALE_STOPS = [90, 100, 110, 125, 140] as const;
export type TextScaleStop = (typeof TEXT_SCALE_STOPS)[number];
function snapBorderRadius(value: number): BorderRadiusStop {
let best: BorderRadiusStop = BORDER_RADIUS_STOPS[0];
let bestDist = Math.abs(value - best);
@@ -50,6 +53,22 @@ function snapBorderRadius(value: number): BorderRadiusStop {
return best;
}
export function normalizeTextScale(value: unknown, fallback: TextScaleStop = 100): TextScaleStop {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
let best: TextScaleStop = TEXT_SCALE_STOPS[0];
let bestDist = Math.abs(value - best);
for (const stop of TEXT_SCALE_STOPS) {
const dist = Math.abs(value - stop);
if (dist < bestDist) {
best = stop;
bestDist = dist;
}
}
return best;
}
export type UiSettings = {
gatewayUrl: string;
token: string;
@@ -65,6 +84,7 @@ export type UiSettings = {
navWidth: number; // Sidebar width when expanded (240400px)
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
borderRadius: number; // Corner roundness (0100, default 50)
textScale?: TextScaleStop; // Browser-local text scale percentage
customTheme?: ImportedCustomTheme;
locale?: string;
};
@@ -206,6 +226,7 @@ export function loadSettings(): UiSettings {
navWidth: 220,
navGroupsCollapsed: {},
borderRadius: 50,
textScale: 100,
};
try {
@@ -267,6 +288,7 @@ export function loadSettings(): UiSettings {
parsed.borderRadius <= 100
? snapBorderRadius(parsed.borderRadius)
: defaults.borderRadius,
textScale: normalizeTextScale(parsed.textScale, defaults.textScale),
customTheme: customTheme ?? undefined,
locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined,
};
@@ -386,6 +408,7 @@ function persistSettings(next: UiSettings) {
navWidth: next.navWidth,
navGroupsCollapsed: next.navGroupsCollapsed,
borderRadius: next.borderRadius,
textScale: normalizeTextScale(next.textScale),
...(next.customTheme ? { customTheme: next.customTheme } : {}),
sessionsByGateway,
...(next.locale ? { locale: next.locale } : {}),

View File

@@ -64,10 +64,12 @@ function createProps(overrides: Partial<QuickSettingsProps> = {}): QuickSettings
hasCustomTheme: false,
customThemeLabel: null,
borderRadius: 50,
textScale: 100,
setTheme: vi.fn(),
onOpenCustomThemeImport: vi.fn(),
setThemeMode: vi.fn(),
setBorderRadius: vi.fn(),
setTextScale: vi.fn(),
userAvatar: null,
onUserAvatarChange: vi.fn(),
configObject: {},
@@ -172,6 +174,22 @@ describe("renderQuickSettings", () => {
]);
});
it("lets operators change text size from Appearance quick settings", () => {
const setTextScale = vi.fn();
const container = document.createElement("div");
render(renderQuickSettings(createProps({ textScale: 125, setTextScale })), container);
const textSizeRow = expectRowByLabel(container, "Text size");
const active = Array.from(textSizeRow.querySelectorAll("button")).find((button) =>
button.classList.contains("qs-segmented__btn--active"),
);
expect(active?.textContent?.trim()).toBe("XL");
expectButtonByText(textSizeRow, "XXL").click();
expect(setTextScale).toHaveBeenCalledWith(140);
});
it("keeps the local user name fixed and shows the assistant identity", () => {
const container = document.createElement("div");

View File

@@ -7,7 +7,7 @@
import { html, nothing, type TemplateResult } from "lit";
import { icons } from "../icons.ts";
import type { BorderRadiusStop } from "../storage.ts";
import type { BorderRadiusStop, TextScaleStop } from "../storage.ts";
import { normalizeOptionalString } from "../string-coerce.ts";
import type { ThemeTransitionContext } from "../theme-transition.ts";
import type { ThemeMode, ThemeName } from "../theme.ts";
@@ -82,10 +82,12 @@ export type QuickSettingsProps = {
hasCustomTheme: boolean;
customThemeLabel?: string | null;
borderRadius: number;
textScale: number;
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
onOpenCustomThemeImport?: () => void;
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
setBorderRadius: (value: number) => void;
setTextScale: (value: number) => void;
userAvatar?: string | null;
onUserAvatarChange?: (next: string | null) => void;
@@ -139,6 +141,14 @@ const BORDER_RADIUS_STOPS: Array<{ value: BorderRadiusStop; label: string }> = [
{ value: 100, label: "Full" },
];
const TEXT_SCALE_OPTIONS: Array<{ value: TextScaleStop; label: string }> = [
{ value: 90, label: "S" },
{ value: 100, label: "M" },
{ value: 110, label: "L" },
{ value: 125, label: "XL" },
{ value: 140, label: "XXL" },
];
const THINKING_LEVELS = ["off", "low", "medium", "high"];
const TOOL_PROFILES = ["minimal", "coding", "messaging", "full"];
const LOCAL_USER_LABEL = "You";
@@ -658,6 +668,25 @@ function renderAppearanceCard(props: QuickSettingsProps) {
)}
</div>
</div>
<div class="qs-row">
<span class="qs-row__label">Text size</span>
<div class="qs-segmented">
${TEXT_SCALE_OPTIONS.map(
(stop) => html`
<button
class="qs-segmented__btn qs-segmented__btn--compact ${stop.value ===
props.textScale
? "qs-segmented__btn--active"
: ""}"
title=${`${stop.value}%`}
@click=${() => props.setTextScale(stop.value)}
>
${stop.label}
</button>
`,
)}
</div>
</div>
</div>
</div>
`;

View File

@@ -57,6 +57,8 @@ describe("config view", () => {
onOpenCustomThemeImport: vi.fn(),
borderRadius: 50,
setBorderRadius: vi.fn(),
textScale: 100,
setTextScale: vi.fn(),
gatewayUrl: "",
assistantName: "OpenClaw",
});

View File

@@ -2,7 +2,12 @@ import JSON5 from "json5";
import { html, nothing, type TemplateResult } from "lit";
import { t } from "../../i18n/index.ts";
import { icons } from "../icons.ts";
import { BORDER_RADIUS_STOPS, type BorderRadiusStop } from "../storage.ts";
import {
BORDER_RADIUS_STOPS,
TEXT_SCALE_STOPS,
type BorderRadiusStop,
type TextScaleStop,
} from "../storage.ts";
import type { ThemeTransitionContext } from "../theme-transition.ts";
import type { ThemeMode, ThemeName } from "../theme.ts";
import type { ConfigUiHints } from "../types.ts";
@@ -26,6 +31,14 @@ const BORDER_RADIUS_LABELS: Record<BorderRadiusStop, string> = {
100: "Full",
};
const TEXT_SCALE_LABELS: Record<TextScaleStop, string> = {
90: "Small",
100: "Default",
110: "Large",
125: "XL",
140: "XXL",
};
export type WebPushUiState = {
supported: boolean;
permission: NotificationPermission | "unsupported";
@@ -85,6 +98,8 @@ export type ConfigProps = {
onOpenCustomThemeImport?: () => void;
borderRadius: number;
setBorderRadius: (value: number) => void;
textScale: number;
setTextScale: (value: number) => void;
gatewayUrl: string;
assistantName: string;
configPath?: string | null;
@@ -1062,6 +1077,26 @@ function renderAppearanceSection(props: ConfigProps) {
</div>
</div>
<div class="settings-appearance__section">
<h3 class="settings-appearance__heading">Text size</h3>
<div class="settings-text-scale">
<div class="settings-text-scale__options">
${TEXT_SCALE_STOPS.map(
(stop) => html`
<button
type="button"
class="settings-text-scale__btn ${stop === props.textScale ? "active" : ""}"
@click=${() => props.setTextScale(stop)}
>
<span class="settings-text-scale__sample">${TEXT_SCALE_LABELS[stop]}</span>
<span class="settings-text-scale__label">${stop}%</span>
</button>
`,
)}
</div>
</div>
</div>
<div class="settings-appearance__section">
<h3 class="settings-appearance__heading">Connection</h3>
<div class="settings-info-grid">