Skip to content

feat(web): configurable fonts and sizes under Settings → Appearance - #5103

Open
StiensWout wants to merge 31 commits into
pingdotgg:mainfrom
StiensWout:agent/web-appearance-fonts
Open

feat(web): configurable fonts and sizes under Settings → Appearance#5103
StiensWout wants to merge 31 commits into
pingdotgg:mainfrom
StiensWout:agent/web-appearance-fonts

Conversation

@StiensWout

@StiensWout StiensWout commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What changed

Settings → Appearance gains a Fonts section: four surfaces, each with a family picker and a size slider, a live preview rendered in the resolved family and size, and a per-row reset.

Setting Covers Size range
Interface font everything outside code blocks and the terminal 12–20px
Prompt font the composer textarea and its placeholder only 12–20px
Code font code blocks, diffs, and file previews 10–18px
Terminal font terminal output 8–20px

The family pickers are grouped by category (Sans serif / Monospace) and filtered to faces that are actually installed, probed with canvas metrics — document.fonts.check() reports true for absent families, so it cannot filter anything. "Custom…" accepts any name: it edits a local draft that commits 400ms after typing pauses (or on Enter/blur) and only once the name resolves, so the applied font holds while a partial name is typed.

Cell-grid surfaces stay monospace. The terminal draws on a fixed cell lattice, so a proportional face renders its text narrower than its own cells and strands the cursor to the right. The Code and Terminal pickers offer monospace faces only, and the renderer independently measures the requested family and falls back to the default when it is not monospace, so a previously stored value cannot leave the terminal in that state.

The terminal size adapts to its canvas. The preference is a ceiling: narrow panes (splits, the side panel) slide the rendered size down until a classic 80-column grid fits, down to an 8px legibility floor, and widening slides it back up. The measurement lives in fit(), so drags, splits, and window resizes all pass through it.

The composer context strip degrades instead of colliding. Its controls truncate, and once even truncated labels cannot fit they animate shut to icons (measured from laid-out content, with hysteresis, restoring as space returns).

Under the hood:

  • eight client settings in the contracts schema (four families, four sizes), persisted client-side like the other appearance preferences, all covered by Restore defaults
  • the --font-sans / --font-mono theme tokens move out of @theme inline so Tailwind utilities reference the variables; body, pre/code, and the @pierre/diffs shadow-root hooks route through them
  • the interface size drives the root font size (and with it every rem-based dimension); prompt and code sizes stay in absolute pixels so they do not scale twice
  • custom names are normalized and quoted before reaching CSS or the canvas font shorthand, where an unquoted non-ident name silently voids the whole declaration
  • font application survives async settings hydration, including terminals created while settings were still loading

Validation

  • full web suite (1760 tests), desktop settings tests, contracts tests
  • web + desktop + contracts typecheck, lint, formatting
  • end-to-end against a live dev server: picker filtering, live application per surface, reload persistence, terminal width fitting, and the strip's collapse/restore behaviour

🤖 Generated with Claude Code


Note

Medium Risk
Interface font size scales all rem-based layout globally; terminal font fitting and async hydration re-apply paths add edge cases, but changes are localized to appearance settings and rendering surfaces with validation for monospace terminals.

Overview
Adds Settings → Appearance → Fonts: four contexts (interface, prompt, code, terminal) with family pickers, size sliders, live previews, and restore-defaults integration. Eight new client settings land in contracts (four families, four clamped sizes).

Runtime application: appearanceFonts.ts normalizes family names, probes installed faces via canvas (not document.fonts.check()), validates monospace for cell-grid surfaces, and FontAppearanceSync writes --font-sans, --font-mono, --font-composer, and size tokens on the document root. Interface size drives root font-size (rem layout); prompt and code sizes stay in px. CSS moves font tokens to @theme so overrides work; body, composer, chat code, and @pierre/diffs hooks route through the variables.

Terminal: ThreadTerminalDrawer applies fontFamilyTerminal / fontSizeTerminal at create and on live updates; proportional families are rejected; narrow panes auto-shrink rendered size toward an 80-column fit via fittedTerminalFontSize.

