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
46 changes: 46 additions & 0 deletions desktop/src/app/appearance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,49 @@ describe("applyAppearance", () => {
expect(root().style.getPropertyValue("--font-size-base")).toBe("");
});
});

describe("monospace font preference", () => {
it("reaches the monospace stack, not the interface one", () => {
applyAppearance(
{
...APPEARANCE_DEFAULTS,
fontFamily: "Inter",
fontFamilyMono: "Cascadia Code",
},
root(),
);
expect(root().style.getPropertyValue("--font-ui-preferred")).toBe("Inter,");
expect(root().style.getPropertyValue("--font-mono-preferred")).toBe(
"Cascadia Code,",
);
});

it("leaves the other list's variable untouched", () => {
// Both lists are edited through the same control, so a picker wired to the
// wrong key would quietly push a monospace face into the UI stack.
applyAppearance(
{ ...APPEARANCE_DEFAULTS, fontFamilyMono: "JetBrains Mono" },
root(),
);
expect(root().style.getPropertyValue("--font-ui-preferred")).toBe("");
});

it("falls back to the default for a list of the wrong type", () => {
expect(sanitizeAppearance({ fontFamilyMono: 42 }).fontFamilyMono).toBe("");
});

it("carries each list's field copy on its own table row", () => {
// The picker reads its placeholder and its note from the row it was reached
// through. Copy that moved back into the renderer, or a row that lost it,
// would render an unlabelled control instead of failing anywhere.
const rows = APPEARANCE_SETTINGS.filter((setting) => setting.noteKey);
expect(rows.map((setting) => setting.key)).toEqual([
"fontFamily",
"fontFamilyMono",
]);
for (const row of rows) {
expect(row.placeholder?.key).toMatch(/^settings\.appearance\.font/);
expect(row.placeholder?.fallback).toBeTruthy();
}
});
});
67 changes: 63 additions & 4 deletions desktop/src/app/appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ export interface AppearanceState {
* fixed list would be both wrong and stale. Empty means "use the default".
*/
fontFamily: string;
/**
* The same thing for monospace text: code, diffs and the small labels that
* use the built-in monospace stack, which the interface list cannot reach.
* Kept separate because a face that reads well in prose is rarely the one
* that reads well in a code block. The terminal pane is out of reach: it
* paints its own canvas and is configured in `TerminalPanel`.
*/
fontFamilyMono: string;
/** Complete, normalized palette imported from one local VS Code theme. */
importedTheme: ImportedTheme | null;
}
Expand All @@ -72,6 +80,7 @@ export const APPEARANCE_DEFAULTS: AppearanceState = {
theme: "system",
fontSize: 14,
fontFamily: "",
fontFamilyMono: "",
importedTheme: null,
};

Expand All @@ -92,6 +101,14 @@ interface AppearanceSetting<K extends keyof AppearanceState> {
toCss?(value: AppearanceState[K]): string;
/** Present for numeric settings so the UI can build a slider. */
range?: { min: number; max: number; step: number; unit: string };
/** Present for free-text settings: the field's placeholder, key and fallback. */
placeholder?: { key: string; fallback: string };
/**
* i18n key for the note under the form. Lives here so the table stays the one
* place that knows everything about a preference; `description` is its
* fallback, which keeps the English copy in this file.
*/
noteKey?: string;
}

