diff --git a/app/globals.css b/app/globals.css index 3399e0ae6d..52118ffe9c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -185,7 +185,8 @@ html, body { overflow: hidden; background: var(--bg); color: var(--text); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: var(--font-ui); + font-weight: var(--font-ui-weight); font-size: 14px; overscroll-behavior: none; } @@ -712,11 +713,22 @@ button.extension-widget-trigger:focus-visible { } :root { - --font-mono: var(--font-noto-mono), 'JetBrains Mono', 'Fira Code', 'Consolas', ui-monospace, 'PingFang SC', 'Microsoft YaHei', monospace; + --font-ui-default: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono-default: var(--font-noto-mono), 'JetBrains Mono', 'Fira Code', 'Consolas', ui-monospace, 'PingFang SC', 'Microsoft YaHei', monospace; + --font-ui: var(--font-ui-default); + --font-mono: var(--font-mono-default); + --font-ui-weight: 400; + --font-mono-weight: 400; } pre, code { font-family: var(--font-mono); + font-weight: var(--font-mono-weight); +} + +/* Inline code inside a heading or emphasis keeps that surrounding emphasis. */ +.markdown-body :is(h1, h2, h3, h4, h5, h6, strong, b) code { + font-weight: inherit; } /* react-markdown output styles */ diff --git a/app/settings.css b/app/settings.css index 66e08803fb..b3bc2ef13e 100644 --- a/app/settings.css +++ b/app/settings.css @@ -1604,6 +1604,69 @@ overflow-wrap: anywhere; } +.settings-font-rows { + display: grid; + grid-template-columns: max-content minmax(0, 1fr) auto 28px; + gap: 12px 8px; +} + +.settings-font-rows > .settings-font-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: max-content minmax(0, 1fr) auto 28px; + grid-template-columns: subgrid; + row-gap: 0; +} + +.settings-font-rows > .settings-font-row > input[type="range"] { + grid-column: 1 / -1; +} + +.settings-chat-range-header.settings-font-header { + grid-column: 1 / -1; + grid-template-columns: max-content minmax(0, 1fr) auto 28px; + grid-template-columns: subgrid; +} + +.settings-chat-range-header.settings-font-header label { + padding-right: 4px; +} + +/* Drawn in the font it names: the live preview. */ +.settings-chat-range-option .settings-font-input { + height: 26px; + margin: 0; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg); + color: var(--text); + font-family: var(--font-ui); + font-weight: var(--font-ui-weight); + font-size: 12px; +} + +.settings-chat-range-option .settings-font-input.is-mono { + font-family: var(--font-mono); + font-weight: var(--font-mono-weight); +} + +.settings-font-input::placeholder { + color: var(--text-dim); +} + +.settings-font-input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +@media (max-width: 640px) { + /* Phones get 16px form text (no focus zoom), so the name field needs the height. */ + .settings-chat-range-option .settings-font-input { + height: 32px; + } +} + .settings-chat-options { display: flex; flex-direction: column; diff --git a/components/AppShell.tsx b/components/AppShell.tsx index c50b18f66c..e3a9769a3b 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -18,6 +18,7 @@ import { AgentSessionPanel } from "./AgentSessionPanel"; import { TerminalPanel } from "./TerminalPanel"; import { newTerminalTab, restoreTerminalTabs, TERMINAL_TABS_KEY, type TerminalTab } from "./terminal-tab-state"; import { useTheme } from "@/hooks/useTheme"; +import { useFontPreferences } from "@/hooks/useFontPreferences"; import { useI18n } from "@/hooks/useI18n"; import { useIsMobile, useIsNarrowMobile } from "@/hooks/useIsMobile"; import { useViewportHeight } from "@/hooks/useViewportHeight"; @@ -83,6 +84,8 @@ export function AppShell() { const [initialNavigation, setInitialNavigation] = useState(() => getInitialNavigation(searchParams)); // Keep the system-theme subscription mounted for the lifetime of the app. useTheme(); + // Restore fonts even when Settings and the chat composer have not been opened. + useFontPreferences(); const { locale, t: translate } = useI18n(); const isMobile = useIsMobile(); const isNarrowMobile = useIsNarrowMobile(); diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 884e6407ef..e1ad147497 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -30,6 +30,7 @@ import { useIsMobile } from "@/hooks/useIsMobile"; import { useEnterSendMode } from "@/hooks/useEnterSendMode"; import { useI18n } from "@/hooks/useI18n"; import { useChatAppearance } from "@/hooks/useChatAppearance"; +import { useFontPreferences } from "@/hooks/useFontPreferences"; import type { ToolPreset } from "@/lib/tool-presets"; import { SelectorRow } from "./SelectorRow"; import { ModelSelector, type ModelSelectorOption } from "./ModelSelector"; @@ -601,6 +602,7 @@ export const ChatInput = forwardRef(function ChatInput({ }: Props, ref) { const { t } = useI18n(); const { fontSize } = useChatAppearance(); + const { ui: uiFontFamily, uiWeight } = useFontPreferences(); const isMobile = useIsMobile(); const enterSendMode = useEnterSendMode(); const [value, setValue] = useState(() => (draftKey ? getDraft(draftKey)?.value ?? "" : "")); @@ -942,7 +944,7 @@ export const ChatInput = forwardRef(function ChatInput({ if (ta.value) ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }, []); - useLayoutEffect(resizeTextarea, [value, fontSize, resizeTextarea]); + useLayoutEffect(resizeTextarea, [value, fontSize, uiFontFamily, uiWeight, resizeTextarea]); useEffect(() => { const ta = textareaRef.current; diff --git a/components/FileViewer.tsx b/components/FileViewer.tsx index 51ea81ec57..7dbe5964f1 100644 --- a/components/FileViewer.tsx +++ b/components/FileViewer.tsx @@ -69,6 +69,7 @@ const DISPLAY_MODE_LABELS: Record = { const FILE_CODE_STYLE: CSSProperties = { fontFamily: "var(--font-mono)", + fontWeight: "var(--font-mono-weight)", fontSize: 13, lineHeight: 1.6, }; @@ -82,6 +83,7 @@ const FILE_LINE_NUMBER_STYLE: CSSProperties = { background: "var(--bg-panel)", borderRight: "1px solid var(--border)", fontFamily: "var(--font-mono)", + fontWeight: "var(--font-mono-weight)", fontSize: 11, fontStyle: "normal", fontVariantNumeric: "tabular-nums", @@ -1402,6 +1404,7 @@ function TextFileViewer({ codeTagProps={{ style: { fontFamily: "var(--font-mono)", + fontWeight: "var(--font-mono-weight)", overflowWrap: wrapLines ? "anywhere" : "normal", }, }} diff --git a/components/FontSettings.tsx b/components/FontSettings.tsx new file mode 100644 index 0000000000..7e4c2088de --- /dev/null +++ b/components/FontSettings.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useI18n } from "@/hooks/useI18n"; +import { useFontPreferences } from "@/hooks/useFontPreferences"; +import { FONT_FAMILY_MAX_LENGTH, FONT_WEIGHT_DEFAULT, FONT_WEIGHT_OPTIONS } from "@/lib/font-preferences"; +import { ConfigButton } from "./SettingsUi"; + +const FONT_FIELDS = [ + { kind: "ui", label: "settings.uiFontFamily", placeholder: "settings.uiFontPlaceholder", reset: "settings.resetUiFont", weightLabel: "settings.uiFontWeight" }, + { kind: "mono", label: "settings.monoFontFamily", placeholder: "settings.monoFontPlaceholder", reset: "settings.resetMonoFont", weightLabel: "settings.monoFontWeight" }, +] as const; + +/** + * Rows for the Fonts & layout section, shaped like the size and width rows below: + * label, name field, weight value and reset in the header line, the weight slider under it. + * The name field is drawn in the font it names, so it doubles as the preview. + */ +export function FontSettings() { + const { t } = useI18n(); + const fonts = useFontPreferences(); + + // One grid for both rows, so the name fields start at the same x whatever the labels' widths. + return
{FONT_FIELDS.map(({ kind, label, placeholder, reset, weightLabel }) => { + const weight = fonts[`${kind}Weight`]; + const weightIndex = FONT_WEIGHT_OPTIONS.findIndex((option) => option.value === weight); + return ( +
+
+ + fonts.setFontPreference(kind, event.target.value)} + /> + {weight} + fonts.resetFontPreference(kind)} + > + + +
+ fonts.setFontWeight(kind, Number(event.target.value))} + /> +
+ ); + })}
; +} diff --git a/components/MermaidBlock.tsx b/components/MermaidBlock.tsx index af8b31600e..8e5351c1f7 100644 --- a/components/MermaidBlock.tsx +++ b/components/MermaidBlock.tsx @@ -316,13 +316,14 @@ export const CodeBlock = memo(function CodeBlock({ code, lang, headerAction, isS padding: "11px 13px", fontSize: "calc(12.5px + var(--chat-font-size-offset, 0px))", lineHeight: 1.62, + fontWeight: "var(--font-mono-weight)", // The light `vs` theme puts its own 1px #ddd border on
; the
             // block's wrapper already draws the frame.
             border: "none",
             borderRadius: 0,
             background: "color-mix(in srgb, var(--bg) 92%, var(--bg-panel))",
           }}
-          codeTagProps={{ style: { fontFamily: "var(--font-mono)" } }}
+          codeTagProps={{ style: { fontFamily: "var(--font-mono)", fontWeight: "var(--font-mono-weight)" } }}
         >
           {code}
         
diff --git a/components/MessageView.tsx b/components/MessageView.tsx
index 0570a5e4cb..291f482565 100644
--- a/components/MessageView.tsx
+++ b/components/MessageView.tsx
@@ -1319,6 +1319,7 @@ function SplitFilesView({ files }: { files: SplitDiffFile[] }) {
             minWidth: 0,
             borderTop: fileIndex === 0 ? "none" : "1px solid var(--border)",
             fontFamily: "var(--font-mono)",
+            fontWeight: "var(--font-mono-weight)",
             fontSize: "calc(12px + var(--chat-font-size-offset, 0px))",
             lineHeight: 1.55,
           }}
