From 45494cfa5437a313918dee72e6a791a50cd5be41 Mon Sep 17 00:00:00 2001 From: wade19990814-hue <280641290+wade19990814-hue@users.noreply.github.com> Date: Fri, 5 Jun 2026 01:10:08 +0800 Subject: [PATCH] feat(desktop): add text size setting --- .../frontend/src/components/SettingsPanel.tsx | 38 +++++++++ desktop/frontend/src/lib/textSize.ts | 32 ++++++++ desktop/frontend/src/locales/en.ts | 7 +- desktop/frontend/src/locales/zh.ts | 7 +- desktop/frontend/src/main.tsx | 2 + desktop/frontend/src/styles.css | 79 +++++++++++++------ 6 files changed, 140 insertions(+), 25 deletions(-) create mode 100644 desktop/frontend/src/lib/textSize.ts diff --git a/desktop/frontend/src/components/SettingsPanel.tsx b/desktop/frontend/src/components/SettingsPanel.tsx index f38c1a8ba..fba73a7d2 100644 --- a/desktop/frontend/src/components/SettingsPanel.tsx +++ b/desktop/frontend/src/components/SettingsPanel.tsx @@ -16,6 +16,7 @@ import { type Theme, type ThemeStyle, } from "../lib/theme"; +import { TEXT_SIZES, applyTextSize, getTextSize, type TextSize } from "../lib/textSize"; import type { NetworkView, ProviderView, SettingsView } from "../lib/types"; import { InlineConfirmButton } from "./InlineConfirmButton"; import { ResizableDrawer } from "./ResizableDrawer"; @@ -36,6 +37,7 @@ export function SettingsPanel({ onClose, onChanged }: { onClose: () => void; onC const [err, setErr] = useState(null); const [theme, setThemeState] = useState(getTheme()); const [themeStyle, setThemeStyleState] = useState(() => getThemeStyle(getTheme())); + const [textSize, setTextSizeState] = useState(getTextSize()); const [tab, setTab] = useState("general"); const reload = async () => setS(normalizeSettingsView(await app.Settings().catch(() => null))); @@ -106,6 +108,7 @@ export function SettingsPanel({ onClose, onChanged }: { onClose: () => void; onC { const nextStyle = themeForStyle(themeStyle) === getResolvedTheme(t) ? themeStyle : defaultStyleForTheme(t); applyTheme(t, nextStyle, { persist: false }); @@ -120,6 +123,10 @@ export function SettingsPanel({ onClose, onChanged }: { onClose: () => void; onC setThemeStyleState(style); void apply(() => app.SetDesktopAppearance(nextTheme, style)); }} + onTextSize={(size) => { + applyTextSize(size); + setTextSizeState(size); + }} /> )} {tab === "updates" && } @@ -984,13 +991,17 @@ function SandboxSection({ s, busy, apply }: SectionProps) { function AppearanceSection({ theme, themeStyle, + textSize, onTheme, onThemeStyle, + onTextSize, }: { theme: Theme; themeStyle: ThemeStyle; + textSize: TextSize; onTheme: (t: Theme) => void; onThemeStyle: (style: ThemeStyle) => void; + onTextSize: (size: TextSize) => void; }) { const t = useT(); const themeOptions: Theme[] = ["auto", "light", "dark"]; @@ -1026,6 +1037,20 @@ function AppearanceSection({ ))} +
+ +
+ {TEXT_SIZES.map((size) => ( + + ))} +
+
); } @@ -1041,6 +1066,19 @@ function themeName(theme: Theme, t: ReturnType): string { } } +function textSizeName(size: TextSize, t: ReturnType): string { + switch (size) { + case "small": + return t("settings.textSizeSmall"); + case "default": + return t("settings.textSizeDefault"); + case "large": + return t("settings.textSizeLarge"); + case "xlarge": + return t("settings.textSizeXLarge"); + } +} + const MB = 1024 * 1024; const mb = (n: number) => (n / MB).toFixed(1); diff --git a/desktop/frontend/src/lib/textSize.ts b/desktop/frontend/src/lib/textSize.ts new file mode 100644 index 000000000..6d350d9f2 --- /dev/null +++ b/desktop/frontend/src/lib/textSize.ts @@ -0,0 +1,32 @@ +export const TEXT_SIZES = ["small", "default", "large", "xlarge"] as const; + +export type TextSize = (typeof TEXT_SIZES)[number]; + +export const DEFAULT_TEXT_SIZE: TextSize = "default"; + +const TEXT_SIZE_KEY = "reasonix-text-size"; + +export function isTextSize(value: unknown): value is TextSize { + return typeof value === "string" && (TEXT_SIZES as readonly string[]).includes(value); +} + +export function getTextSize(): TextSize { + const stored = typeof localStorage !== "undefined" ? localStorage.getItem(TEXT_SIZE_KEY) : null; + return isTextSize(stored) ? stored : DEFAULT_TEXT_SIZE; +} + +export function applyTextSize(size: TextSize): void { + if (typeof document === "undefined") return; + const root = document.documentElement; + if (size === DEFAULT_TEXT_SIZE) root.removeAttribute("data-text-size"); + else root.setAttribute("data-text-size", size); + try { + localStorage.setItem(TEXT_SIZE_KEY, size); + } catch { + /* private mode / no storage - the in-DOM attribute still applies */ + } +} + +export function initTextSize(): void { + applyTextSize(getTextSize()); +} diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index 367425d83..92c7393b0 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -417,7 +417,7 @@ export const en = { "settings.tab.appearance": "Appearance", "settings.tab.updates": "Updates", "settings.providerCount": "{n} providers", - "settings.appearanceMeta": "theme · accent", + "settings.appearanceMeta": "theme · accent · text", "settings.updatesMeta": "version · config", "settings.closeBehavior": "When closing window", "settings.closeBehavior.background": "Keep running", @@ -503,6 +503,11 @@ export const en = { "settings.themeCurrent": "Theme: {theme} / {style}", "settings.themeChanged": "Theme changed to {theme} / {style}", "settings.themeUnknown": "Unknown theme: {name}", + "settings.textSize": "Text size", + "settings.textSizeSmall": "Small", + "settings.textSizeDefault": "Default", + "settings.textSizeLarge": "Large", + "settings.textSizeXLarge": "Extra large", "settings.language": "Language", "settings.langAuto": "Auto (system)", "settings.config": "config: {path}", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index 4c2cf24a1..329512964 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -418,7 +418,7 @@ export const zh: Record = { "settings.tab.appearance": "外观", "settings.tab.updates": "更新", "settings.providerCount": "{n} 个服务", - "settings.appearanceMeta": "主题 · 强调色", + "settings.appearanceMeta": "主题 · 强调色 · 字号", "settings.updatesMeta": "版本 · 配置", "settings.closeBehavior": "关闭窗口时", "settings.closeBehavior.background": "保持后台运行", @@ -504,6 +504,11 @@ export const zh: Record = { "settings.themeCurrent": "当前主题:{theme} / {style}", "settings.themeChanged": "已切换主题为 {theme} / {style}", "settings.themeUnknown": "未知主题:{name}", + "settings.textSize": "字号", + "settings.textSizeSmall": "小", + "settings.textSizeDefault": "默认", + "settings.textSizeLarge": "大", + "settings.textSizeXLarge": "特大", "settings.language": "语言", "settings.langAuto": "自动(跟随系统)", "settings.config": "配置文件:{path}", diff --git a/desktop/frontend/src/main.tsx b/desktop/frontend/src/main.tsx index 344e148f3..59d2617f7 100644 --- a/desktop/frontend/src/main.tsx +++ b/desktop/frontend/src/main.tsx @@ -4,11 +4,13 @@ import App from "./App"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { installGlobalCrashHandlers } from "./lib/crash"; import { LocaleProvider } from "./lib/i18n"; +import { initTextSize } from "./lib/textSize"; import { initTheme } from "./lib/theme"; import "./styles.css"; // Apply the saved appearance (auto/light/dark) before the first paint. initTheme(); +initTextSize(); // Pre-warm font fallback stacks so the first frame doesn't flicker between the // browser default font and the app's configured typeface. Inserting a hidden span diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 1afc8882d..6735c4f1d 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -34,6 +34,19 @@ --fg: #f4f5f7; --fg-dim: #c0c4cc; --fg-faint: #858b96; + --font-scale: 1; + --text-2xs: calc(10px * var(--font-scale)); + --text-xs: calc(11px * var(--font-scale)); + --text-sm: calc(12px * var(--font-scale)); + --text-md: calc(13px * var(--font-scale)); + --text-base: calc(14px * var(--font-scale)); + --text-lg: calc(15px * var(--font-scale)); + --text-xl: calc(18px * var(--font-scale)); + --font-caption: var(--text-xs); + --font-code: var(--text-sm); + --font-content: var(--text-base); + --font-control: var(--text-md); + --font-control-small: var(--text-sm); --scrollbar-size: 10px; --scrollbar-track: transparent; --scrollbar-thumb: color-mix(in srgb, var(--fg-faint) 42%, transparent); @@ -284,6 +297,18 @@ --accent-strong: #276a8f; } +:root[data-text-size="small"] { + --font-scale: 0.94; +} + +:root[data-text-size="large"] { + --font-scale: 1.08; +} + +:root[data-text-size="xlarge"] { + --font-scale: 1.16; +} + * { box-sizing: border-box; } @@ -300,12 +325,19 @@ body { background: var(--bg); color: var(--fg); font-family: var(--sans); - font-size: 14px; + font-size: var(--text-base); line-height: 1.6; -webkit-font-smoothing: antialiased; overflow: hidden; } +button, +input, +select, +textarea { + font-family: inherit; +} + /* Only real hyperlinks get the pointer cursor; everything else (buttons, tabs, tree rows, menu items) stays default so the UI reads as native chrome. */ a[href] { @@ -633,7 +665,7 @@ a[href] { background: var(--button-bg); color: var(--button-muted-fg); font: inherit; - font-size: 12px; + font-size: var(--font-control-small); font-weight: 500; line-height: 1.25; border-radius: var(--button-radius); @@ -666,7 +698,7 @@ a[href] { .banner { flex: 0 0 auto; padding: 8px 16px; - font-size: 12.5px; + font-size: var(--font-control-small); } .banner--error { background: var(--del-bg); @@ -686,7 +718,7 @@ a[href] { } .banner__hint { color: var(--fg-dim); - font-size: 11.5px; + font-size: var(--font-caption); } .banner__spacer { flex: 1 1 auto; @@ -1160,7 +1192,7 @@ a[href] { padding: 8px 12px; border-left: 2px solid var(--border); color: var(--fg-dim); - font-size: 13px; + font-size: var(--font-content); white-space: pre-wrap; } @@ -1255,7 +1287,7 @@ a[href] { .md table { border-collapse: collapse; margin: 0 0 12px; - font-size: 13px; + font-size: var(--font-content); display: block; overflow-x: auto; } @@ -1278,7 +1310,7 @@ a[href] { border: 1px solid var(--border-soft); border-radius: 8px; font-family: var(--mono); - font-size: 12.5px; + font-size: var(--font-code); line-height: 1.55; overflow: auto; white-space: pre; @@ -1446,14 +1478,14 @@ a[href] { } .tool__name { font-family: var(--mono); - font-size: 12.5px; + font-size: var(--font-code); font-weight: 600; color: var(--accent); flex-shrink: 0; } .tool__subject { font-family: var(--mono); - font-size: 12px; + font-size: var(--font-code); color: var(--fg-dim); overflow: hidden; text-overflow: ellipsis; @@ -1471,7 +1503,7 @@ a[href] { padding: 0 11px 10px 30px; } .tool__note { - font-size: 11px; + font-size: var(--font-caption); color: var(--fg-faint); margin-top: 3px; } @@ -1797,6 +1829,7 @@ a[href] { background: none; color: var(--fg); font: inherit; + font-size: var(--font-content); line-height: 1.55; max-height: 200px; outline: none; @@ -2512,7 +2545,7 @@ a[href] { background: var(--button-bg); color: var(--button-fg); font: inherit; - font-size: 13px; + font-size: var(--font-control); font-weight: 500; line-height: 1.25; border-radius: var(--button-radius); @@ -3944,7 +3977,7 @@ a[href] { border-radius: 7px; color: var(--fg); font: inherit; - font-size: 13px; + font-size: var(--font-control); padding: 6px 8px; } .mem-select:focus, @@ -3962,7 +3995,7 @@ a[href] { } .mem-hint { margin-top: 6px; - font-size: 11px; + font-size: var(--font-caption); color: var(--fg-faint); font-family: var(--mono); word-break: break-all; @@ -4224,11 +4257,11 @@ a[href] { .btn--small { height: var(--button-small-height); padding: 0 10px; - font-size: 12px; + font-size: var(--font-control-small); } .badge { flex-shrink: 0; - font-size: 10px; + font-size: var(--text-2xs); text-transform: uppercase; letter-spacing: 0.04em; padding: 2px 7px; @@ -5385,12 +5418,12 @@ a[href] { white-space: nowrap; } .settings-nav__item span { - font-size: 13px; + font-size: var(--font-control); font-weight: 650; } .settings-nav__item small { color: var(--fg-dim); - font-size: 10.5px; + font-size: var(--text-2xs); font-family: var(--mono); } .settings-content { @@ -5443,7 +5476,7 @@ a[href] { } .settings-model-card span { color: var(--fg-dim); - font-size: 11px; + font-size: var(--font-caption); margin-bottom: 5px; } .settings-model-card strong { @@ -5452,7 +5485,7 @@ a[href] { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 14px; + font-size: var(--text-base); font-weight: 700; color: var(--fg); } @@ -5460,7 +5493,7 @@ a[href] { margin-top: 4px; color: var(--fg-dim); font-family: var(--mono); - font-size: 10.5px; + font-size: var(--text-2xs); } .settings-summary { border: 1px solid var(--border); @@ -5493,7 +5526,7 @@ a[href] { margin-bottom: 0; } .set-label { - font-size: 12px; + font-size: var(--font-control-small); color: var(--fg); font-weight: 550; min-width: 92px; @@ -5512,7 +5545,7 @@ a[href] { display: flex; align-items: center; gap: 8px; - font-size: 12px; + font-size: var(--font-control-small); color: var(--fg-dim); margin-bottom: 10px; } @@ -5645,7 +5678,7 @@ a[href] { background: transparent; color: var(--fg-dim); font: inherit; - font-size: 12px; + font-size: var(--font-control-small); padding: 5px 13px; text-transform: capitalize; }