function clampNumber(value: unknown, min: number, max: number, fallback: number) {
Expand All @@ -111,6 +128,26 @@ export const CONVERSATION_WIDTH_RANGE = {
step: 5,
unit: "%",
} as const;
/** A free-text family list: trimmed, bounded, and never anything but a string. */
function familyList(value: unknown): string {
return typeof value === "string" ? value.trim().slice(0, 200) : "";
}

/**
* Rendered as a prefix of the built-in stack, never the whole of it, so an
* unavailable font degrades instead of leaving the text unstyled.
*
* The trailing comma carries the mechanism. The stack it is prepended to has no
* comma of its own (`var(--font-mono-preferred) "SFMono-Regular", …`), so
* without one the two become a single bogus family name and the browser drops
* the whole declaration. Measured in Edge 153, the engine Tauri uses on
* Windows: with the comma the built-in stack follows the preference as
* intended; without it the element falls back to the inherited interface font —
* no error, just the wrong font.
*/
function familyListCss(value: string): string {
return value ? `${value},` : "";
}

const CONVERSATION_WIDTH: AppearanceSetting<"conversationWidth"> = {
key: "conversationWidth",
Expand Down Expand Up @@ -164,17 +201,39 @@ const FONT_FAMILY: AppearanceSetting<"fontFamily"> = {
"picking a CJK face so mixed text renders consistently. Anything the " +
"system lacks is skipped, so listing several is safe.",
cssVariable: "--font-ui-preferred",
sanitize: (value) => (typeof value === "string" ? value.trim().slice(0, 200) : ""),
// Trailing comma: this list is a prefix of the built-in stack, never the
// whole of it, so an unavailable font can always fall through.
toCss: (value) => (value ? `${value},` : ""),
noteKey: "settings.appearance.fontDescription",
placeholder: {
key: "settings.appearance.fontPlaceholder",
fallback: "e.g. Sarasa Mono SC, Inter",
},
sanitize: familyList,
toCss: familyListCss,
};

const FONT_FAMILY_MONO: AppearanceSetting<"fontFamilyMono"> = {
key: "fontFamilyMono",
label: "Preferred monospace fonts",
description:
"Comma-separated families tried before the built-in monospace stack: " +
"code blocks, diffs and the small monospace labels follow it. A face " +
"chosen for prose is rarely the one that reads well in code, so this list " +
"is independent of the one above. The terminal pane keeps its own font.",
cssVariable: "--font-mono-preferred",
noteKey: "settings.appearance.fontMonoDescription",
placeholder: {
key: "settings.appearance.fontMonoPlaceholder",
fallback: "e.g. JetBrains Mono, Cascadia Code",
},
sanitize: familyList,
toCss: familyListCss,
};

export const APPEARANCE_SETTINGS = [
CONVERSATION_WIDTH,
THEME,
FONT_SIZE,
FONT_FAMILY,
FONT_FAMILY_MONO,
] as const;

/** Coerce a parsed blob of unknown provenance into a complete state. */
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/app/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const ZH_CN: Record<string, string> = {
"settings.appearance.conversationWidth": "对话宽度",
"settings.appearance.fontSize": "字号",
"settings.appearance.fontFamily": "首选字体",
"settings.appearance.fontFamilyMono": "首选等宽字体",
"settings.appearance.paper": "纸张 · 暖色低蓝光",
"settings.appearance.midnight": "午夜 · 深邃冷色",
"settings.appearance.claude": "Claude · 象牙白与陶土色",
Expand All @@ -111,12 +112,15 @@ const ZH_CN: Record<string, string> = {
"settings.appearance.importHint":
"读取一个本地 JSON/JSONC 颜色主题文件;暂不导入主题 include 链和语法配色。",
"settings.appearance.fontPlaceholder": "例如:更纱黑体 SC、Inter",
"settings.appearance.fontMonoPlaceholder": "例如:JetBrains Mono、Cascadia Code",
"settings.appearance.addInstalledFont": "添加已安装字体…",
"settings.appearance.fontGroup.interface": "界面字体",
"settings.appearance.fontGroup.monospace": "等宽字体",
"settings.appearance.fontGroup.cjk": "中日韩字体",
"settings.appearance.fontDescription":
"以逗号分隔的字体会优先于内置字体尝试,便于为中英文混排选择一致的字体。系统会跳过未安装的字体,因此可以安全地列出多个字体。",
"settings.appearance.fontMonoDescription":
"以逗号分隔的字体会优先于内置等宽字体尝试,代码块、差异视图与细小的等宽标签都会跟随。适合正文的字体往往不适合代码,因此这份列表与上面的相互独立。终端面板仍使用自己的字体。",
"settings.appearance.localOnly":
"这些显示设置仅保存在本机,会立即生效,不属于项目配置。",
"settings.appearance.reset": "恢复默认设置",
Expand Down
39 changes: 21 additions & 18 deletions desktop/src/features/settings/AppearanceSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ const FONT_GROUPS = [
/**
* Appearance controls, rendered from `APPEARANCE_SETTINGS`.
*
* The table owns each preference's label, range and validation, so adding one
* is a row there rather than another block of near-identical JSX here.
* Changes apply immediately and persist locally — these are per-machine
* The table owns each preference's label, range, validation and field copy, so
* adding one is a row there rather than another block of near-identical JSX
* here. Changes apply immediately and persist locally — these are per-machine
* display choices, not project configuration, so there is nothing to save.
*/
export function AppearanceSettings() {
Expand Down Expand Up @@ -220,20 +220,22 @@ export function AppearanceSettings() {
);
}

// The remaining settings are free-text family lists. Each edits the key
// it was reached through, so the two lists cannot write each other.
const fontKey = setting.key as "fontFamily" | "fontFamilyMono";
return (
<label key={setting.key} htmlFor={id}>
{label}
<input
id={id}
type="text"
value={value as string}
placeholder={t(
"settings.appearance.fontPlaceholder",
"e.g. Sarasa Mono SC, Inter",
)}
onChange={(event) =>
set(setting.key as "fontFamily", event.target.value)
placeholder={
setting.placeholder
? t(setting.placeholder.key, setting.placeholder.fallback)
: undefined
}
onChange={(event) => set(fontKey, event.target.value)}
/>
{installed.length > 0 ? (
<select
Expand All @@ -245,8 +247,8 @@ export function AppearanceSettings() {
onChange={(event) => {
if (!event.target.value) return;
set(
"fontFamily",
appendFamily(appearance.fontFamily, event.target.value),
fontKey,
appendFamily(appearance[fontKey], event.target.value),
);
}}
>
Expand Down Expand Up @@ -317,13 +319,14 @@ export function AppearanceSettings() {
{importError ? <p role="alert">{importError}</p> : null}
</div>

<p className={styles.note}>
{t(
"settings.appearance.fontDescription",
APPEARANCE_SETTINGS.find((setting) => setting.key === "fontFamily")
?.description ?? "Choose fonts for the interface.",
)}
</p>
{/* One note per preference that asks for one, in table order. */}
{APPEARANCE_SETTINGS.map((setting) =>
setting.noteKey ? (
<p className={styles.note} key={setting.key}>
{t(setting.noteKey, setting.description)}
</p>
) : null,
)}

<footer className={styles.formActions}>
<span>
Expand Down
10 changes: 7 additions & 3 deletions desktop/src/styles/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
background: #e4e9e5;
color-scheme: light dark;

/* Appearance preferences (src/app/appearance.ts) override the three
/* Appearance preferences (src/app/appearance.ts) override the four
variables below on the root element. They are declared here so the app
works untouched when nothing has been customised, and so the defaults
stay in the stylesheet rather than being duplicated in TypeScript. */

/* Empty by default; the preference prepends families to the stack. */
/* Empty by default; the preference prepends families to the stack. Declared
empty rather than omitted: `var()` with no fallback would make the whole
declaration invalid at computed-value time. */
--font-ui-preferred: ;
--font-mono-preferred: ;
--font-size-base: 14px;
/* Width of the conversation column, its composer, and the goal rail —
one value so the three cannot drift apart. */
Expand All @@ -27,7 +30,8 @@
ui-rounded, "SF Pro Rounded", "Avenir Next", "Segoe UI Variable Display",
-apple-system, BlinkMacSystemFont, sans-serif;
--font-mono:
"SFMono-Regular", ui-monospace, "Cascadia Code", Consolas, monospace;
var(--font-mono-preferred) "SFMono-Regular", ui-monospace, "Cascadia Code",
Consolas, monospace;

/* Type scale.
Every step is `rem`, so it resolves against the root font-size — which is
Expand Down
26 changes: 20 additions & 6 deletions desktop/src/styles/tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,17 +171,31 @@ describe("theme override selectors", () => {
describe("appearance variables", () => {
const rootDeclarations = declarationsAfter(":root {");

it.each(["--conversation-width", "--font-size-base", "--font-ui-preferred"])(
"declares a default for %s",
(variable) => {
expect(rootDeclarations.has(variable)).toBe(true);
},
);
// Declared even when they are empty: a preference var that no stack consumes
// is a setting that silently does nothing, and one that is consumed without
// being declared makes its whole declaration invalid at computed-value time.
it.each([
"--conversation-width",
"--font-size-base",
"--font-ui-preferred",
"--font-mono-preferred",
])("declares a default for %s", (variable) => {
expect(rootDeclarations.has(variable)).toBe(true);
});

it("routes the root font through both variables", () => {
expect(rootDeclarations.get("font-family")).toContain(
"var(--font-ui-preferred)",
);
expect(rootDeclarations.get("font-size")).toBe("var(--font-size-base)");
});

it("routes the monospace stack through its own variable", () => {
// Code, terminal output and the small metadata text use --font-mono, so a
// monospace preference that is not part of this stack would look applied
// and change nothing.
expect(rootDeclarations.get("--font-mono")).toContain(
"var(--font-mono-preferred)",
);
});
});
Loading