Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/chat-and-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@

#### 1.2.x 字体与字号三槽

每槽独立保存字体族与字号,配置为 `config.ui.{font_sans, font_content, font_mono, font_size_sans, font_size_content, font_size_mono}`,WebView 的 `localStorage` 只作首帧防闪缓存。设置页按「**界面字体 → 界面字号 → 内容字体 → 内容字号 → 代码字体 → 代码字号**」排列,字号 Select 与字体输入同宽,每项带独立预览和还原默认入口。字号为连续 **12–20px** 整数;默认及还原值是 **14 / 14 / 12px**。已有合法显式值保留,新增内容字段缺失时默认 14px;还原只清除本槽缓存覆盖并同步本槽默认值。
每槽独立保存字体族与字号,配置为 `config.ui.{font_sans, font_content, font_mono, font_size_sans, font_size_content, font_size_mono}`,WebView 的 `localStorage` 只作首帧防闪缓存。设置页按「**界面 → 内容 → 代码**」三行排列,每行左侧只标槽名(定宽窄列,与右侧控件垂直居中)、行内并排放字体族输入与定宽字号 Select、**行末一个重置按钮**,下方一行预览。三个控件等高——字号 Select 不挂 `size="small"`(会比 Input 矮一截、下边线对不齐),重置也不再藏在两个控件的 `suffix` / `suffixIcon` 里。字号为连续 **12–20px** 整数;默认及还原值是 **14 / 14 / 12px**。已有合法显式值保留,新增内容字段缺失时默认 14px;还原只清除本槽缓存覆盖并同步本槽默认值。

行末重置**一按清两样**(字体族清空 + 字号回默认档),因为两者同属一槽、分开两个按钮只会让人猜哪个管哪个;实现上顺序调用 `commitFontSlot` 与 `commitFontSizeSlot`(两者都基于 `readStoredFonts()` 叠加、返回全量六字段),但**只提交最终结果一次**,避免在串行提交队列里连发两轮。

合并成三行是因为同槽字体族与字号原本各带一个预览行、而两者样本完全相同(同字体族 + 同字号),属纯重复。**两个控件的锚点仍各自独立**(`ui.font_<slot>` / `ui.font_size_<slot>`),设置搜索能分别定位。公共说明只在「字体」分段标题右侧说一次(`settings.fontHint`),不再逐项重复:字号范围、槽间独立性、默认值与生效方式都由控件本身、当前选中值、还原按钮和预览行体现;唯一无法自明的是输入格式(逗号分隔多个、留空用默认),保留在这行说明里——注意是**即时生效**(回车 / 失焦 / 停手约 600ms / 卸载四条路径都会提交),不是「需按回车」。

界面槽覆盖标题栏、导航、设置、输入控件、工具标题、状态和元数据;内容槽覆盖用户/助手正文,以及子代理、计划、技能、发布说明、Markdown 与纯文本预览中的正文。代码槽覆盖行内代码、代码块、源码预览、diff 与日志,包括内容中的代码。按钮(含正文内复制按钮)仍取界面槽。正文、代码基础字号直接取对应槽;标题和辅助文字保留相对层级,界面/内容偏移基准各为 14px、代码为 12px。三槽不互相继承已选字体或字号,不添加统一 +1/导航 +2 补偿;图标、布局尺寸、图片、PDF 与文档自身排版不随字体设置整体缩放。

