-
Notifications
You must be signed in to change notification settings - Fork 3.6k
feat(web): configurable fonts and sizes under Settings → Appearance #5103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
StiensWout
wants to merge
31
commits into
pingdotgg:main
Choose a base branch
from
StiensWout:agent/web-appearance-fonts
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,339
−60
Open
Changes from all commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
316c001
feat(web): configurable font families under Settings → Appearance
StiensWout 3aaf1b3
feat(web): font dropdowns with availability filtering and surface pre…
StiensWout dc3ebeb
fix(web): make code and terminal font previews follow the selection
StiensWout 80612c4
fix(web): reliable font availability probing and theme-consistent pre…
StiensWout 0d7b5f5
test(desktop): add font-family keys to the client settings fixture
StiensWout a31d2bc
fix(web): route body and code font-family through the theme tokens
StiensWout d423e00
fix(web): survive async settings hydration and group font dropdowns
StiensWout bec7059
feat(web): unified font catalog per dropdown and draft-committed cust…
StiensWout 47b1e92
fix(web): route shadow-root code surfaces through the appearance font…
StiensWout df68214
fix(web): include font settings in restore defaults and align compose…
StiensWout 1218240
refactor(web): scope font settings to the prompt textarea and terminal
StiensWout eadee77
fix(web): commit an explicit clear of the custom font input
StiensWout 4e4cf0f
fix(web): plainer copy for the font settings rows
StiensWout d932f98
feat(web): restore all four font settings with a calmer custom input
StiensWout 61df965
fix(web): polish pass on font settings
StiensWout 2e35470
fix(web): stop the custom-font shift and normalize apparent font size
StiensWout 9001d78
fix(web): start the custom font field empty
StiensWout 3bafffd
fix(web): plainer font setting descriptions
StiensWout 1a3481a
fix(web): cap the size correction for faces with unusual proportions
StiensWout ddb3b48
docs(web): correct the size-adjust docblock after clamping
StiensWout 04c529a
feat(web): font size sliders instead of automatic size normalization
StiensWout 162d5b4
feat(web): collapse the composer context strip to icons when text out…
StiensWout ed70fa4
fix(web): keep proportional fonts out of the grid surfaces, collapse …
StiensWout 2977bae
fix(web): refuse proportional faces in the terminal grid
StiensWout c299943
feat(web): slide the terminal font size with the canvas width
StiensWout 0fcaa3b
fix(web): truncate the context strip before collapsing, measure conte…
StiensWout 7580c5d
feat(web): animate the context strip label collapse
StiensWout c2677c2
fix(web): consume the code size token, stop focus theft, gate termina…
StiensWout 805813e
fix(web): include the environment label in the collapse, gate the cod…
StiensWout 5ce99c2
fix(web): commit slider steps reliably, scope pre sizing, measure nes…
StiensWout a6f5f65
fix(web): apply the prompt-size floor only on touch devices
StiensWout File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
|
|
||
| import { | ||
| clampCodeFontSize, | ||
| clampInterfaceFontSize, | ||
| clampPromptFontSize, | ||
| DEFAULT_CODE_FONT_STACK, | ||
| DEFAULT_SANS_FONT_STACK, | ||
| MONO_FONT_OPTIONS, | ||
| SANS_FONT_OPTIONS, | ||
| appearanceFontStack, | ||
| cssFontFamilies, | ||
| fontOptionCategories, | ||
| } from "./appearanceFonts"; | ||
|
|
||
| describe("cssFontFamilies", () => { | ||
| it("returns null for effectively empty input", () => { | ||
| expect(cssFontFamilies("")).toBeNull(); | ||
| expect(cssFontFamilies(" ")).toBeNull(); | ||
| expect(cssFontFamilies(" , , ")).toBeNull(); | ||
| }); | ||
|
|
||
| it("quotes names with spaces and keeps single idents bare", () => { | ||
| expect(cssFontFamilies("Fira Code")).toBe('"Fira Code"'); | ||
| expect(cssFontFamilies("monospace")).toBe("monospace"); | ||
| expect(cssFontFamilies('"Comic Mono"')).toBe('"Comic Mono"'); | ||
| }); | ||
|
|
||
| it("normalizes comma-separated lists and strips embedded quotes", () => { | ||
| expect(cssFontFamilies(" Fira Code , Menlo ")).toBe('"Fira Code", Menlo'); | ||
| expect(cssFontFamilies('Bad"Name')).toBe('"BadName"'); | ||
| }); | ||
|
|
||
| it("quotes names that are not single CSS idents", () => { | ||
| expect(cssFontFamilies("3270 Nerd Font")).toBe('"3270 Nerd Font"'); | ||
| expect(cssFontFamilies("M+ 1m")).toBe('"M+ 1m"'); | ||
| }); | ||
| }); | ||
|
|
||
| describe("fontOptionCategories", () => { | ||
| it("splits a mixed list into labeled sections preserving catalog order", () => { | ||
| const mixed = [...SANS_FONT_OPTIONS.slice(0, 2), ...MONO_FONT_OPTIONS.slice(0, 2)]; | ||
| const sections = fontOptionCategories(mixed); | ||
| expect(sections.map(([category]) => category)).toEqual(["Sans serif", "Monospace"]); | ||
| expect(sections[0]?.[1]).toEqual(SANS_FONT_OPTIONS.slice(0, 2)); | ||
| expect(sections[1]?.[1]).toEqual(MONO_FONT_OPTIONS.slice(0, 2)); | ||
| }); | ||
|
|
||
| it("keeps a single-category list in one unlabeled-ready section", () => { | ||
| const sections = fontOptionCategories(MONO_FONT_OPTIONS); | ||
| expect(sections).toHaveLength(1); | ||
| expect(sections[0]?.[0]).toBe("Monospace"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("appearanceFontStack", () => { | ||
| it("prepends the custom family to the default stack", () => { | ||
| expect(appearanceFontStack("Fira Code", DEFAULT_CODE_FONT_STACK)).toBe( | ||
| `"Fira Code", ${DEFAULT_CODE_FONT_STACK}`, | ||
| ); | ||
| }); | ||
|
|
||
| it("falls back to the default stack when unset", () => { | ||
| expect(appearanceFontStack("", DEFAULT_SANS_FONT_STACK)).toBe(DEFAULT_SANS_FONT_STACK); | ||
| }); | ||
| }); | ||
|
|
||
| describe("font size clamping", () => { | ||
| it("keeps sizes inside the ranges the UI can absorb", () => { | ||
| expect(clampInterfaceFontSize(16)).toBe(16); | ||
| expect(clampInterfaceFontSize(2)).toBe(12); | ||
| expect(clampInterfaceFontSize(96)).toBe(20); | ||
| expect(clampPromptFontSize(40)).toBe(20); | ||
| expect(clampCodeFontSize(1)).toBe(10); | ||
| }); | ||
|
|
||
| it("rounds fractional values and falls back for unusable input", () => { | ||
| expect(clampCodeFontSize(13.4)).toBe(13); | ||
| expect(clampInterfaceFontSize(Number.NaN)).toBe(16); | ||
| expect(clampPromptFontSize(Number.POSITIVE_INFINITY)).toBe(14); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,255 @@ | ||
| /** | ||
| * Font preferences from Settings → Appearance, applied as CSS custom | ||
| * properties. The default stacks mirror the `--font-sans` / `--font-mono` | ||
| * definitions in `index.css`; a custom family is always prepended to the | ||
| * matching default stack so glyph coverage never regresses. | ||
| */ | ||
|
|
||
| import { | ||
| DEFAULT_CODE_FONT_SIZE, | ||
| DEFAULT_INTERFACE_FONT_SIZE, | ||
| DEFAULT_PROMPT_FONT_SIZE, | ||
| MAX_CODE_FONT_SIZE, | ||
| MAX_INTERFACE_FONT_SIZE, | ||
| MAX_PROMPT_FONT_SIZE, | ||
| MIN_CODE_FONT_SIZE, | ||
| MIN_INTERFACE_FONT_SIZE, | ||
| MIN_PROMPT_FONT_SIZE, | ||
| } from "@t3tools/contracts"; | ||
|
|
||
| export const DEFAULT_SANS_FONT_STACK = | ||
| '"DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, ' + | ||
| "sans-serif"; | ||
|
|
||
| export const DEFAULT_CODE_FONT_STACK = | ||
| '"SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace'; | ||
|
|
||
| function quoteFontFamilyName(name: string): string { | ||
| const bare = name.trim(); | ||
| if (bare.length === 0) return ""; | ||
| // Already quoted, or a single ident that needs no quoting. | ||
| if (/^(['"]).*\1$/.test(bare)) return bare; | ||
| if (/^[a-zA-Z][a-zA-Z0-9-]*$/.test(bare)) return bare; | ||
| return `"${bare.replaceAll('"', "")}"`; | ||
| } | ||
|
|
||
| /** | ||
| * Normalize a user-entered family (single name or comma-separated list) into a | ||
| * safe CSS font-family list, or null when the input is effectively empty. | ||
| */ | ||
| export function cssFontFamilies(input: string): string | null { | ||
| const families = input | ||
| .split(",") | ||
| .map(quoteFontFamilyName) | ||
| .filter((name) => name.length > 0); | ||
| return families.length > 0 ? families.join(", ") : null; | ||
| } | ||
|
|
||
| /** The full stack a preference resolves to: custom families before the default. */ | ||
| export function appearanceFontStack(custom: string, defaultStack: string): string { | ||
| const families = cssFontFamilies(custom); | ||
| return families === null ? defaultStack : `${families}, ${defaultStack}`; | ||
| } | ||
|
|
||
| export interface AppearanceFontPreferences { | ||
| readonly sans: string; | ||
| readonly code: string; | ||
| readonly composer: string; | ||
| readonly sizeInterface: number; | ||
| readonly sizePrompt: number; | ||
| readonly sizeCode: number; | ||
| } | ||
|
|
||
| /** | ||
| * Apply the preferences to the root element. Unset families remove the | ||
| * override so the stylesheet defaults (and theme changes) stay in charge. | ||
| * | ||
| * Sizes are always written: the interface size drives the root font size (and | ||
| * with it every rem-based dimension), while the prompt and code sizes stay in | ||
| * absolute pixels so they do not scale twice. | ||
| */ | ||
| export function applyAppearanceFontVariables( | ||
| root: HTMLElement, | ||
| preferences: AppearanceFontPreferences, | ||
| ): void { | ||
| const families: ReadonlyArray<readonly [variable: string, custom: string, fallback: string]> = [ | ||
| ["--font-sans", preferences.sans, DEFAULT_SANS_FONT_STACK], | ||
| ["--font-mono", preferences.code, DEFAULT_CODE_FONT_STACK], | ||
| // The composer falls back to whatever the sans preference resolves to. | ||
| ["--font-composer", preferences.composer, "var(--font-sans)"], | ||
| ]; | ||
| for (const [variable, custom, fallback] of families) { | ||
| const list = cssFontFamilies(custom); | ||
| if (list === null) { | ||
| root.style.removeProperty(variable); | ||
| } else { | ||
| root.style.setProperty(variable, `${list}, ${fallback}`); | ||
| } | ||
| } | ||
|
|
||
| root.style.fontSize = `${clampInterfaceFontSize(preferences.sizeInterface)}px`; | ||
| root.style.setProperty("--font-size-prompt", `${clampPromptFontSize(preferences.sizePrompt)}px`); | ||
| const code = clampCodeFontSize(preferences.sizeCode); | ||
| root.style.setProperty("--font-size-code", `${code}px`); | ||
| // The @pierre/diffs surfaces read their own hook for code text. | ||
| root.style.setProperty("--diffs-font-size", `${code}px`); | ||
| } | ||
|
|
||
| function clampFontSize(value: number, minimum: number, maximum: number, fallback: number): number { | ||
| if (!Number.isFinite(value)) return fallback; | ||
| return Math.min(maximum, Math.max(minimum, Math.round(value))); | ||
| } | ||
|
|
||
| export function clampInterfaceFontSize(value: number): number { | ||
| return clampFontSize( | ||
| value, | ||
| MIN_INTERFACE_FONT_SIZE, | ||
| MAX_INTERFACE_FONT_SIZE, | ||
| DEFAULT_INTERFACE_FONT_SIZE, | ||
| ); | ||
| } | ||
|
|
||
| export function clampPromptFontSize(value: number): number { | ||
| return clampFontSize(value, MIN_PROMPT_FONT_SIZE, MAX_PROMPT_FONT_SIZE, DEFAULT_PROMPT_FONT_SIZE); | ||
| } | ||
|
|
||
| export function clampCodeFontSize(value: number): number { | ||
| return clampFontSize(value, MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE, DEFAULT_CODE_FONT_SIZE); | ||
| } | ||
|
|
||
| export type FontCategory = "Sans serif" | "Monospace"; | ||
|
|
||
| export interface FontOption { | ||
| readonly label: string; | ||
| readonly family: string; | ||
| readonly category: FontCategory; | ||
| } | ||
|
|
||
| function fontCatalog( | ||
| category: FontCategory, | ||
| entries: ReadonlyArray<Omit<FontOption, "category">>, | ||
| ): readonly FontOption[] { | ||
| return entries.map((entry) => ({ ...entry, category })); | ||
| } | ||
|
|
||
| /** | ||
| * Curated choices for the Appearance dropdowns. The settings UI filters these | ||
| * through `isFontFamilyAvailable`, so platforms only offer faces that will | ||
| * actually render; "Custom" in the UI covers everything else. The category | ||
| * groups mixed dropdowns (composer) into labeled sections. | ||
| */ | ||
| export const SANS_FONT_OPTIONS: readonly FontOption[] = fontCatalog("Sans serif", [ | ||
| // The bundled webfont registers as "DM Sans Variable", not "DM Sans"; the | ||
| // option must reference the registered name to resolve on every machine. | ||
| { label: "DM Sans", family: "DM Sans Variable" }, | ||
| { label: "Inter", family: "Inter" }, | ||
| { label: "SF Pro", family: "SF Pro Text" }, | ||
| { label: "Segoe UI", family: "Segoe UI" }, | ||
| { label: "Roboto", family: "Roboto" }, | ||
| { label: "Helvetica Neue", family: "Helvetica Neue" }, | ||
| { label: "Arial", family: "Arial" }, | ||
| { label: "System UI", family: "system-ui" }, | ||
| ]); | ||
|
|
||
| export const MONO_FONT_OPTIONS: readonly FontOption[] = fontCatalog("Monospace", [ | ||
| { label: "SF Mono", family: "SF Mono" }, | ||
| { label: "JetBrains Mono", family: "JetBrains Mono" }, | ||
| { label: "Fira Code", family: "Fira Code" }, | ||
| { label: "Cascadia Code", family: "Cascadia Code" }, | ||
| { label: "Menlo", family: "Menlo" }, | ||
| { label: "Monaco", family: "Monaco" }, | ||
| { label: "Consolas", family: "Consolas" }, | ||
| { label: "Source Code Pro", family: "Source Code Pro" }, | ||
| { label: "IBM Plex Mono", family: "IBM Plex Mono" }, | ||
| { label: "Ubuntu Mono", family: "Ubuntu Mono" }, | ||
| { label: "Courier New", family: "Courier New" }, | ||
| ]); | ||
|
|
||
| /** The options split into their labeled category sections, in catalog order. */ | ||
| export function fontOptionCategories( | ||
| options: readonly FontOption[], | ||
| ): ReadonlyArray<readonly [FontCategory, readonly FontOption[]]> { | ||
| const sections = new Map<FontCategory, FontOption[]>(); | ||
| for (const option of options) { | ||
| const section = sections.get(option.category); | ||
| if (section === undefined) { | ||
| sections.set(option.category, [option]); | ||
| } else { | ||
| section.push(option); | ||
| } | ||
| } | ||
| return [...sections.entries()]; | ||
| } | ||
|
|
||
| const FONT_PROBE_TEXT = "mmmmmmmmMMWli1O0@# fjord"; | ||
| let fontProbeContext: CanvasRenderingContext2D | null | undefined; | ||
|
|
||
| function probeWidth(fontList: string): number | null { | ||
| if (fontProbeContext === undefined) { | ||
| fontProbeContext = document.createElement("canvas").getContext("2d"); | ||
| } | ||
| if (fontProbeContext === null) return null; | ||
| fontProbeContext.font = `16px ${fontList}`; | ||
| return fontProbeContext.measureText(FONT_PROBE_TEXT).width; | ||
| } | ||
|
|
||
| /** | ||
| * Canvas metric probing instead of document.fonts.check(): check() reports | ||
| * true for families that are not installed at all (nothing needs loading), so | ||
| * it cannot filter the dropdown. A family exists when falling back to at | ||
| * least one generic changes the measured advance. | ||
| */ | ||
| export function isFontFamilyAvailable(family: string): boolean { | ||
| const families = cssFontFamilies(family); | ||
| if (families === null) return false; | ||
| if (/^(system-ui|sans-serif|serif|monospace|ui-monospace)$/i.test(families)) return true; | ||
| try { | ||
| for (const generic of ["monospace", "serif", "sans-serif"]) { | ||
| const baseline = probeWidth(generic); | ||
| const candidate = probeWidth(`${families}, ${generic}`); | ||
| if (baseline === null || candidate === null) return false; | ||
| if (candidate !== baseline) return true; | ||
| } | ||
| return false; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Whether a family renders every character on the same advance. Cell-grid | ||
| * surfaces (the terminal) require this: a proportional face draws its text | ||
| * narrower than the lattice the cursor and selection are placed on, which | ||
| * reads as ragged gaps and a cursor stranded to the right of the text. | ||
| * | ||
| * Unmeasurable environments answer true, so a missing canvas never blocks a | ||
| * legitimate font. | ||
| */ | ||
| export function isMonospaceFamily(family: string): boolean { | ||
| const families = cssFontFamilies(family); | ||
| if (families === null) return true; | ||
| try { | ||
| if (fontProbeContext === undefined) { | ||
| fontProbeContext = document.createElement("canvas").getContext("2d"); | ||
| } | ||
| if (fontProbeContext === null) return true; | ||
| // Fall back to a generic mono so an absent face measures as monospace and | ||
| // is left for the normal fallback chain to resolve. | ||
| fontProbeContext.font = `32px ${families}, monospace`; | ||
| const narrow = fontProbeContext.measureText("i").width; | ||
| const wide = fontProbeContext.measureText("M").width; | ||
| if (!Number.isFinite(narrow) || !Number.isFinite(wide) || wide === 0) return true; | ||
| return Math.abs(wide - narrow) < 0.5; | ||
| } catch { | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
| /** Webfonts the app bundles; offered even before document.fonts has loaded them. */ | ||
| const BUNDLED_FAMILIES = new Set(["DM Sans Variable", "JetBrains Mono"]); | ||
|
|
||
| export function availableFontOptions(options: readonly FontOption[]): readonly FontOption[] { | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
| return options.filter( | ||
| (option) => BUNDLED_FAMILIES.has(option.family) || isFontFamilyAvailable(option.family), | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.