Composer strip: BranchToolbar collapses context labels to icons when space is tight (useLabelsOverflow, resize/font-load aware, hysteresis).

UI polish: Glass opacity slider class renamed to .settings-slider; composer prompt size drops hardcoded Tailwind text sizes in favor of .composer-editor-surface.

Reviewed by Cursor Bugbot for commit a6f5f65. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add configurable font families and sizes to Settings → Appearance

  • Adds four font family selectors (Interface, Prompt, Code, Terminal) and corresponding size sliders to the Appearance settings panel, with live preview cards for each.
  • Introduces appearanceFonts.ts with utilities for font stack normalization, canvas-based font availability detection, monospace validation, and CSS variable application via applyAppearanceFontVariables.
  • Adds font size and family fields (fontSizeInterface, fontSizePrompt, fontSizeCode, fontSizeTerminal, fontFamilySans, fontFamilyComposer, fontFamilyCode, fontFamilyTerminal) to ClientSettingsSchema in packages/contracts/src/settings.ts.
  • Syncs settings to CSS variables on the document root via a new FontAppearanceSync component mounted in the app shell.
  • The embedded terminal reads fontFamilyTerminal and fontSizeTerminal from client settings, applies them at creation, and updates live; the terminal surface also dynamically reduces font size to preserve at least 80 columns within available canvas width.
  • The BranchToolbar gains overflow-aware compact mode that collapses labels to icons under constrained widths, triggered by a new useLabelsOverflow hook that accounts for font changes.
  • Behavioral Change: the glass opacity slider CSS class and variables are renamed from .glass-opacity-slider/--glass-slider-* to .settings-slider/--settings-slider-*.

Macroscope summarized a6f5f65.

Adds a Fonts section to Appearance with four client-persisted
preferences — interface (sans), composer, code, and terminal font
families. Family only; sizing is deliberately left for a follow-up.

- contracts: four FontFamilyPreference client settings (empty string
  means default), patchable per key
- the font theme tokens move out of the inline Tailwind theme so
  utilities reference the variables, and a FontAppearanceSync applies
  custom families as CSS variable overrides with the default stacks
  kept as fallbacks
- the composer gets its own --font-composer variable (defaults to the
  sans stack) applied on the editor surface, so prompt writers can use
  a mono face without changing the rest of the app
- the terminal preference feeds the Ghostty surface's font option and
  live setFont from pingdotgg#4860's groundwork; the bundled Nerd Font symbols
  fallback keeps prompt glyphs on any custom face
- each settings row shows a live preview rendered in the resolved
  stack, with per-row reset; custom names are normalized and quoted
  through tested pure helpers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3e03bdb5-1fbb-42c3-9e3a-0a6bbf0d9ace

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Jul 31, 2026
Comment thread apps/web/src/components/ThreadTerminalDrawer.tsx
StiensWout and others added 2 commits July 31, 2026 13:36
…views

Replaces the free-text family inputs with dropdowns of curated faces
filtered through document.fonts.check so only fonts that will actually
render are offered (bundled webfonts always appear); a Custom… option
keeps arbitrary families possible. Each option renders in its own face,
and every row gains a preview card styled like the real surface — chat
text, a mini composer, a highlighted code snippet, and a dark terminal
prompt — rendered in the resolved stack, matching mobile's Appearance
screen style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pre and code elements in the preview cards carried the preflight
mono font-family, overriding the card's resolved stack, so those two
previews never changed with the dropdown. They inherit now; also brace
the comment-looking literal flagged by lint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/appearanceFonts.ts
@StiensWout StiensWout changed the title feat(web): configurable font families under Settings → Appearance [WIP] feat(web): configurable font families under Settings → Appearance Jul 31, 2026
StiensWout and others added 3 commits July 31, 2026 13:40
…views