Expand Down
2 changes: 1 addition & 1 deletion docs/settings-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
- `SETTINGS_VISUAL` 是设置区字号、密度和尺寸入口:内容最大宽度 960px,窄/中/宽控件基准 180/240/360px。使用 `SettingsThemeProvider` 的局部 antd `ConfigProvider` / `App`;Portal 弹框通过 `SettingsThemeScope` 继承公共样式与变量。
- `SettingsForm`、`SettingsFormItem`、`SettingsSection` 统一左右式行与带边框分组:标题/说明在左,控件在右,校验反馈靠近控件,关闭自动冒号。内容容器宽度不超过 680px 时变成上下排列;MCP 编辑器继续标签在上,窄键值行可堆叠。
- 宽度类 `.w-narrow` / `.w-mid` / `.w-wide` 都有 `max-width:100%`;局部页体适配由共享 CSS 收口。不要在页体增加像素内联宽度,或重新引入旧“内容只占右栏 50%”/所有表单强制 vertical 的规则。
- 字体使用 `--ws-font-sans` / `--ws-font-content` / `--ws-font-mono`,颜色走 `--ws-*` 与 antd token,不新增硬编码字体链或彩色强调色。外观页按语言、主题、字体排列;主题使用预览卡,三组分别为界面、内容、代码,默认字号 14/14/12px,各带字体与字号预览和还原入口;作用范围见[界面指南](./chat-and-interface.md#12x-字体与字号三槽)。
- 字体使用 `--ws-font-sans` / `--ws-font-content` / `--ws-font-mono`,颜色走 `--ws-*` 与 antd token,不新增硬编码字体链或彩色强调色。外观页按语言、主题、字体排列;主题使用预览卡。字体分组三行(界面 / 内容 / 代码),每行左侧只标槽名、与右侧控件垂直居中且取**定宽窄列**(`SETTINGS_VISUAL.fontSlotLabelWidth`,不再是三档宽度档或半行宽),行内并排字体名输入与定宽字号 Select(`fontSizeSelectWidth`)、行末一个 `fontResetWidth` 宽的重置按钮(一按清本槽字体族与字号),下方一行预览。三个控件 `align-items: center` 等高:字号 Select 不得挂 `size="small"`(会比同行 Input 矮一截),重置不得再藏在控件的 suffix / suffixIcon 里。共性说明只写在分段标题右侧一次,不逐项重复。默认字号 14/14/12px;作用范围见[界面指南](./chat-and-interface.md#12x-字体与字号三槽)。
- 供应商当前是响应式卡片:标题为名称与协议,正文为端点和模型摘要(上下文/输出、图片/视频、active 标记),卡片底部编辑/删除。编辑先在局部表单暂存,不能退回旧“字段变化直接污染页级草稿”行为;字段校验与输出预算见 [供应商与网络](./providers-and-network.md)。
- 网络代理卡片的 `.proxy-mode-list` 必须显式 `font-size: var(--ws-font-size)`,变量由 theme bridge 桥接 antd fontSize。Radio.Group 自带 font-size:0,单靠 DOM 文本存在不能保证标题肉眼可见。manual 校验只看支持协议前缀,空串允许保存;界面默认显示系统代理不等于后端把 null 自动改为显式配置。
- MCP 当前是服务器卡片与编辑弹框,支持用户级/项目级、测试、断开/重连和工具说明。状态按作用域及服务器名匹配;刷新只重读状态。结构化编辑须保留未知字段,解析不了时保留原始 JSON 供修复,不能降成空列表覆盖原文。详细界面约束见下一节,后端入口为 [mcp.rs](../src-tauri/src/host/commands/mcp.rs)。
Expand Down
24 changes: 12 additions & 12 deletions ui/src/__tests__/app.smoke.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,21 +303,21 @@ describe("App 渲染冒烟", () => {
expect(document.querySelector('[data-testid="settings-page"] .ant-form-horizontal')).toBeTruthy();
expect(document.querySelector('[data-testid="settings-page"] .ant-form-vertical')).toBeFalsy();

// Appearance: language, theme, output display and three independent font/size previews.
// Appearance: language, theme, output display and three font slots (one row each).
expect(document.querySelectorAll('[data-testid="settings-page"] .ant-select').length).toBeGreaterThanOrEqual(1);
expect(document.body.textContent ?? "").toContain("界面字体");
expect(document.body.textContent ?? "").toContain("内容字体");
expect(document.body.textContent ?? "").toContain("代码字体");
expect(document.querySelectorAll(".font-preview-row").length).toBe(6);
expect(document.body.textContent ?? "").toContain("界面");
expect(document.body.textContent ?? "").toContain("内容");
expect(document.body.textContent ?? "").toContain("代码");
// 三槽合并成三行后,每槽只有一行预览(原字体族/字号各一行、样本完全相同)
expect(document.querySelectorAll(".font-preview-row").length).toBe(3);
expect(Array.from(document.querySelectorAll(".settings-section-title")).map((node) => node.textContent)).toEqual(["界面语言", "主题", "输出展示", "字体"]);
expect(document.querySelector('[data-setting-id="ui.output_display"]')).toBeTruthy();
expect(document.querySelector('[data-setting-id="ui.font_sans"] .font-preview-row')).toBeTruthy();
expect(document.querySelector('[data-setting-id="ui.font_mono"] .font-preview-row')).toBeTruthy();
expect(document.querySelector('[data-setting-id="ui.font_content"] .font-preview-row')).toBeTruthy();
expect(document.querySelector('[data-setting-id="ui.font_size_content"] .font-preview-row')).toBeTruthy();
// Each size row also previews its applied family and size.
expect(document.querySelector('[data-setting-id="ui.font_size_sans"] .font-preview-row')).toBeTruthy();
expect(document.querySelector('[data-setting-id="ui.font_size_mono"] .font-preview-row')).toBeTruthy();
// 字体族与字号控件同处一行:各槽两个锚点都在,彼此独立可搜
for (const slot of ["sans", "content", "mono"]) {
const row = document.querySelector(`[data-setting-id="ui.font_${slot}"]`)!.closest(".settings-font-pair")!;
expect(row.querySelector(`[data-setting-id="ui.font_size_${slot}"]`)).toBeTruthy();
expect(row.querySelector(".font-preview-row")).toBeTruthy();
}

// 模型与供应商页([docs/providers-and-network](../../../docs/providers-and-network.md)):列表 + 编辑表单字段 + AI 回复语言
await clickTab("模型与供应商");
Expand Down
33 changes: 22 additions & 11 deletions ui/src/__tests__/font.settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useSettings } from "../stores/settings";
import { useUi } from "../stores/ui";
import { configSnapshot, configSaved } from "./configFixtures";
import { AppearanceSettings } from "../features/panels/FontSettings";
import type { FontSlot } from "../utils/fonts";

const calls: Array<[string, any]> = [];

Expand Down Expand Up @@ -64,12 +65,17 @@ function mount() {
);
}

/** 按 Form.Item 的 label 文本找字体输入框 */
/** 按字体槽定位输入框:走设置项锚点(ui.font_<slot>),不依赖行标签文字。
* 字体与字号已合并成三行、行标签简化为「界面 / 内容 / 代码」,按 Form.Item label 找已不成立。 */
const FAMILY_SLOT_BY_LABEL: Record<string, FontSlot> = {
界面字体: "sans", 内容字体: "content", 代码字体: "mono",
};

function fontInput(label: string): HTMLInputElement {
const item = Array.from(document.querySelectorAll(".ant-form-item")).find((el) =>
(el.querySelector(".ant-form-item-label")?.textContent ?? "").includes(label),
);
const el = item?.querySelector("input") as HTMLInputElement | null;
const slot = FAMILY_SLOT_BY_LABEL[label];
const el = (slot
? document.querySelector<HTMLInputElement>(`[data-setting-id="ui.font_${slot}"] input`)
: null) as HTMLInputElement | null;
if (!el) throw new Error(`找不到字体输入框:${label}`);
return el;
}
Expand Down Expand Up @@ -159,14 +165,18 @@ describe("界面字体:提交不丢与后端落盘", () => {
expect(fontPrefCalls()).toHaveLength(0);
});

it("「恢复默认」清空该槽并把空值同步到后端", async () => {
it("行末「恢复默认」一次清掉本槽字体族与字号,且只提交一次", async () => {
localStorage.setItem("ws_font_sans", "Inter");
localStorage.setItem("ws_font_size_sans", "17");
mount();
expect(fontInput("界面字体").value).toBe("Inter");

fireEvent.click(screen.getByLabelText(/恢复默认・界面字体/));
fireEvent.click(screen.getByLabelText("恢复默认・界面字体 + 界面字号"));
await waitFor(() => expect(localStorage.getItem("ws_font_sans")).toBeNull());
await waitFor(() => expect(fontPrefCalls().some((a) => a?.sans === "")).toBe(true));
// 一次点击 = 一次落盘(两个 commitFont* 串行后只提交最终结果)
await waitFor(() => expect(fontPrefCalls()).toHaveLength(1));
expect(fontPrefCalls()[0]).toMatchObject({ sans: "", fontSizeSans: 14 });
await waitFor(() => expect(useSettings.getState().config?.ui.font_sans).toBe(""));
});
it("authoritative font receipt updates saved state", async () => {
mount();
Expand Down Expand Up @@ -226,17 +236,18 @@ describe("界面字体:提交不丢与后端落盘", () => {
await waitFor(() => expect(useSettings.getState().config?.ui.font_content).toBe("Source Han Serif"));
expect(fontInput("界面字体").value).toBe("Inter");
expect(fontInput("代码字体").value).toBe("Menlo");
const preview = input.closest(".settings-font-control")!.querySelector<HTMLElement>(".font-preview-row")!;
const preview = input.closest(".settings-font-pair")!.querySelector<HTMLElement>(".font-preview-row")!;
expect(preview.style.fontFamily).toBe('"Source Han Serif", var(--ws-font-content-fallback)');
expect(preview.style.fontSize).toBe("18px");
});

it("restoring the content family preserves interface/code families and all sizes", async () => {
it("restoring the content slot preserves interface/code families", async () => {
localStorage.setItem("ws_font_sans", "Inter");
localStorage.setItem("ws_font_content", "Georgia");
localStorage.setItem("ws_font_mono", "Menlo");
mount();
fireEvent.click(screen.getByLabelText("恢复默认・内容字体"));
// 行末重置一次清内容槽的字体族与字号;另外两槽不动
fireEvent.click(screen.getByLabelText("恢复默认・内容字体 + 内容字号"));
await waitFor(() => expect(fontPrefCalls()).toHaveLength(1));
expect(fontPrefCalls()[0]).toMatchObject({
sans: "Inter", content: "", mono: "Menlo",
Expand Down
6 changes: 4 additions & 2 deletions ui/src/__tests__/font.sizes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ describe("font sizes in the runtime UI", () => {
for (const [slot, label, px, family] of [["sans", "17px", "17px", "Saved Sans"], ["content", "16px", "16px", "Saved Content"], ["mono", "19px", "19px", "Saved Mono"]] as const) {
const sizeField = document.querySelector(`[data-setting-id="ui.font_size_${slot}"]`)!;
expect(sizeField.querySelector(".ant-select")?.textContent).toContain(label);
const preview = sizeField.querySelector<HTMLElement>(".font-preview-row")!;
// 预览行已从字号控件内部上移到整行容器(三槽合并后只留一行预览)
const preview = sizeField.closest(".settings-font-pair")!
.querySelector<HTMLElement>(".font-preview-row")!;
expect(preview.style.fontSize).toBe(px);
expect(preview.style.fontFamily).toContain(family);
expect(document.querySelector<HTMLInputElement>(`[data-setting-id="ui.font_${slot}"] input`)?.value).toBe(family);
Expand Down Expand Up @@ -156,7 +158,7 @@ describe("font sizes in the runtime UI", () => {
fireEvent.click(screen.getByRole("button", { name: "重试・界面字号" }));
await waitFor(() => expect(useSettings.getState().config?.ui.font_size_sans).toBe(19));
expect(invoke).toHaveBeenLastCalledWith("set_font_prefs", expect.objectContaining({ fontSizeSans: 19, fontSizeContent: 14, fontSizeMono: 12 }));
fireEvent.click(screen.getByRole("button", { name: "恢复默认・界面字号" }));
fireEvent.click(screen.getByRole("button", { name: "恢复默认・界面字体 + 界面字号" }));
expect(screen.getByTestId("app-size").textContent).toBe("14");
await waitFor(() => expect(useSettings.getState().config?.ui.font_size_sans).toBe(14));
});
Expand Down
25 changes: 22 additions & 3 deletions ui/src/__tests__/settings.style.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const theme = readFileSync(join(here, "../features/panels/settings/SettingsTheme.tsx"), "utf8");
const css = readFileSync(join(here, "../features/panels/settings/settings-theme.css"), "utf8");
const fontSettings = readFileSync(join(here, "../features/panels/FontSettings.tsx"), "utf8");

describe("设置页共享视觉契约(settings-ui-unification)", () => {
it("所有面板共享一个 antd 主题层与左右式 Form", () => {
Expand All @@ -30,14 +31,32 @@ describe("设置页共享视觉契约(settings-ui-unification)", () => {
expect(css).toMatch(/@container settings-content \(max-width: 680px\)[\s\S]*?\.settings-provider-model-form\s*\{\s*grid-template-columns:\s*minmax\(0, 1fr\)/);
});

it("主题预览铺满卡片,字体输入与各自预览占同一半宽设置行", () => {
it("主题预览铺满卡片,字体行左列为定宽窄列、行内双控件占满右侧", () => {
expect(css).toMatch(/\.settings-theme-root \.settings-theme-picker\s*\{\s*width:\s*100%/);
expect(css).toMatch(/\.settings-row\.settings-row-full \.ant-form-item-control\s*\{[^}]*flex:\s*0 0 100%/);
expect(css).toMatch(/\.settings-row\.settings-font-row \.ant-form-item-control\s*\{[^}]*flex:\s*0 0 50%/);
expect(css).toMatch(/\.settings-row\.settings-font-row \.ant-form-item-label\s*\{[^}]*margin-inline-end:\s*var\(--settings-row-gap\)/);
// 三槽合并成三行后左列只剩「界面 / 内容 / 代码」两字标签:定宽窄列,右侧控件吃掉余量
expect(css).toMatch(/\.settings-row\.settings-font-row \.ant-form-item-label\s*\{[^}]*flex:\s*0 0 var\(--settings-font-slot-width\)/);
expect(css).toMatch(/\.settings-row\.settings-font-row \.ant-form-item-control\s*\{[^}]*flex:\s*1 1 auto/);
expect(css).toMatch(/\.settings-row\.settings-font-row \.ant-form-item-label\s*\{[^}]*align-items:\s*center/);
// 三个尺寸都进 SETTINGS_VISUAL,页体不写字面量
expect(theme).toMatch(/fontSlotLabelWidth:\s*72/);
expect(theme).toMatch(/fontSizeSelectWidth:\s*88/);
expect(theme).toMatch(/fontResetWidth:\s*32/);
expect(css).toMatch(/\.settings-theme-root \.settings-font-control\s*\{[^}]*flex-direction:\s*column/);
});

it("字体行三个控件等高居中,行末重置按钮独立成列", () => {
// Select 不能靠 size="small" 缩高(会比同行 Input 矮一截、下边线对不齐),
// 重置也不能再藏在 suffixIcon 里——两者都已移到行末的整槽重置按钮
const sizeSelect = fontSettings.match(/<Select<FontSizeLevel>[\s\S]*?\/>/)?.[0] ?? "";
expect(sizeSelect).not.toBe("");
expect(sizeSelect).not.toMatch(/size="small"/);
expect(sizeSelect).not.toMatch(/suffixIcon/);
expect(css).toMatch(/\.settings-font-pair\s*\{[^}]*grid-template-columns:[^}]*var\(--settings-font-size-select-width\)[^}]*var\(--settings-font-reset-width\)/);
expect(css).toMatch(/\.settings-font-pair\s*\{[^}]*align-items:\s*center/);
expect(css).toMatch(/\.settings-theme-root \.settings-font-reset\s*\{[^}]*align-items:\s*center/);
});

it("内容宽度、行卡片、控制宽度和窄屏堆叠由公共常量与样式管理", () => {
expect(theme).toMatch(/contentWidth:\s*960/);
expect(theme).toMatch(/controlWidth:\s*\{\s*narrow:\s*180,\s*mid:\s*240,\s*wide:\s*360/);
Expand Down
Loading
Loading