@@ -1447,7 +1448,7 @@ function PatchTextView({ text }: { text: string }) {
   const lines = text.split(/\r?\n/);
 
   return (
-    
+
{lines.map((line, i) => { const kind = line.startsWith("@@") ? "hunk" : diff --git a/components/SettingsPanel.test.mjs b/components/SettingsPanel.test.mjs index adcc4a2675..0f95b57fde 100644 --- a/components/SettingsPanel.test.mjs +++ b/components/SettingsPanel.test.mjs @@ -150,9 +150,13 @@ test("keeps language selection in General settings", () => { assert.match(panelSource, /setLocale\(plugin\.id/); }); -test("groups chat display controls together without row backgrounds", () => { +test("groups fonts, chat font size and width in one section, chat behavior in another, without row backgrounds", () => { const appearanceSection = panelSource.slice( panelSource.indexOf('{t("settings.appearance")}'), + panelSource.indexOf('{t("settings.typography")}'), + ); + const typographySection = panelSource.slice( + panelSource.indexOf('{t("settings.typography")}'), panelSource.indexOf('{t("settings.chat")}'), ); const chatSection = panelSource.slice( @@ -160,11 +164,17 @@ test("groups chat display controls together without row backgrounds", () => { panelSource.indexOf("{shellSettings?.isWindows"), ); - assert.doesNotMatch(appearanceSection, /settings-chat-content/); + assert.doesNotMatch(appearanceSection, /settings-chat-content|/); + assert.equal((typographySection.match(/className="settings-chat-option settings-chat-range-option"/g) ?? []).length, 2); + for (const key of ["chatContentWidth", "chatContentFontSize", "fontDescription"]) { + assert.match(typographySection, new RegExp(`t\\("settings\\.${key}"\\)`)); + } assert.match(chatSection, /className="settings-chat-options"/); - assert.equal((chatSection.match(/className="settings-chat-option(?: |")/g) ?? []).length, 5); + assert.doesNotMatch(chatSection, /settings-chat-content/); + assert.equal((chatSection.match(/className="settings-chat-option(?: |")/g) ?? []).length, 3); assert.equal((chatSection.match(/
-

{t("settings.chat")}

+

{t("settings.typography")}

+

{t("settings.fontDescription")}

-
- {t("settings.thinkingExpandedDefault")} - { - setThinkingExpandedByDefault(enabled); - setThinkingExpanded(enabled); - }} - /> -
+
- - {chatContentWidth}px + + {fontSize}px setChatContentWidth(CHAT_CONTENT_WIDTH_DEFAULT)} + title={t("settings.resetChatContentFontSize")} + aria-label={t("settings.resetChatContentFontSize")} + disabled={fontSize === CHAT_CONTENT_FONT_SIZE_DEFAULT} + onClick={() => setFontSize(CHAT_CONTENT_FONT_SIZE_DEFAULT)} >
setChatContentWidth(Number(event.target.value))} + min={CHAT_CONTENT_FONT_SIZE_MIN} + max={CHAT_CONTENT_FONT_SIZE_MAX} + step={1} + value={fontSize} + onChange={(event) => setFontSize(Number(event.target.value))} />
- - {fontSize}px + + {chatContentWidth}px setFontSize(CHAT_CONTENT_FONT_SIZE_DEFAULT)} + title={t("settings.resetChatContentWidth")} + aria-label={t("settings.resetChatContentWidth")} + disabled={chatContentWidth === CHAT_CONTENT_WIDTH_DEFAULT} + onClick={() => setChatContentWidth(CHAT_CONTENT_WIDTH_DEFAULT)} >
setFontSize(Number(event.target.value))} + min={CHAT_CONTENT_WIDTH_MIN} + max={CHAT_CONTENT_WIDTH_MAX} + step={10} + value={chatContentWidth} + onChange={(event) => setChatContentWidth(Number(event.target.value))} + /> +
+
+
+ +
+

{t("settings.chat")}

+
+
+ {t("settings.thinkingExpandedDefault")} + { + setThinkingExpandedByDefault(enabled); + setThinkingExpanded(enabled); + }} />
diff --git a/components/TerminalPanel.tsx b/components/TerminalPanel.tsx index 57b67c218d..f3a2abd078 100644 --- a/components/TerminalPanel.tsx +++ b/components/TerminalPanel.tsx @@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from "react"; import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import { useI18n } from "@/hooks/useI18n"; +import { subscribeFontPreferences } from "@/hooks/useFontPreferences"; +import { readFontWeight } from "@/lib/font-preferences"; import { createTerminalWriter, terminalRequest } from "@/lib/terminal-client"; import type { TerminalEvent } from "@/lib/terminal-manager"; import type { TerminalTab } from "./terminal-tab-state"; @@ -43,9 +45,17 @@ export function TerminalPanel({ tab, active, onRestart, onClosed, onCloseError } setError(null); setExitCode(null); + const readTerminalFont = () => { + const style = getComputedStyle(container); + return { + fontFamily: style.getPropertyValue("--font-mono").trim() || "monospace", + // Base weights stop at 600, so xterm's default bold (700) stays heavier. + fontWeight: readFontWeight(style.getPropertyValue("--font-mono-weight")), + }; + }; const terminal = new Terminal({ cursorBlink: true, - fontFamily: getComputedStyle(container).getPropertyValue("--font-mono").trim() || "monospace", + ...readTerminalFont(), fontSize: 13, lineHeight: 1.25, scrollback: 8000, @@ -93,6 +103,14 @@ export function TerminalPanel({ tab, active, onRestart, onClosed, onCloseError } }); const resizeObserver = new ResizeObserver(fitAndResize); resizeObserver.observe(container); + const updateFont = () => { + if (disposed) return; + const font = readTerminalFont(); + terminal.options.fontFamily = font.fontFamily; + terminal.options.fontWeight = font.fontWeight; + fitAndResize(); + }; + const unsubscribeFonts = subscribeFontPreferences(updateFont); const connect = () => { if (disposed || exited || !navigator.onLine) return; @@ -166,6 +184,7 @@ export function TerminalPanel({ tab, active, onRestart, onClosed, onCloseError } events?.close(); void writer.stop(); resizeObserver.disconnect(); + unsubscribeFonts(); onData.dispose(); onResize.dispose(); window.removeEventListener("pagehide", pageHide); diff --git a/docs/agents/settings-ui.md b/docs/agents/settings-ui.md index 274b438e90..83a509bfa5 100644 --- a/docs/agents/settings-ui.md +++ b/docs/agents/settings-ui.md @@ -1,5 +1,11 @@ # Settings panels: plugins, skills and shared blocks +## General appearance +- `components/FontSettings.tsx` offers separate interface/chat and monospace (code, tool output, terminal) font-name lists in the Fonts & layout section after Appearance, together with chat font size and content width; the Chat section keeps only behavior switches. Every row there has one shape: a header line (label, value, reset button in a fixed 28px column) over a full-width slider, so values and resets line up. A font row puts its name field between label and value, and its slider is the weight (100–600, step 100; `aria-valuetext` adds the weight's name). Both font rows share one grid (`subgrid`), so their name fields start at the same x whatever the labels' widths in each locale. The name field is drawn in the font it names, which is the preview; keep one short section description, no separate preview lines or per-control paragraphs. `hooks/useFontPreferences.ts` stores them only in the browser (`pi-ui-font-family`, `pi-mono-font-family`), applies changes immediately through `--font-ui` / `--font-mono`, and syncs other tabs. AppShell keeps the hook mounted so restoring a font never requires opening Settings or a chat. Empty input removes the override; the original stacks stay in `app/globals.css` as `--font-ui-default` / `--font-mono-default`. +- Font names refer to fonts on the **browser's device**, not the server; no enumeration, upload or remote download. `lib/font-preferences.ts` treats commas as separators, accepts optional enclosing quotes, quotes non-generic names as data, and always appends the default stack. Never interpolate font input into a stylesheet or accept it as executable CSS. Keep raw input whitespace while typing so multi-word names remain editable. +- Each font has a separate base weight (100–600 in steps of 100, default Regular/400; it stops at 600 because headings and labels use 600 and bold text, highlighted bold and the terminal's bold use 700, so a heavier base would outweigh them), stored as `pi-ui-font-weight` / `pi-mono-font-weight` and applied through `--font-ui-weight` / `--font-mono-weight`. Missing/invalid stored weights use 400; saving 400 removes the override. Each reset clears that font's name **and** weight in one update, not the other font or chat size/width. The UI weight is inherited by ordinary interface/chat text; code/pre, highlighted code (including its inline theme styles), source-view line numbers and diffs use the mono weight. Headings, Markdown bold/inline code inside emphasis, and other explicitly weighted labels keep their original weights; do not blanket-override them with `!important`. +- ChatInput remeasures an existing draft when the interface font or weight changes. TerminalPanel subscribes in its existing lifetime effect, updates xterm's family and normal weight (its bold stays xterm's default 700, above every base weight) and refits, never restarting the terminal or its SSE stream for a font change. + ## Plugins and skills - `/api/plugins` uses pi's `SettingsManager` + `DefaultPackageManager` for global/project package install, remove, update, enable and disable; disabling writes empty `extensions/skills/prompts/themes` arrays to the package entry. - `/api/skills` lists through `DefaultResourceLoader`, so settings paths, package skills and project `.agents/skills` appear as the runtime sees them. Toggling a skill edits only the `disable-model-invocation` frontmatter key of its `SKILL.md`, so user formatting survives, and `PATCH /api/skills` edits only `.md` files (its roots also hold `auth.json`, settings and project files). `/api/skills/install` shells through `npx skills add ... --agent pi`; project installs run in the selected cwd. diff --git a/e2e/README.md b/e2e/README.md index 745eca914b..9181eae8bb 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -31,6 +31,9 @@ Coverage: - Markdown, code blocks, and real tool-call/tool-result blocks render. - Chat width and font size persist, existing drafts resize, and short settings panels keep every language option reachable on desktop and mobile. +- Interface/chat and monospace font lists and weights apply live, survive + refresh, sync between tabs and reset independently without changing width or + font size. Headings and Markdown emphasis retain their weights. - Unknown sessions and paths outside the fixture project are rejected. - A local extension checks dialog keyboard navigation, Esc cancellation, collapse/expand draft preservation, countdown display, and server-side expiry. diff --git a/e2e/chat-appearance.mjs b/e2e/chat-appearance.mjs index 8010a02c14..f1bd91a4df 100644 --- a/e2e/chat-appearance.mjs +++ b/e2e/chat-appearance.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { checkFontPreferences } from "./font-preferences.mjs"; export async function checkChatAppearanceReset(page) { const width = page.getByRole("slider", { name: "Chat content width", exact: true }); @@ -82,6 +83,7 @@ export async function checkChatAppearance(page) { assert.equal(await width.inputValue(), "2000"); assert.equal(await fontSize.inputValue(), "18"); await checkChatAppearanceReset(page); + await checkFontPreferences(page); for (const viewport of [{ width: 1280, height: 600 }, { width: 320, height: 568 }]) { await page.setViewportSize(viewport); await page.locator(".settings-general").evaluate((el) => { el.scrollTop = el.scrollHeight; }); diff --git a/e2e/font-preferences.mjs b/e2e/font-preferences.mjs new file mode 100644 index 0000000000..b3e01403fb --- /dev/null +++ b/e2e/font-preferences.mjs @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; + +/** Starts and ends in General settings, with both font overrides reset. */ +export async function checkFontPreferences(page) { + const ui = page.getByRole("textbox", { name: "Interface font", exact: true }); + const mono = page.getByRole("textbox", { name: "Monospace font", exact: true }); + const uiWeight = page.getByRole("slider", { name: "Interface font weight", exact: true }); + const monoWeight = page.getByRole("slider", { name: "Monospace font weight", exact: true }); + const weight = (selector) => page.locator(selector).first().evaluate((el) => getComputedStyle(el).fontWeight); + const resetUi = page.getByRole("button", { name: "Reset interface font", exact: true }); + const resetMono = page.getByRole("button", { name: "Reset monospace font", exact: true }); + const font = (selector) => page.locator(selector).first().evaluate((el) => getComputedStyle(el).fontFamily); + const openSettings = async () => { + const sidebar = page.getByRole("button", { name: "Show sidebar", exact: true }); + if (await sidebar.isVisible()) await sidebar.click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + }; + assert.equal(await ui.inputValue(), ""); + assert.equal(await mono.inputValue(), ""); + assert.equal(await uiWeight.inputValue(), "400"); + assert.equal(await monoWeight.inputValue(), "400"); + assert.equal(await weight(".markdown-user-message"), "400", "Regular, not Light, is the default"); + assert.equal(await weight(".markdown-code-block code"), "400"); + await uiWeight.fill("500"); + await monoWeight.fill("600"); + for (const selector of ["body", ".chat-input-textarea", ".markdown-user-message", "#settings-font-ui"]) { + assert.equal(await weight(selector), "500", selector); + } + for (const selector of [".markdown-code-block pre", ".markdown-code-block code", "#settings-font-mono"]) { + assert.equal(await weight(selector), "600", selector); + } + assert.equal(await weight(".markdown-user-message strong"), "700", "Markdown emphasis keeps its weight"); + assert.equal(await weight(".markdown-body h2"), "600", "Headings keep their weight"); + await ui.pressSequentially("Times New Roman, serif"); + assert.equal(await ui.inputValue(), "Times New Roman, serif", "Typing spaces must not join font names"); + await mono.fill("monospace"); + for (const selector of ["body", ".chat-input-textarea", ".markdown-user-message", "#settings-font-ui"]) { + assert.match(await font(selector), /^"?Times New Roman"?, serif,/, selector); + } + for (const selector of [".markdown-code-block code", "#settings-font-mono"]) { + assert.match(await font(selector), /^monospace,/, selector); + } + await page.reload({ waitUntil: "networkidle" }); + await page.locator(".markdown-code-block code").waitFor(); + assert.match(await font("body"), /^"?Times New Roman"?, serif,/); + assert.match(await font(".markdown-code-block code"), /^monospace,/); + assert.equal(await weight(".markdown-user-message"), "500"); + assert.equal(await weight(".markdown-code-block code"), "600"); + await openSettings(); + assert.equal(await ui.inputValue(), "Times New Roman, serif"); + assert.equal(await mono.inputValue(), "monospace"); + assert.equal(await uiWeight.inputValue(), "500"); + assert.equal(await monoWeight.inputValue(), "600"); + + const other = await page.context().newPage(); + try { + await other.goto(page.url(), { waitUntil: "networkidle" }); + await ui.fill("serif"); + await other.waitForFunction(() => getComputedStyle(document.body).fontFamily.startsWith("serif,")); + await uiWeight.fill("300"); + await other.waitForFunction(() => getComputedStyle(document.body).fontWeight === "300"); + await resetUi.click(); + await other.waitForFunction(() => !document.documentElement.style.getPropertyValue("--font-ui") && getComputedStyle(document.body).fontWeight === "400"); + } finally { + await other.close(); + } + assert.equal(await mono.inputValue(), "monospace", "Resetting the UI font must preserve the code font"); + assert.equal(await uiWeight.inputValue(), "400"); + assert.equal(await monoWeight.inputValue(), "600"); + await ui.fill("serif"); + await uiWeight.fill("500"); + await resetMono.click(); + assert.equal(await ui.inputValue(), "serif", "Resetting the code font must preserve the UI font"); + assert.equal(await uiWeight.inputValue(), "500"); + assert.equal(await monoWeight.inputValue(), "400"); + await resetUi.click(); + await uiWeight.fill("600"); + assert.equal(await resetUi.isDisabled(), false, "Weight alone must enable Reset"); + await resetUi.click(); + + // Changing font metrics must refit a draft without requiring another keystroke. + await page.keyboard.press("Escape"); + const textarea = page.locator(".chat-input-textarea"); + await textarea.fill("iiii llll mmmm wwww sample text ".repeat(12)); + await openSettings(); + await ui.fill("serif"); + const serifHeight = await textarea.evaluate((el) => el.clientHeight); + await ui.fill("monospace"); + await page.waitForFunction((oldHeight) => { + const input = document.querySelector(".chat-input-textarea"); + return input.clientHeight !== oldHeight && (input.scrollHeight <= input.clientHeight + 1 || input.clientHeight >= 199); + }, serifHeight); + await resetUi.click(); + + assert.deepEqual(await page.evaluate(() => ({ + ui: localStorage.getItem("pi-ui-font-family"), + mono: localStorage.getItem("pi-mono-font-family"), + uiWeight: localStorage.getItem("pi-ui-font-weight"), + monoWeight: localStorage.getItem("pi-mono-font-weight"), + width: localStorage.getItem("pi-chat-content-width"), + fontSize: localStorage.getItem("pi-chat-content-font-size"), + })), { ui: null, mono: null, uiWeight: null, monoWeight: null, width: "820", fontSize: "14" }); + assert.equal(await resetUi.isDisabled(), true); + assert.equal(await resetMono.isDisabled(), true); + console.log("PASS: font families and weights, persistence, cross-tab sync, independent reset, emphasis and draft resizing"); +} diff --git a/e2e/terminal.mjs b/e2e/terminal.mjs index b23afa12fb..c3ac7f6e74 100644 --- a/e2e/terminal.mjs +++ b/e2e/terminal.mjs @@ -69,6 +69,12 @@ try { }); const ready = () => page.locator(".terminal-panel:visible .is-ready").waitFor(); const text = () => page.locator(".terminal-panel:visible .xterm-rows").innerText(); + // xterm replaces its character spans asynchronously after a font change/refit. + const expectFontWeight = (weight, bold = false) => page.waitForFunction(({ weight, bold }) => { + const panel = [...document.querySelectorAll(".terminal-panel")].find((element) => element.getBoundingClientRect().width > 0); + const character = panel?.querySelector(`.xterm-rows span${bold ? ".xterm-bold" : ":not(.xterm-bold)"}`); + return character && getComputedStyle(character).fontWeight === String(weight); + }, { weight, bold }); const run = async (command) => { await page.locator(".terminal-panel:visible .xterm-helper-textarea").focus(); await page.keyboard.type(command); @@ -99,6 +105,32 @@ try { const [id] = created; assert.equal(created.size, 1); + // A font change refits an existing xterm without restarting its shell or SSE. + const fontConnections = []; + const trackFontConnections = (request) => { + if (/\/api\/terminal(?:$|\/[^/]+\/events)/.test(new URL(request.url()).pathname)) fontConnections.push(request.url()); + }; + page.on("request", trackFontConnections); + await hidePanel(); + await showSidebar(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("textbox", { name: "Monospace font", exact: true }).fill("monospace"); + await page.getByRole("slider", { name: "Monospace font weight", exact: true }).fill("600"); + await page.keyboard.press("Escape"); + if (viewport.width <= 640) { + await page.locator(".sidebar-overlay-backdrop").click({ position: { x: viewport.width - 5, y: 100 } }); + } + await showPanel(); + await ready(); + assert.match(await page.locator(".terminal-panel:visible .xterm-rows").evaluate((el) => getComputedStyle(el).fontFamily), /^monospace,/); + await expectFontWeight(600); + await run("printf '\\nFONT:%s:%s\\n' \"$PR695_TOKEN\" \"$$\""); + await waitOutput(`FONT:alive:${pid}`); + await run("printf '\\033[1mBOLD_FONT\\033[0m\\n'"); + await expectFontWeight(700, true); + page.off("request", trackFontConnections); + assert.deepEqual(fontConnections, [], "Font changes must not recreate terminals or reconnect streams"); + await hidePanel(); await showSidebar(); await page.getByText("note.txt", { exact: true }).click(); @@ -116,6 +148,7 @@ try { await page.reload(); await ready(); + await expectFontWeight(600); await run("printf '\\nREFRESH:%s:%s\\n' \"$PR695_TOKEN\" \"$$\""); await waitOutput(`REFRESH:alive:${pid}`); assert.equal(created.size, 1, "refresh must reconnect, not create"); diff --git a/hooks/useFontPreferences.test.mjs b/hooks/useFontPreferences.test.mjs new file mode 100644 index 0000000000..0a8cb720f4 --- /dev/null +++ b/hooks/useFontPreferences.test.mjs @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { Script } from "node:vm"; +import ts from "typescript"; +import * as fonts from "../lib/font-preferences.ts"; + +const source = readFileSync(new URL("./useFontPreferences.ts", import.meta.url), "utf8"); +const script = new Script(ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, +}).outputText); + +function fixture({ initial = {}, blocked = false, server = false } = {}) { + const stored = new Map(Object.entries(initial)); + const applied = new Map(); + const events = new Map(); + const storage = { + getItem(key) { if (blocked) throw new Error("blocked"); return stored.get(key) ?? null; }, + setItem(key, value) { if (blocked) throw new Error("blocked"); stored.set(key, value); }, + removeItem(key) { if (blocked) throw new Error("blocked"); stored.delete(key); }, + }; + const exports = {}; + script.runInNewContext({ + exports, + require(id) { + if (id === "@/lib/font-preferences") return fonts; + if (id === "react") return { useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot() }; + throw new Error(`Unexpected import ${id}`); + }, + ...(!server && { + window: { + localStorage: storage, + addEventListener(type, listener) { assert.equal(events.has(type), false); events.set(type, listener); }, + removeEventListener(type, listener) { assert.equal(events.get(type), listener); events.delete(type); }, + }, + document: { documentElement: { style: { + setProperty(key, value) { applied.set(key, value); }, + removeProperty(key) { applied.delete(key); }, + } } }, + }), + }); + return { ...exports, stored, applied, events }; +} + +test("server rendering uses stable defaults without touching browser APIs", () => { + const store = fixture({ server: true }); + assert.equal(store.useFontPreferences().ui, ""); + assert.equal(store.useFontPreferences().mono, ""); + assert.equal(store.useFontPreferences().uiWeight, 400); + assert.equal(store.useFontPreferences().monoWeight, 400); +}); + +test("mounting restores both fonts without opening Settings", () => { + const store = fixture({ initial: { [fonts.FONT_STORAGE_KEYS.ui]: "Georgia", [fonts.FONT_STORAGE_KEYS.mono]: "Menlo" } }); + assert.equal(store.useFontPreferences().ui, "Georgia"); + assert.equal(store.useFontPreferences().mono, "Menlo"); + assert.equal(store.applied.get("--font-ui"), '"Georgia", var(--font-ui-default)'); + assert.equal(store.applied.get("--font-mono"), '"Menlo", var(--font-mono-default)'); +}); + +test("weight edits preserve font names, notify consumers and reset one font atomically", () => { + const store = fixture({ initial: { + [fonts.FONT_STORAGE_KEYS.ui]: "Georgia", + [fonts.FONT_STORAGE_KEYS.mono]: "Menlo", + [fonts.FONT_STORAGE_KEYS.monoWeight]: "600", + "pi-chat-content-font-size": "18", + } }); + let notifications = 0; + const stop = store.subscribeFontPreferences(() => notifications++); + assert.equal(store.useFontPreferences().uiWeight, 400, "older preferences retain Regular"); + assert.equal(store.useFontPreferences().monoWeight, 600); + assert.equal(store.applied.get("--font-mono-weight"), "600"); + store.setFontWeight("ui", 500); + assert.equal(store.useFontPreferences().ui, "Georgia"); + assert.equal(store.applied.get("--font-ui-weight"), "500"); + assert.equal(store.stored.get(fonts.FONT_STORAGE_KEYS.uiWeight), "500"); + store.setFontPreference("ui", "Arial"); + assert.equal(store.useFontPreferences().uiWeight, 500, "a new family keeps the chosen weight"); + store.setFontWeight("ui", 500); + assert.equal(notifications, 2, "an unchanged weight does not notify"); + store.resetFontPreference("ui"); + assert.equal(notifications, 3, "resetting family and weight is one update"); + assert.equal(store.useFontPreferences().ui, ""); + assert.equal(store.useFontPreferences().uiWeight, 400); + assert.equal(store.applied.has("--font-ui"), false); + assert.equal(store.applied.has("--font-ui-weight"), false); + assert.equal(store.stored.has(fonts.FONT_STORAGE_KEYS.uiWeight), false); + assert.equal(store.useFontPreferences().mono, "Menlo"); + assert.equal(store.useFontPreferences().monoWeight, 600); + assert.equal(store.stored.get("pi-chat-content-font-size"), "18"); + store.setFontWeight("mono", 400); + assert.equal(store.applied.has("--font-mono-weight"), false); + assert.equal(store.stored.has(fonts.FONT_STORAGE_KEYS.monoWeight), false); + assert.equal(store.useFontPreferences().mono, "Menlo"); + stop(); +}); + +test("edits notify all consumers, preserve typed spaces and reset each font independently", () => { + const store = fixture(); + let notifications = 0; + const stop = store.subscribeFontPreferences(() => notifications++); + let terminalNotifications = 0; + const stopTerminal = store.subscribeFontPreferences(() => terminalNotifications++); + store.setFontPreference("ui", "Times New "); + store.setFontPreference("mono", "Menlo"); + assert.equal(store.useFontPreferences().ui, "Times New "); + assert.equal(store.stored.get(fonts.FONT_STORAGE_KEYS.ui), "Times New "); + assert.equal(notifications, 2); + assert.equal(terminalNotifications, 2); + store.setFontPreference("mono", "Menlo"); + assert.equal(notifications, 2, "unchanged values do not notify"); + store.setFontPreference("ui", ""); + assert.equal(store.applied.has("--font-ui"), false); + assert.equal(store.stored.has(fonts.FONT_STORAGE_KEYS.ui), false); + assert.equal(store.useFontPreferences().mono, "Menlo"); + assert.equal(store.applied.get("--font-mono"), '"Menlo", var(--font-mono-default)'); + stop(); + assert.equal(store.events.has("storage"), true); + stopTerminal(); + assert.equal(store.events.size, 0); +}); + +test("storage changes in another tab, including clear, update the current page", () => { + const store = fixture(); + let notifications = 0; + const stop = store.subscribeFontPreferences(() => notifications++); + store.stored.set(fonts.FONT_STORAGE_KEYS.ui, "Arial"); + store.events.get("storage")({ key: "unrelated" }); + assert.equal(notifications, 0); + store.events.get("storage")({ key: fonts.FONT_STORAGE_KEYS.ui }); + assert.equal(store.useFontPreferences().ui, "Arial"); + assert.equal(store.applied.get("--font-ui"), '"Arial", var(--font-ui-default)'); + store.stored.set(fonts.FONT_STORAGE_KEYS.uiWeight, "500"); + store.events.get("storage")({ key: fonts.FONT_STORAGE_KEYS.uiWeight }); + assert.equal(store.useFontPreferences().uiWeight, 500); + assert.equal(store.applied.get("--font-ui-weight"), "500"); + store.stored.clear(); + store.events.get("storage")({ key: null }); + assert.equal(store.useFontPreferences().ui, ""); + assert.equal(store.applied.size, 0); + assert.equal(store.useFontPreferences().uiWeight, 400); + assert.equal(notifications, 3); + stop(); +}); + +test("blocked storage and malformed saved values leave usable defaults and live editing", () => { + const invalid = fixture({ initial: { + [fonts.FONT_STORAGE_KEYS.ui]: "a".repeat(fonts.FONT_FAMILY_MAX_LENGTH + 1), + [fonts.FONT_STORAGE_KEYS.uiWeight]: "Light", + [fonts.FONT_STORAGE_KEYS.monoWeight]: "1000", + } }); + assert.equal(invalid.useFontPreferences().ui, ""); + assert.equal(invalid.useFontPreferences().uiWeight, 400); + assert.equal(invalid.useFontPreferences().monoWeight, 400); + assert.equal(invalid.applied.size, 0); + const blocked = fixture({ blocked: true }); + assert.equal(blocked.useFontPreferences().mono, ""); + blocked.setFontPreference("mono", "monospace"); + assert.equal(blocked.useFontPreferences().mono, "monospace"); + assert.equal(blocked.applied.get("--font-mono"), "monospace, var(--font-mono-default)"); + blocked.setFontWeight("mono", 300); + assert.equal(blocked.useFontPreferences().monoWeight, 300); + assert.equal(blocked.applied.get("--font-mono-weight"), "300"); + blocked.resetFontPreference("mono"); + assert.equal(blocked.applied.size, 0); +}); diff --git a/hooks/useFontPreferences.ts b/hooks/useFontPreferences.ts new file mode 100644 index 0000000000..7c6a275e99 --- /dev/null +++ b/hooks/useFontPreferences.ts @@ -0,0 +1,113 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { + DEFAULT_FONT_PREFERENCES, + FONT_STORAGE_KEYS, + FONT_WEIGHT_DEFAULT, + fontFamilyOverride, + readFontPreference, + readFontWeight, + type FontKind, + type FontPreferences, +} from "@/lib/font-preferences"; + +const FONT_KINDS: FontKind[] = ["ui", "mono"]; +const PREFERENCE_KEYS = Object.keys(FONT_STORAGE_KEYS) as (keyof FontPreferences)[]; +let preferences: FontPreferences | null = null; +const listeners = new Set<() => void>(); + +function readStoredPreferences(): FontPreferences { + const next = { ...DEFAULT_FONT_PREFERENCES }; + for (const kind of FONT_KINDS) { + try { + next[kind] = readFontPreference(window.localStorage.getItem(FONT_STORAGE_KEYS[kind])); + const weightKey = `${kind}Weight` as const; + next[weightKey] = readFontWeight(window.localStorage.getItem(FONT_STORAGE_KEYS[weightKey])); + } catch { + // Browser preferences remain usable when storage is blocked. + } + } + return next; +} + +function applyPreferences(next: FontPreferences): void { + for (const kind of FONT_KINDS) { + const property = `--font-${kind}`; + const value = fontFamilyOverride(kind, next[kind]); + if (value) document.documentElement.style.setProperty(property, value); + else document.documentElement.style.removeProperty(property); + const weight = next[`${kind}Weight`]; + if (weight === FONT_WEIGHT_DEFAULT) document.documentElement.style.removeProperty(`${property}-weight`); + else document.documentElement.style.setProperty(`${property}-weight`, String(weight)); + } +} + +function getSnapshot(): FontPreferences { + if (typeof window === "undefined") return DEFAULT_FONT_PREFERENCES; + if (!preferences) { + preferences = readStoredPreferences(); + applyPreferences(preferences); + } + return preferences; +} + +function notify(): void { + listeners.forEach((listener) => listener()); +} + +function onStorage(event: StorageEvent): void { + if (event.key !== null && !Object.values(FONT_STORAGE_KEYS).includes(event.key)) return; + const next = readStoredPreferences(); + const current = getSnapshot(); + if (PREFERENCE_KEYS.every((key) => next[key] === current[key])) return; + preferences = next; + applyPreferences(next); + notify(); +} + +/** Also used by the terminal: changing a font must not recreate its shell or stream. */ +export function subscribeFontPreferences(listener: () => void): () => void { + if (listeners.size === 0) window.addEventListener("storage", onStorage); + listeners.add(listener); + getSnapshot(); + return () => { + listeners.delete(listener); + if (listeners.size === 0) window.removeEventListener("storage", onStorage); + }; +} + +function setPreferences(next: FontPreferences): void { + const current = getSnapshot(); + const changedKeys = PREFERENCE_KEYS.filter((key) => current[key] !== next[key]); + if (!changedKeys.length) return; + preferences = next; + applyPreferences(next); + for (const key of changedKeys) { + try { + if (next[key] === DEFAULT_FONT_PREFERENCES[key]) window.localStorage.removeItem(FONT_STORAGE_KEYS[key]); + else window.localStorage.setItem(FONT_STORAGE_KEYS[key], String(next[key])); + } catch { + // Apply in memory even if the browser cannot save the preference. + } + } + notify(); +} + +export function setFontPreference(kind: FontKind, value: string): void { + setPreferences({ ...getSnapshot(), [kind]: readFontPreference(value) }); +} + +export function setFontWeight(kind: FontKind, value: number): void { + setPreferences({ ...getSnapshot(), [`${kind}Weight`]: readFontWeight(value) }); +} + +/** Reset this font's family and weight together, leaving the other font unchanged. */ +export function resetFontPreference(kind: FontKind): void { + setPreferences({ ...getSnapshot(), [kind]: "", [`${kind}Weight`]: FONT_WEIGHT_DEFAULT }); +} + +export function useFontPreferences() { + const snapshot = useSyncExternalStore(subscribeFontPreferences, getSnapshot, () => DEFAULT_FONT_PREFERENCES); + return { ...snapshot, setFontPreference, setFontWeight, resetFontPreference }; +} diff --git a/lib/font-preferences.test.mjs b/lib/font-preferences.test.mjs new file mode 100644 index 0000000000..ab329b53c7 --- /dev/null +++ b/lib/font-preferences.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { FONT_FAMILY_MAX_LENGTH, FONT_WEIGHT_OPTIONS, fontFamilyCss, fontFamilyOverride, readFontPreference, readFontWeight } from "./font-preferences.ts"; + +test("font preferences preserve editable whitespace and bound malformed storage", () => { + assert.equal(readFontPreference("Times New "), "Times New "); + for (const value of [null, undefined, 123, {}, "a".repeat(FONT_FAMILY_MAX_LENGTH + 1)]) { + assert.equal(readFontPreference(value), ""); + } + assert.equal(readFontPreference("a".repeat(FONT_FAMILY_MAX_LENGTH)).length, FONT_FAMILY_MAX_LENGTH); +}); + +test("font weights default to Regular, accept 100-600 and reject heavier or invalid storage", () => { + assert.deepEqual(FONT_WEIGHT_OPTIONS.map(({ value }) => value), [100, 200, 300, 400, 500, 600]); + for (const { value } of FONT_WEIGHT_OPTIONS) { + assert.equal(readFontWeight(value), value); + assert.equal(readFontWeight(String(value)), value); + } + for (const value of [undefined, null, "", "Light", "normal", "400px", {}, true, 0, -100, 700, 900, 1000, 550, NaN, Infinity]) { + assert.equal(readFontWeight(value), 400); + } +}); + +test("font names support lists, Chinese, optional quotes, and explicit generic families", () => { + assert.equal(fontFamilyCss(' JetBrains Mono, "PingFang SC", 微软雅黑, monospace, '), '"JetBrains Mono", "PingFang SC", "微软雅黑", monospace'); + assert.equal(fontFamilyCss("'Times New Roman', SERIF"), '"Times New Roman", serif'); + assert.equal(fontFamilyCss('"monospace", system-ui'), '"monospace", system-ui'); + assert.equal(fontFamilyCss(" , , \t"), ""); + assert.equal(fontFamilyCss('""'), ""); + assert.equal(fontFamilyCss(null), ""); +}); + +test("names are CSS string data, not variables, keywords, declarations or downloads", () => { + for (const value of ['A "quoted" font', "C:\\fonts", "inherit", "initial", "var(--font-ui)", "url(https://example.com/font.woff2)", 'x\";color:red}/*', ""]) { + assert.equal(fontFamilyCss(value), JSON.stringify(value)); + } +}); + +test("custom fonts retain the original stacks and empty input removes only the override", () => { + assert.equal(fontFamilyOverride("ui", "Arial"), '"Arial", var(--font-ui-default)'); + assert.equal(fontFamilyOverride("mono", "Menlo, monospace"), '"Menlo", monospace, var(--font-mono-default)'); + assert.equal(fontFamilyOverride("ui", ""), ""); + assert.equal(fontFamilyOverride("mono", " , "), ""); +}); diff --git a/lib/font-preferences.ts b/lib/font-preferences.ts new file mode 100644 index 0000000000..901290eb8f --- /dev/null +++ b/lib/font-preferences.ts @@ -0,0 +1,65 @@ +export type FontKind = "ui" | "mono"; +/** + * Base weights stop at Semi Bold (600): headings and labels use 600, bold text, + * syntax-highlighted bold and the terminal's bold use 700. A heavier base would + * make ordinary text heavier than the text meant to stand out. + */ +export const FONT_WEIGHT_OPTIONS = [ + { value: 100, label: "settings.fontWeightThin" }, + { value: 200, label: "settings.fontWeightExtraLight" }, + { value: 300, label: "settings.fontWeightLight" }, + { value: 400, label: "settings.fontWeightRegular" }, + { value: 500, label: "settings.fontWeightMedium" }, + { value: 600, label: "settings.fontWeightSemiBold" }, +] as const; +export type FontWeight = (typeof FONT_WEIGHT_OPTIONS)[number]["value"]; +export type FontPreferences = Record & Record<`${FontKind}Weight`, FontWeight>; + +export const FONT_FAMILY_MAX_LENGTH = 500; +export const FONT_WEIGHT_DEFAULT: FontWeight = 400; +export const FONT_STORAGE_KEYS: Record = { + ui: "pi-ui-font-family", + mono: "pi-mono-font-family", + uiWeight: "pi-ui-font-weight", + monoWeight: "pi-mono-font-weight", +}; +export const DEFAULT_FONT_PREFERENCES: FontPreferences = { + ui: "", mono: "", uiWeight: FONT_WEIGHT_DEFAULT, monoWeight: FONT_WEIGHT_DEFAULT, +}; + +/** Missing, malformed or unsupported weights keep the original Regular (400). */ +export function readFontWeight(value: unknown): FontWeight { + const weight = typeof value === "string" ? Number(value) : value; + return FONT_WEIGHT_OPTIONS.find((option) => option.value === weight)?.value ?? FONT_WEIGHT_DEFAULT; +} + +/** Keep the input's whitespace while typing; malformed stored values use the default. */ +export function readFontPreference(value: unknown): string { + return typeof value === "string" && value.length <= FONT_FAMILY_MAX_LENGTH ? value : ""; +} + +const GENERIC_FAMILIES = new Set([ + "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", + "ui-serif", "ui-sans-serif", "ui-monospace", "ui-rounded", +]); + +/** + * A comma-separated list of font names, not arbitrary CSS. Quote each name so + * punctuation, CSS keywords and pasted declarations can never alter styles. + * Optional enclosing quotes are accepted; commas always separate names. + */ +export function fontFamilyCss(value: unknown): string { + return readFontPreference(value).split(",").flatMap((part) => { + let name = part.trim().replace(/\s+/g, " "); + const quoted = name.length >= 2 && (name[0] === '"' || name[0] === "'") && name.endsWith(name[0]); + if (quoted) name = name.slice(1, -1).trim(); + if (!name) return []; + return [!quoted && GENERIC_FAMILIES.has(name.toLowerCase()) ? name.toLowerCase() : JSON.stringify(name)]; + }).join(", "); +} + +/** Custom names come first, but unavailable fonts retain the original fallback stack. */ +export function fontFamilyOverride(kind: FontKind, value: unknown): string { + const family = fontFamilyCss(value); + return family ? `${family}, var(--font-${kind}-default)` : ""; +} diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 0bb5253766..ced9a4c66a 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -19,6 +19,22 @@ export const enLocale: LocalePlugin = { "settings.appearanceDescription": "Select a theme or follow your system preference.", "settings.chatContentWidth": "Chat content width", "settings.chatContentFontSize": "Chat font size", + "settings.typography": "Fonts & layout", + "settings.uiFontFamily": "Interface font", + "settings.monoFontFamily": "Monospace font", + "settings.uiFontPlaceholder": "System default", + "settings.monoFontPlaceholder": "Default: Noto Sans Mono", + "settings.resetUiFont": "Reset interface font", + "settings.resetMonoFont": "Reset monospace font", + "settings.fontDescription": "Names of fonts installed on this device, comma-separated; leave blank for the default. The monospace font is used for code and the terminal. Saved in this browser only.", + "settings.uiFontWeight": "Interface font weight", + "settings.monoFontWeight": "Monospace font weight", + "settings.fontWeightThin": "Thin", + "settings.fontWeightExtraLight": "Extra Light", + "settings.fontWeightLight": "Light", + "settings.fontWeightRegular": "Regular", + "settings.fontWeightMedium": "Medium", + "settings.fontWeightSemiBold": "Semi Bold", "settings.resetChatContentWidth": "Reset chat content width", "settings.resetChatContentFontSize": "Reset chat font size", "settings.chatContentWidthDescription": "Set the maximum width of messages and the composer.", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index eabdadb90b..a2521579ba 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -19,6 +19,22 @@ export const zhCNLocale: LocalePlugin = { "settings.appearanceDescription": "选择浅色或深色主题,也可以跟随系统设置。", "settings.chatContentWidth": "聊天内容宽度", "settings.chatContentFontSize": "聊天字体大小", + "settings.typography": "字体与排版", + "settings.uiFontFamily": "界面字体", + "settings.monoFontFamily": "等宽字体", + "settings.uiFontPlaceholder": "系统默认", + "settings.monoFontPlaceholder": "默认 Noto Sans Mono", + "settings.resetUiFont": "重置界面字体", + "settings.resetMonoFont": "重置等宽字体", + "settings.fontDescription": "填写本机已安装的字体名,多个用逗号分隔,留空使用默认。等宽字体用于代码和终端。仅保存在当前浏览器。", + "settings.uiFontWeight": "界面字体字重", + "settings.monoFontWeight": "等宽字体字重", + "settings.fontWeightThin": "极细", + "settings.fontWeightExtraLight": "特细", + "settings.fontWeightLight": "细体", + "settings.fontWeightRegular": "常规", + "settings.fontWeightMedium": "中等", + "settings.fontWeightSemiBold": "半粗", "settings.resetChatContentWidth": "重置聊天内容宽度", "settings.resetChatContentFontSize": "重置聊天字体大小", "settings.chatContentWidthDescription": "设置消息内容和输入框的最大宽度。", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index 3c18dc63cc..07c118d14b 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -19,6 +19,22 @@ export const zhTWLocale: LocalePlugin = { "settings.appearanceDescription": "選擇淺色或深色主題,也可以跟隨系統設定。", "settings.chatContentWidth": "聊天內容寬度", "settings.chatContentFontSize": "聊天字體大小", + "settings.typography": "字體與排版", + "settings.uiFontFamily": "介面字體", + "settings.monoFontFamily": "等寬字體", + "settings.uiFontPlaceholder": "系統預設", + "settings.monoFontPlaceholder": "預設 Noto Sans Mono", + "settings.resetUiFont": "重設介面字體", + "settings.resetMonoFont": "重設等寬字體", + "settings.fontDescription": "填寫本機已安裝的字體名稱,多個以逗號分隔,留空使用預設。等寬字體用於程式碼和終端。僅儲存在目前瀏覽器。", + "settings.uiFontWeight": "介面字體字重", + "settings.monoFontWeight": "等寬字體字重", + "settings.fontWeightThin": "極細", + "settings.fontWeightExtraLight": "特細", + "settings.fontWeightLight": "細體", + "settings.fontWeightRegular": "一般", + "settings.fontWeightMedium": "中等", + "settings.fontWeightSemiBold": "半粗", "settings.resetChatContentWidth": "重設聊天內容寬度", "settings.resetChatContentFontSize": "重設聊天字體大小", "settings.chatContentWidthDescription": "設定訊息內容和輸入框的最大寬度。",