-
Notifications
You must be signed in to change notification settings - Fork 3.6k
feat(web): configurable font families 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
14
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.
Open
Changes from all commits
Commits
Show all changes
14 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 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,39 @@ | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
|
|
||
| import { | ||
| DEFAULT_CODE_FONT_STACK, | ||
| DEFAULT_SANS_FONT_STACK, | ||
| appearanceFontStack, | ||
| cssFontFamilies, | ||
| } 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"'); | ||
| }); | ||
| }); | ||
|
|
||
| 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); | ||
| }); | ||
| }); |
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,175 @@ | ||
| /** | ||
| * Font-family 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. | ||
| */ | ||
|
|
||
| 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; | ||
| } | ||
|
|
||
| /** | ||
| * Apply the preferences to the root element. Unset preferences remove the | ||
| * override so the stylesheet defaults (and theme changes) stay in charge. | ||
| */ | ||
| export function applyAppearanceFontVariables( | ||
| root: HTMLElement, | ||
| preferences: AppearanceFontPreferences, | ||
| ): void { | ||
| const assignments: ReadonlyArray<readonly [string, string | null, string]> = [ | ||
| ["--font-sans", cssFontFamilies(preferences.sans), DEFAULT_SANS_FONT_STACK], | ||
| ["--font-mono", cssFontFamilies(preferences.code), DEFAULT_CODE_FONT_STACK], | ||
| // The composer falls back to whatever the sans preference resolves to. | ||
| ["--font-composer", cssFontFamilies(preferences.composer), "var(--font-sans)"], | ||
| ]; | ||
| for (const [variable, families, defaultStack] of assignments) { | ||
| if (families === null) { | ||
| root.style.removeProperty(variable); | ||
| } else { | ||
| root.style.setProperty(variable, `${families}, ${defaultStack}`); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| 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", [ | ||
| { label: "DM Sans", family: "DM Sans" }, | ||
| { 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; | ||
| } | ||
| } | ||
|
|
||
| /** Webfonts the app bundles; offered even before document.fonts has loaded them. */ | ||
| const BUNDLED_FAMILIES = new Set(["DM Sans", "JetBrains Mono"]); | ||
|
|
||
| export function availableFontOptions(options: readonly FontOption[]): readonly FontOption[] { | ||
| return options.filter( | ||
| (option) => BUNDLED_FAMILIES.has(option.family) || isFontFamilyAvailable(option.family), | ||
| ); | ||
| } | ||
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
|
macroscopeapp[bot] marked this conversation as resolved.
|
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
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.