Skip to content
Open
Show file tree
Hide file tree
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 Jul 31, 2026
3aaf1b3
feat(web): font dropdowns with availability filtering and surface pre…
StiensWout Jul 31, 2026
dc3ebeb
fix(web): make code and terminal font previews follow the selection
StiensWout Jul 31, 2026
80612c4
fix(web): reliable font availability probing and theme-consistent pre…
StiensWout Jul 31, 2026
0d7b5f5
test(desktop): add font-family keys to the client settings fixture
StiensWout Jul 31, 2026
a31d2bc
fix(web): route body and code font-family through the theme tokens
StiensWout Jul 31, 2026
d423e00
fix(web): survive async settings hydration and group font dropdowns
StiensWout Jul 31, 2026
bec7059
feat(web): unified font catalog per dropdown and draft-committed cust…
StiensWout Jul 31, 2026
47b1e92
fix(web): route shadow-root code surfaces through the appearance font…
StiensWout Jul 31, 2026
df68214
fix(web): include font settings in restore defaults and align compose…
StiensWout Jul 31, 2026
1218240
refactor(web): scope font settings to the prompt textarea and terminal
StiensWout Jul 31, 2026
eadee77
fix(web): commit an explicit clear of the custom font input
StiensWout Jul 31, 2026
4e4cf0f
fix(web): plainer copy for the font settings rows
StiensWout Jul 31, 2026
d932f98
feat(web): restore all four font settings with a calmer custom input
StiensWout Jul 31, 2026
61df965
fix(web): polish pass on font settings
StiensWout Aug 1, 2026
2e35470
fix(web): stop the custom-font shift and normalize apparent font size
StiensWout Aug 1, 2026
9001d78
fix(web): start the custom font field empty
StiensWout Aug 1, 2026
3bafffd
fix(web): plainer font setting descriptions
StiensWout Aug 1, 2026
1a3481a
fix(web): cap the size correction for faces with unusual proportions
StiensWout Aug 1, 2026
ddb3b48
docs(web): correct the size-adjust docblock after clamping
StiensWout Aug 1, 2026
04c529a
feat(web): font size sliders instead of automatic size normalization
StiensWout Aug 1, 2026
162d5b4
feat(web): collapse the composer context strip to icons when text out…
StiensWout Aug 1, 2026
ed70fa4
fix(web): keep proportional fonts out of the grid surfaces, collapse …
StiensWout Aug 1, 2026
2977bae
fix(web): refuse proportional faces in the terminal grid
StiensWout Aug 1, 2026
c299943
feat(web): slide the terminal font size with the canvas width
StiensWout Aug 1, 2026
0fcaa3b
fix(web): truncate the context strip before collapsing, measure conte…
StiensWout Aug 1, 2026
7580c5d
feat(web): animate the context strip label collapse
StiensWout Aug 1, 2026
c2677c2
fix(web): consume the code size token, stop focus theft, gate termina…
StiensWout Aug 1, 2026
805813e
fix(web): include the environment label in the collapse, gate the cod…
StiensWout Aug 1, 2026
5ce99c2
fix(web): commit slider steps reliably, scope pre sizing, measure nes…
StiensWout Aug 1, 2026
a6f5f65
fix(web): apply the prompt-size floor only on touch devices
StiensWout Aug 1, 2026
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
8 changes: 8 additions & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ const clientSettings: ClientSettings = {
diffIgnoreWhitespace: true,
environmentIdentificationMode: "artwork",
favorites: [],
fontFamilyCode: "",
fontFamilyComposer: "",
fontFamilySans: "",
fontFamilyTerminal: "",
fontSizeCode: 13,
fontSizeInterface: 16,
fontSizePrompt: 14,
fontSizeTerminal: 12,
glassOpacity: 80,
providerModelPreferences: {},
sidebarAutoSettleAfterDays: 3,
Expand Down
82 changes: 82 additions & 0 deletions apps/web/src/appearanceFonts.test.ts
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);
});
});
255 changes: 255 additions & 0 deletions apps/web/src/appearanceFonts.ts
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`);
Comment thread
cursor[bot] marked this conversation as resolved.
}

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[] {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
return options.filter(
(option) => BUNDLED_FAMILIES.has(option.family) || isFontFamilyAvailable(option.family),
);
}
Loading
Loading