document.fonts.check() answers true for families that are not installed
(nothing needs loading), so the dropdowns offered fonts that silently
fell back to the default stack and the sans-side previews appeared not
to update. Availability now comes from canvas metric probing: a family
counts as available when substituting it before a generic changes the
measured advance.

The terminal preview card also follows the app theme now — the real
terminal renders on the app background in light mode, so a hardcoded
dark card was inconsistent theming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The base styles hardcoded literal font stacks on body and pre/code, so the
runtime --font-sans/--font-mono overrides from Settings -> Appearance only
reached the preview cards. Reference the theme tokens instead; the literal
stacks stay single-sourced in the @theme block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/components/settings/SettingsPanels.tsx Outdated
Comment thread apps/web/src/components/ThreadTerminalDrawer.tsx Outdated
Comment thread apps/web/src/components/settings/SettingsPanels.tsx Outdated
Comment thread apps/web/src/components/settings/SettingsPanels.tsx
@macroscopeapp

macroscopeapp Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces a new user-facing feature (configurable fonts and sizes in Settings → Appearance) with new UI components, settings fields, font detection logic, and terminal auto-sizing. New features warrant human review.

You can customize Macroscope's approvability policy. Learn more.

Client settings hydrate after mount, which dropped two things on reload: the
terminal font (setFont fired while the surface was still loading, so the
preference never reached the created terminal) and the custom-font input
(customMode initialized from the pre-hydration empty value, leaving the
field hidden behind a "Custom" trigger). The terminal re-applies the current
preference once the surface exists, and the input visibility now derives
from the value itself.

Also groups mixed dropdowns (composer) into labeled Sans serif / Monospace
sections and removes the composer's redundant default entry - the reset
affordance already returns it to following the interface font.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jul 31, 2026
StiensWout and others added 3 commits July 31, 2026 14:16
…om names

Every font dropdown now offers the full catalog split into bold Sans serif /
Monospace sections, so any surface can point at any face. The custom input
edits a draft that only commits once the typed name probes as an installed
font - the current font holds (and the field flags invalid) while a partial
or unknown name is typed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… tokens

The @pierre/diffs surfaces (file previews, chat diffs, annotatable code,
search lines) render inside shadow roots with their own literal font stack
as fallback, so the code font preference never reached them. The library
consults --diffs-font-family/--diffs-header-font-family as override hooks
and custom properties inherit across the shadow boundary, so defining the
hooks once at :root covers every consumer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r preview

