mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 14:44:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
15
ui/src/styles/chat/text.test.ts
Normal file
15
ui/src/styles/chat/text.test.ts
Normal 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);");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 (240–400px)
|
||||
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
||||
borderRadius: number; // Corner roundness (0–100, 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 } : {}),
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -57,6 +57,8 @@ describe("config view", () => {
|
||||
onOpenCustomThemeImport: vi.fn(),
|
||||
borderRadius: 50,
|
||||
setBorderRadius: vi.fn(),
|
||||
textScale: 100,
|
||||
setTextScale: vi.fn(),
|
||||
gatewayUrl: "",
|
||||
assistantName: "OpenClaw",
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user