Restore defaults now lists and resets the four font-family preferences (the
General page's restore is intentionally global), and the composer preview
falls back to the resolved interface stack so it matches the runtime
var(--font-composer) chain instead of the bare default sans stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/components/settings/SettingsPanels.tsx Outdated
Per review direction the interface and code fonts stay on the theme
defaults; only the prompt textarea (people who prefer writing prompts in a
mono face) and the terminal remain configurable. The textarea setting is
retitled to make its scope explicit - the rest of the composer follows the
interface font.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@StiensWout StiensWout changed the title [WIP] feat(web): configurable font families under Settings → Appearance feat(web): configurable prompt textarea and terminal fonts under Settings → Appearance Jul 31, 2026
@StiensWout StiensWout changed the title feat(web): configurable prompt textarea and terminal fonts under Settings → Appearance feat(web): configurable font families under Settings → Appearance Jul 31, 2026
StiensWout and others added 5 commits July 31, 2026 14:40
An emptied custom font field now resets the preference to the default
instead of silently keeping the previous font behind an empty-looking
input; partial or unknown names still hold the current font while typing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The final split keeps every surface configurable: interface (labels,
controls, messages, composer chrome), prompt (textarea and placeholder
only), code (code blocks, diffs, file previews), and terminal. Every
dropdown now offers the same Default entry, fixing the prompt/terminal
inconsistency.

The custom input no longer commits on every keystroke: it edits a local
draft and commits 400ms after typing pauses (or on Enter/blur), so the app
does not reflow mid-word and the unknown-name flag waits for the pause.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Quote custom terminal families before they reach the canvas font string:
  a non-ident name ("3270 Nerd Font", "M+ 1m") made the whole string
  invalid CSS, so the assignment silently kept the previous font.
- Point the DM Sans catalog entry at the registered "DM Sans Variable"
  family; the bundled webfont never registers plain "DM Sans", so the
  option rendered in the serif fallback and only applied by coincidence.
- Cap the custom input at the schema's 200-character limit.
- Cover fontOptionCategories and non-ident quoting with tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The custom input now replaces the dropdown instead of stacking beneath it,
so entering custom mode no longer grows the row and pushes the rest of the
page down; it autofocuses, and Escape discards uncommitted typing without
closing the settings page.

Typefaces differ in x-height at the same pixel size (DM Sans 0.51 vs
JetBrains Mono 0.55), so switching family also changed apparent text size.
Each surface now carries a font-size-adjust measured from its own default
stack: the platform default renders unchanged, and any chosen font is
scaled to match it. Measurements are re-taken once webfonts finish loading,
since an early probe describes the fallback face rather than the default.

The sidebar PR number follows the interface font (tabular digits keep it
from reflowing) instead of the code font.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/components/settings/SettingsPanels.tsx
Selecting Custom no longer prefills the outgoing family, so the field is
ready to type into; the applied font holds until a valid name is entered,
and an empty field is not flagged as invalid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/components/settings/SettingsPanels.tsx Outdated
Comment thread apps/web/src/components/settings/SettingsPanels.tsx Outdated
Comment thread apps/web/src/components/settings/SettingsPanels.tsx Outdated
Comment thread apps/web/src/index.css Outdated
StiensWout and others added 4 commits August 1, 2026 08:58
Four rows all opening with "Used ..." read like a filled-in template and
restated their own titles. Each line now just names what it covers, and the
prompt row carries the reason people actually asked for it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Matching x-heights exactly overcorrects: real Courier New sits at ~0.42 em
against DM Sans' 0.51, so an exact match scaled it 21% up and its capitals
and ascenders towered over everything else. The correction is now capped at
6%, and each preview normalizes its own stack rather than a shared default
so it shows the size the app will actually render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the font-size-adjust correction with explicit control, mirroring
the mobile Appearance sliders: interface, prompt, code, and terminal each
get a size in CSS pixels alongside their family.

The x-height correction could never satisfy every face - matching exactly
blew out Courier New's proportions, capping it left the difference visible -
so the size is the user's call now. Ranges are clamped in the schema (the
interface size drives the root font size, and with it every rem-based
dimension) and each preview renders at the size its surface will use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/components/settings/SettingsPanels.tsx
Comment thread apps/web/src/components/settings/SettingsPanels.tsx
Comment thread apps/web/src/appearanceFonts.ts
…grows it

The branch, workspace, and environment controls sit in a fixed-width strip,
so a larger interface font pushes their labels into each other. The strip is
now a query container and the labels hide below a rem-based threshold -
rem scales with the interface size, so the controls fall back to their icons
exactly when the text would start to overlap, at any font size or window
width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/components/BranchToolbarEnvironmentSelector.tsx
Comment thread apps/web/src/components/BranchToolbarBranchSelector.tsx
Comment thread apps/web/src/components/BranchToolbarEnvModeSelector.tsx Outdated
…labels only on overflow

The terminal and code views render on a fixed cell grid, so a proportional
face like Helvetica Neue cannot line up - their pickers now offer monospace
faces only, while the interface and prompt keep the full catalog.

The composer strip's labels collapsed at a guessed container width, which
hid them even when they fitted. It now measures how far each label is
clipped and collapses only once they genuinely stop fitting, remembering the
required width so hiding them cannot flip the decision back.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Aug 1, 2026
Comment thread apps/web/src/components/BranchToolbar.tsx Outdated
Comment thread apps/web/src/components/BranchToolbar.tsx
Comment thread apps/web/src/components/BranchToolbar.tsx Outdated
A proportional family draws each glyph on its own advance while the cursor
and selection sit on the grid's single cell width, so the text drifts left
of its cells and the cursor strands to the right - the ragged gaps and
misplaced cursor seen with Helvetica Neue.

The renderer now measures the requested family and falls back to the default
when it is not monospace, so a value stored before the picker was restricted
cannot leave the terminal in that state. Unmeasurable environments and
absent faces are treated as monospace, leaving the normal fallback chain in
charge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/terminal/ghostty/surface.ts
StiensWout and others added 3 commits August 1, 2026 10:38
The size preference is a ceiling, not an absolute: narrow panes (splits, the
side panel) now shrink the rendered size until a classic 80-column grid
fits, down to a legibility floor, and widening slides it back up to the
preference. The measurement lives in fit(), so pane drags, splits, and
window resizes all pass through it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nt not boxes

The workspace trigger refused to shrink, so a large interface font made it
overflow and paint over the branch name. Every control now truncates, which
makes overlap impossible; icons-only remains the backstop for when even
truncated content cannot fit.

The overlap measurement had three faults: it summed flex-stretched boxes
(always 'full', so it always wanted to collapse), it counted a Base UI
hidden form element as 223px of phantom content, and the compiler-memoized
useEffectEvent left observers reading the first render's null element
forever. It now sums laid-out content (skipping hidden artifacts and the
absolutely-positioned compact labels, which stay measurable at natural
width), reads state through a render-synced ref, and applies hysteresis so
the boundary cannot flap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Labels slide shut (max-width to zero with a fade) instead of vanishing.
Squeezed-shut labels still report their full text through scrollWidth, so
the overflow measurement reads the same value in both states and stays
transition-invariant - a mid-animation measurement cannot flip the decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@StiensWout StiensWout changed the title feat(web): configurable font families under Settings → Appearance feat(web): configurable fonts and sizes under Settings → Appearance Aug 1, 2026
Comment thread apps/web/src/components/BranchToolbarEnvironmentSelector.tsx
…l families

Three review findings:

- The code size preference set --font-size-code but nothing read it. Block
  code and highlighted chat blocks now take it; inline code stays relative to
  its sentence so it cannot tower over the surrounding prose.
- The custom family field autofocused whenever it mounted, so opening
  Appearance with a persisted non-catalog family stole the keyboard. Focus
  now follows the deliberate act of picking Custom from the dropdown.
- The terminal silently falls back from a proportional face, while the row
  still accepted and previewed one. The terminal row now refuses a custom
  family that is not monospace (flagging the field instead of pretending it
  took the value), and its preview renders the fallback the grid will
  actually draw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/components/settings/SettingsPanels.tsx
…e family

The interactive environment picker rendered a bare SelectValue, so the
overflow measurement never saw it and it stayed full-width while its
neighbours collapsed. It now carries the same label marker and transition.

The Code row accepted any custom family, including proportional faces that
break column alignment in code blocks, diffs, and file previews. It requires
monospace now, matching Terminal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/index.css Outdated
Comment thread apps/web/src/components/settings/SettingsPanels.tsx Outdated
Comment thread apps/web/src/components/BranchToolbar.tsx
…ted labels

Three review findings:

- High: track clicks and keyboard steps fire input and change in one task,
  before any re-render, so the change handler read a stale draft and dropped
  the commit. The draft now mirrors into a ref written inside the input
  handler itself, so the commit reads the same turn's value.
- The global pre font-size rule beat text-size utilities and inherited sizes
  on unrelated pre surfaces (terminal previews, approvals). The code size is
  now scoped to chat markdown code blocks; diffs and file previews already
  take it through --diffs-font-size.
- SelectValue truncates internally, so the outer label marker's scrollWidth
  matched its clipped box and the workspace/environment labels fell out of
  the strip's overflow measurement. The text width is now the largest
  scrollWidth in the label subtree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/web/src/index.css Outdated
The 16px focused-field floor exists for the touch-browser zoom quirk, which
desktop browsers do not have - a narrow desktop window must not silently
override a smaller chosen prompt size. Gate the media query on a coarse
pointer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant